feat: Rust runtime adapter + OC-FE Tauri frontend#1
Conversation
- Add RuntimeBackend toggle (stock vs rust) in config.ts - New gateway-rust-adapter.ts for direct Rust runtime communication (health checks, process spawn, shutdown RPC, dashboard URL) - Gateway routing: each export delegates to Rust adapter when backend='rust', stock path completely unchanged - Fix CSP to allow inline scripts in dashboard (unsafe-inline) - Session seeding supports HTTP/WS port split for Rust runtime - Rust runtime on port 18800, stock OpenClaw keeps 18789 - Env vars: OPENCLAW_RUNTIME_BACKEND, OPENCLAW_RS_BINARY_PATH, OPENCLAW_RS_CONFIG, OPENCLAW_RS_HTTP_PORT, OPENCLAW_RS_GATEWAY_TOKEN Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
📝 WalkthroughWalkthroughAdds OpenClaw Desktop: a new Windows Electron app that detects and manages a local OpenClaw gateway (stock CLI or Rust backend), performs health checks, starts/stops/restarts the runtime, retrieves dashboard tokens, provides a recovery/status UI, and integrates tray, single-instance, and IPC preload functionality. Changes
Sequence Diagram(s)sequenceDiagram
participant App as Main Process
participant Gateway as Gateway Module
participant RustAdapter as Rust Adapter
participant CLI as Gateway CLI
participant HTTP as Gateway HTTP
participant Renderer as Renderer / Dashboard
participant Recovery as Recovery Page
App->>Gateway: checkGatewayHealth()
activate Gateway
alt backend == rust
Gateway->>RustAdapter: checkGatewayHealth()
RustAdapter->>HTTP: GET /health (with timeout)
HTTP-->>RustAdapter: 200 / error
RustAdapter-->>Gateway: GatewayState
else backend == stock
Gateway->>CLI: exec status/health commands
CLI-->>Gateway: command result
Gateway->>HTTP: optional GET /health
HTTP-->>Gateway: 200 / error
end
Gateway-->>App: GatewayState
deactivate Gateway
alt state != healthy
App->>Gateway: startGatewayIntegrationPoint()
activate Gateway
alt backend == rust
Gateway->>RustAdapter: startGatewayIntegrationPoint()
RustAdapter->>RustAdapter: spawn detached process (maybe with --config)
RustAdapter->>HTTP: poll /health until healthy or timeout
RustAdapter-->>Gateway: GatewayLaunchResult
else
Gateway->>CLI: exec start command
CLI-->>Gateway: result
Gateway->>HTTP: poll /health until healthy or timeout
Gateway-->>App: GatewayLaunchResult
end
deactivate Gateway
end
alt healthy
App->>Gateway: getDashboardLaunchUrl()
Gateway->>CLI: exec dashboard command (or RustAdapter for token)
CLI-->>Gateway: tokenized URL / token
Gateway-->>App: dashboard URL
App->>Renderer: loadURL(dashboard URL)
Renderer-->>App: rendered
else not healthy
App->>Recovery: load status.html
Recovery-->>App: displayed
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5b8584e991
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const httpControlUrl = `http://127.0.0.1:${desktopConfig.gateway.httpControlPort}/rpc`; | ||
|
|
||
| // Try the HTTP control port first (preferred for Rust runtime). | ||
| for (const rpcUrl of [httpControlUrl, url]) { |
There was a problem hiding this comment.
Restrict Rust RPC calls to Rust control endpoint
Do not fall back to desktopConfig.gateway.baseUrl when sending Rust control RPCs. In Rust mode this fallback can target the stock gateway port (default 127.0.0.1:18789), so a failed call to the Rust control plane can end up posting gateway.shutdown to the stock runtime instead. This breaks the advertised port/runtime separation and can stop the wrong service when Rust is unhealthy or missing.
Useful? React with 👍 / 👎.
| rustProcess = spawn(binaryPath, args, { | ||
| cwd: desktopConfig.gateway.workingDirectory, | ||
| detached: true, | ||
| stdio: 'ignore', | ||
| windowsHide: true, |
There was a problem hiding this comment.
Handle child process spawn errors before unref
spawn(...) errors are emitted asynchronously on the child process, so the surrounding try/catch will not catch cases like invalid cwd or execution failures. Because no error listener is attached, those failures become unhandled 'error' events and can crash the Electron main process during startup attempts. Add an error handler and convert it into a failed launch result.
Useful? React with 👍 / 👎.
… clean dead code - postRpc: Only target Rust HTTP control port, never fall back to stock gateway (prevents shutting down the wrong runtime) - spawn: Add error handler on child process before unref - tryLoadDashboard: Wire session seeding so dashboard auto-connects - Remove unused: withGatewayDetail, detectDashboardAuthIssue, clearDashboardOriginState, dashboardAuthIssuePattern Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (2)
openclaw-desktop/src/gateway.ts (1)
351-362: Consider adding early return for stop when already stopped.The function returns early for
startwhen already healthy but doesn't do the same forstopwhen already stopped. While the CLI stop command is likely idempotent, adding a similar check would be consistent with the Rust adapter'sstopGatewayIntegrationPointbehavior (which returns early at lines 295-303).♻️ Suggested improvement for consistency
if (action === 'start' && existingState.healthy) { return { attempted: false, action: 'noop', ok: true, message: 'Gateway already appears healthy.', state: existingState, }; } + + if (action === 'stop' && !existingState.healthy && existingState.kind === 'stopped') { + return { + attempted: false, + action: 'noop', + ok: true, + message: 'Gateway already stopped.', + state: existingState, + }; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@openclaw-desktop/src/gateway.ts` around lines 351 - 362, runGatewayControl currently returns early when action === 'start' and the gateway is healthy but lacks a symmetrical early return for action === 'stop' when the gateway is already stopped; update runGatewayControl to call checkGatewayHealth(), detect when action === 'stop' and existingState.healthy is false (or an explicit stopped flag on existingState), and return a GatewayLaunchResult with attempted: false, action: 'noop', ok: true, a clear message like 'Gateway already stopped.', and state: existingState to mirror the start case and match stopGatewayIntegrationPoint behavior.openclaw-desktop/src/gateway-rust-adapter.ts (1)
21-48: Consider extracting shared utilities.
delay,normalizeError, andfetchJsonare duplicated fromgateway.ts. Consider extracting these to a shared utility module (e.g.,utils.ts) to reduce duplication and ensure consistent behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@openclaw-desktop/src/gateway-rust-adapter.ts` around lines 21 - 48, The functions delay, normalizeError, and fetchJson are duplicated; extract them into a shared utils module (e.g., create utils.ts) that exports delay, normalizeError, and fetchJson with the same signatures and behavior (preserve AbortController/timeout logic in fetchJson and types). Replace the local definitions in gateway-rust-adapter.ts (and gateway.ts) with imports from the new utils module and remove the duplicated implementations so both files import and use utils.delay, utils.normalizeError, and utils.fetchJson.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@openclaw-desktop/package.json`:
- Line 23: The "verify" npm script in package.json currently runs "node
scripts/verify.mjs" which resolves relative to openclaw-desktop and triggers
MODULE_NOT_FOUND; update the "verify" script entry to point to the
repository-root verifier (e.g., change the value for the "verify" script from
"node scripts/verify.mjs" to "node ../scripts/verify.mjs") so npm runs the
correct script from the repo root.
In `@openclaw-desktop/README.md`:
- Around line 116-125: The README's Configuration table is missing the new Rust
backend env vars used in openclaw-desktop/src/config.ts; update the table to
include OPENCLAW_RUNTIME_BACKEND, OPENCLAW_RS_BINARY_PATH, OPENCLAW_RS_CONFIG,
OPENCLAW_RS_HTTP_PORT, and OPENCLAW_RS_GATEWAY_TOKEN with sensible defaults and
short descriptions so users can discover and configure the Rust adapter;
reference the variables exactly as named and mirror any default values or notes
from config.ts (e.g., backend selection, binary path, config file, HTTP port,
gateway auth token) to keep docs in sync with the code.
In `@openclaw-desktop/renderer/status.html`:
- Around line 99-112: Update the recovery UI copy to be backend-aware by
switching the text in the elements with IDs summary, start, restart, stop, and
open based on the active backend mode; detect the backend mode via the existing
renderer-to-main bridge (e.g., a preload-exposed flag or RPC like
window.api.getBackendMode()/invoke("backendMode")) and, when mode === "rust" or
a no-login path is reported, replace the stock CLI/service strings ("Start
gateway service", "Request fresh dashboard session", and the summary paragraph)
with Rust-specific text (e.g., "Spawn gateway process", "Request dashboard
session (no-login mode)", and a matching summary) and adjust restart/stop labels
to process-aware wording; ensure this logic runs on page load and also when the
user clicks the refresh button so the UI always reflects the current backend
mode.
In `@openclaw-desktop/src/config.ts`:
- Around line 53-72: The gateway.baseUrl and dashboard.url defaults currently
always use the stock port 18789; when resolvedBackend === 'rust' update the
default values to use the Rust gateway port (use rustHttpControlPort if
Number.isFinite(rustHttpControlPort) else 18890) so the UI targets the Rust
WS/dashboard by default. Concretely, change how gateway.baseUrl and
dashboard.url are computed in the config block: if resolvedBackend === 'rust'
construct a default URL that points to 127.0.0.1:{rustPort} (and include
trailing slash for dashboard.url as before), otherwise keep the existing 18789
defaults; reference the existing symbols gateway.baseUrl, dashboard.url,
resolvedBackend and rustHttpControlPort to locate and modify the logic.
In `@openclaw-desktop/src/gateway-rust-adapter.ts`:
- Around line 314-321: The fallback SIGTERM against rustProcessPid can hit a
stale PID; before calling process.kill(rustProcessPid, 'SIGTERM') add a safety
check that the PID is still the expected Rust process: first test existence with
process.kill(rustProcessPid, 0) inside a try, and then verify the process
identity (e.g., read /proc/<pid>/cmdline or run ps to compare the command/name)
to ensure it matches the Rust adapter binary or expected args; only send SIGTERM
if both checks pass. Implement this check as a helper (e.g., isSameProcess(pid,
expectedCmd)) and use it in the block where rustProcessPid is used to decide
whether to kill.
- Around line 18-19: The module-level rustProcess/rustProcessPid tracking is
racy because startGatewayIntegrationPoint can be invoked concurrently; add an
in-flight guard (e.g. a module-scoped boolean like isStarting or a simple mutex)
and use it inside startGatewayIntegrationPoint to serialize starts: immediately
return the existing rustProcess/rustProcessPid if a process is already running,
and if not, check and set isStarting before performing the health-check and
spawn; ensure you clear isStarting in all exit paths (success, error, or early
return) and only assign rustProcess/rustProcessPid after the child is
successfully spawned so duplicate processes are not created or left orphaned.
In `@openclaw-desktop/src/main.ts`:
- Around line 250-298: tryLoadDashboard currently only loads the URL and injects
the titlebar, but never runs the session-seeding/auth-retry helpers defined
above it, so the Rust gateway token and split HTTP/WS settings never reach the
dashboard; modify tryLoadDashboard to await the existing session-seeding and
auth-retry helper functions (the helper functions you defined above
tryLoadDashboard) before calling await window.loadURL(dashboardUrl), and ensure
their results (e.g., OPENCLAW_RS_GATEWAY_TOKEN and HTTP/WS settings) are passed
into the renderer (for example by adding them to the dashboard URL/hash or by
using window.webContents.executeJavaScript to set window-level variables) so the
dashboard boots with the correct Rust-mode configuration, then proceed with the
current CSS/JS injection as before.
- Around line 236-237: The code is logging a session-bearing URL from
getDashboardLaunchUrl(), leaking tokens; change the logging to redact sensitive
parts: compute dashboardUrl via getDashboardLaunchUrl() ??
desktopConfig.dashboard.url, but before calling log.info('Loading dashboard',
...) sanitize the URL returned by getDashboardLaunchUrl() (or if it contains a
query string or known token param name) by stripping query parameters or
replacing token/session params with "<REDACTED>" and only log the safe
components (hostname/path) or the fallback desktopConfig.dashboard.url; update
the log call that uses dashboardUrl to log the sanitized value instead
(referencing getDashboardLaunchUrl(), dashboardUrl and log.info).
- Around line 48-83: The onHeadersReceived handler
(window.webContents.session.webRequest.onHeadersReceived) is too broad and
strips X-Frame-Options/CSP for all responses and resource types, and
setWindowOpenHandler only covers window.open; add a will-navigate listener to
block or validate navigations against a trusted dashboard origin allowlist and
prevent main-frame navigations to non-allowed origins, and change the
onHeadersReceived logic to only mutate headers when details.resourceType ===
'mainFrame' and the response origin matches the trusted dashboard origin; also
sanitize the dashboard URL when logging from getDashboardLaunchUrl() to remove
any fragment/token before writing to logs.
In `@openclaw-desktop/src/preload.ts`:
- Around line 4-16: The preload currently exposes the full api object
(contextBridge.exposeInMainWorld('openclawDesktop', api)) including sensitive
methods startGateway/stopGateway/restartGateway/showDashboard to any loaded
page; restrict those by splitting or gating the bridge: expose a minimal safe
API (window control methods like minimizeWindow/maximizeWindow/closeWindow) to
all origins, and only expose or enable
startGateway/stopGateway/restartGateway/showDashboard for trusted local origins
by checking the renderer origin inside preload (e.g., evaluate location.origin
or a validated origin whitelist) before adding those functions to the exposed
object or by exposing a separate interface for trusted content; update the api
construction and the call to contextBridge.exposeInMainWorld accordingly so
untrusted remote dashboard cannot call gateway control functions.
---
Nitpick comments:
In `@openclaw-desktop/src/gateway-rust-adapter.ts`:
- Around line 21-48: The functions delay, normalizeError, and fetchJson are
duplicated; extract them into a shared utils module (e.g., create utils.ts) that
exports delay, normalizeError, and fetchJson with the same signatures and
behavior (preserve AbortController/timeout logic in fetchJson and types).
Replace the local definitions in gateway-rust-adapter.ts (and gateway.ts) with
imports from the new utils module and remove the duplicated implementations so
both files import and use utils.delay, utils.normalizeError, and
utils.fetchJson.
In `@openclaw-desktop/src/gateway.ts`:
- Around line 351-362: runGatewayControl currently returns early when action ===
'start' and the gateway is healthy but lacks a symmetrical early return for
action === 'stop' when the gateway is already stopped; update runGatewayControl
to call checkGatewayHealth(), detect when action === 'stop' and
existingState.healthy is false (or an explicit stopped flag on existingState),
and return a GatewayLaunchResult with attempted: false, action: 'noop', ok:
true, a clear message like 'Gateway already stopped.', and state: existingState
to mirror the start case and match stopGatewayIntegrationPoint behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2d2b4b80-94bb-4d67-a536-2f356d4e4c1f
⛔ Files ignored due to path filters (1)
openclaw-desktop/renderer/trayTemplate.pngis excluded by!**/*.png
📒 Files selected for processing (10)
openclaw-desktop/README.mdopenclaw-desktop/package.jsonopenclaw-desktop/renderer/status.htmlopenclaw-desktop/src/config.tsopenclaw-desktop/src/gateway-rust-adapter.tsopenclaw-desktop/src/gateway.tsopenclaw-desktop/src/main.tsopenclaw-desktop/src/preload.tsopenclaw-desktop/src/tray.tsopenclaw-desktop/tsconfig.json
| "dev": "npm run build && electron .", | ||
| "start": "electron .", | ||
| "typecheck": "tsc --noEmit -p tsconfig.json", | ||
| "verify": "node scripts/verify.mjs", |
There was a problem hiding this comment.
npm run verify points at the wrong script location.
npm resolves node scripts/verify.mjs relative to openclaw-desktop/package.json, but the provided verifier lives at the repo root. From this package directory the current script will fail with MODULE_NOT_FOUND.
One possible fix
- "verify": "node scripts/verify.mjs",
+ "verify": "node ../scripts/verify.mjs",📝 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.
| "verify": "node scripts/verify.mjs", | |
| "verify": "node ../scripts/verify.mjs", |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/package.json` at line 23, The "verify" npm script in
package.json currently runs "node scripts/verify.mjs" which resolves relative to
openclaw-desktop and triggers MODULE_NOT_FOUND; update the "verify" script entry
to point to the repository-root verifier (e.g., change the value for the
"verify" script from "node scripts/verify.mjs" to "node ../scripts/verify.mjs")
so npm runs the correct script from the repo root.
| ## Configuration | ||
|
|
||
| All settings have sensible defaults. Override with environment variables if needed: | ||
|
|
||
| | Variable | Default | Description | | ||
| |---|---|---| | ||
| | `OPENCLAW_GATEWAY_URL` | `http://127.0.0.1:18789` | Gateway base URL | | ||
| | `OPENCLAW_CLI_PATH` | `%APPDATA%\npm\openclaw.cmd` | Path to the OpenClaw CLI | | ||
| | `OPENCLAW_WORKDIR` | `%USERPROFILE%\.openclaw` | OpenClaw working directory | | ||
|
|
There was a problem hiding this comment.
Document the new Rust backend environment variables.
openclaw-desktop/src/config.ts now reads OPENCLAW_RUNTIME_BACKEND, OPENCLAW_RS_BINARY_PATH, OPENCLAW_RS_CONFIG, OPENCLAW_RS_HTTP_PORT, and OPENCLAW_RS_GATEWAY_TOKEN, but this table still only documents the stock gateway settings. That makes the new adapter hard to discover and configure from the package docs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/README.md` around lines 116 - 125, The README's
Configuration table is missing the new Rust backend env vars used in
openclaw-desktop/src/config.ts; update the table to include
OPENCLAW_RUNTIME_BACKEND, OPENCLAW_RS_BINARY_PATH, OPENCLAW_RS_CONFIG,
OPENCLAW_RS_HTTP_PORT, and OPENCLAW_RS_GATEWAY_TOKEN with sensible defaults and
short descriptions so users can discover and configure the Rust adapter;
reference the variables exactly as named and mirror any default values or notes
from config.ts (e.g., backend selection, binary path, config file, HTTP port,
gateway auth token) to keep docs in sync with the code.
| This shell now attaches to the real local OpenClaw runtime pattern on this machine: a Windows service-managed | ||
| gateway controlled through the OpenClaw CLI. On startup it will try to recover by starting the gateway if needed, | ||
| then request a fresh tokenized dashboard URL. If that still fails, this page stays visible as a truthful recovery | ||
| and control surface instead of pretending the runtime is simply off. | ||
| </p> | ||
| <p id="summary">Checking gateway state…</p> | ||
| </div> | ||
|
|
||
| <div class="actions"> | ||
| <button class="primary" id="refresh">Refresh status</button> | ||
| <button class="secondary" id="start">Start gateway service</button> | ||
| <button class="secondary" id="restart">Restart gateway service</button> | ||
| <button class="danger" id="stop">Stop gateway service</button> | ||
| <button class="ghost" id="open">Request fresh dashboard session</button> |
There was a problem hiding this comment.
Make the recovery copy backend-aware.
These strings hardcode the stock CLI/service flow, but this PR also adds a Rust backend with process spawn, shutdown RPC, and optional no-login behavior. In Rust mode the page will still tell users to “start gateway service” and request a “fresh tokenized dashboard session,” which points them at the wrong remediation path.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/renderer/status.html` around lines 99 - 112, Update the
recovery UI copy to be backend-aware by switching the text in the elements with
IDs summary, start, restart, stop, and open based on the active backend mode;
detect the backend mode via the existing renderer-to-main bridge (e.g., a
preload-exposed flag or RPC like
window.api.getBackendMode()/invoke("backendMode")) and, when mode === "rust" or
a no-login path is reported, replace the stock CLI/service strings ("Start
gateway service", "Request fresh dashboard session", and the summary paragraph)
with Rust-specific text (e.g., "Spawn gateway process", "Request dashboard
session (no-login mode)", and a matching summary) and adjust restart/stop labels
to process-aware wording; ensure this logic runs on page load and also when the
user clicks the refresh button so the UI always reflects the current backend
mode.
| gateway: { | ||
| backend: resolvedBackend === 'rust' ? 'rust' : 'stock', | ||
| baseUrl: process.env.OPENCLAW_GATEWAY_URL ?? 'http://127.0.0.1:18789', | ||
| healthPath: process.env.OPENCLAW_GATEWAY_HEALTH_PATH ?? '/health', | ||
| statusPath: process.env.OPENCLAW_GATEWAY_STATUS_PATH ?? '/status', | ||
| launchCommand: process.env.OPENCLAW_CLI_PATH ?? path.join(npmBinDir, 'openclaw.cmd'), | ||
| workingDirectory: process.env.OPENCLAW_WORKDIR ?? path.join(userHome, '.openclaw'), | ||
| healthTimeoutMs: 2500, | ||
| commandTimeoutMs: 12000, | ||
| startTimeoutMs: 20000, | ||
| stopTimeoutMs: 15000, | ||
| pollIntervalMs: 1000, | ||
| rustBinaryPath: process.env.OPENCLAW_RS_BINARY_PATH ?? path.join(userHome, '.openclaw', 'bin', 'openclaw-rs.exe'), | ||
| rustConfigPath: process.env.OPENCLAW_RS_CONFIG ?? path.join(userHome, '.openclaw', 'openclaw-rs.toml'), | ||
| httpControlPort: Number.isFinite(rustHttpControlPort) ? rustHttpControlPort : 18890, | ||
| rustToken: process.env.OPENCLAW_RS_GATEWAY_TOKEN, | ||
| }, | ||
| dashboard: { | ||
| url: process.env.OPENCLAW_DASHBOARD_URL ?? 'http://127.0.0.1:18789/', | ||
| }, |
There was a problem hiding this comment.
Rust mode still defaults the frontend to the stock gateway port.
When OPENCLAW_RUNTIME_BACKEND=rust is the only override, baseUrl and the fallback dashboard URL still default to 18789. openclaw-desktop/src/main.ts Line 118 derives the Rust WebSocket endpoint from desktopConfig.gateway.baseUrl, so the UI keeps targeting the stock gateway unless the user manually overrides it. This needs a Rust-specific WS/dashboard default instead of reusing the stock base URL.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/config.ts` around lines 53 - 72, The gateway.baseUrl and
dashboard.url defaults currently always use the stock port 18789; when
resolvedBackend === 'rust' update the default values to use the Rust gateway
port (use rustHttpControlPort if Number.isFinite(rustHttpControlPort) else
18890) so the UI targets the Rust WS/dashboard by default. Concretely, change
how gateway.baseUrl and dashboard.url are computed in the config block: if
resolvedBackend === 'rust' construct a default URL that points to
127.0.0.1:{rustPort} (and include trailing slash for dashboard.url as before),
otherwise keep the existing 18789 defaults; reference the existing symbols
gateway.baseUrl, dashboard.url, resolvedBackend and rustHttpControlPort to
locate and modify the logic.
| let rustProcess: ChildProcess | null = null; | ||
| let rustProcessPid: number | null = null; |
There was a problem hiding this comment.
Race condition risk with module-level process state.
These module-level variables track a single spawned process, but startGatewayIntegrationPoint lacks synchronization. As noted in the IPC handler context (main.ts:396-405), rapid successive calls can both pass the health check before either detects a spawned process, resulting in:
- Multiple processes spawned
- Only the last one tracked in
rustProcess/rustProcessPid - Orphan processes that won't be properly managed or cleaned up
Consider adding a mutex/lock or a "starting" state flag to serialize concurrent start attempts.
🔒 Proposed fix using a simple in-flight guard
let rustProcess: ChildProcess | null = null;
let rustProcessPid: number | null = null;
+let startInProgress = false;Then in startGatewayIntegrationPoint:
export async function startGatewayIntegrationPoint(): Promise<GatewayLaunchResult> {
+ if (startInProgress) {
+ return {
+ attempted: false,
+ action: 'noop',
+ ok: false,
+ message: 'Rust runtime start already in progress.',
+ state: await checkGatewayHealth(),
+ };
+ }
+
const existingState = await checkGatewayHealth();
if (existingState.healthy) {
return {
attempted: false,
action: 'noop',
ok: true,
message: 'Rust runtime already appears healthy.',
state: existingState,
};
}
+
+ startInProgress = true;
+ try {
// ... rest of start logic ...
+ } finally {
+ startInProgress = false;
+ }
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/gateway-rust-adapter.ts` around lines 18 - 19, The
module-level rustProcess/rustProcessPid tracking is racy because
startGatewayIntegrationPoint can be invoked concurrently; add an in-flight guard
(e.g. a module-scoped boolean like isStarting or a simple mutex) and use it
inside startGatewayIntegrationPoint to serialize starts: immediately return the
existing rustProcess/rustProcessPid if a process is already running, and if not,
check and set isStarting before performing the health-check and spawn; ensure
you clear isStarting in all exit paths (success, error, or early return) and
only assign rustProcess/rustProcessPid after the child is successfully spawned
so duplicate processes are not created or left orphaned.
| // Fall back to killing the tracked process. | ||
| if (rustProcessPid) { | ||
| try { | ||
| process.kill(rustProcessPid, 'SIGTERM'); | ||
| } catch { | ||
| // Process may already be gone. | ||
| } | ||
| } |
There was a problem hiding this comment.
Minor edge case: Stale PID could target wrong process.
If the Rust process exited without triggering the exit handler (e.g., if unref() disconnected event delivery and the process crashed), rustProcessPid might become stale. If the OS reassigned that PID to another process, process.kill(rustProcessPid, 'SIGTERM') could terminate an unrelated process.
This is a narrow window and unlikely in practice, but consider checking if the process is still alive or using a more robust process management approach if this becomes a concern.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/gateway-rust-adapter.ts` around lines 314 - 321, The
fallback SIGTERM against rustProcessPid can hit a stale PID; before calling
process.kill(rustProcessPid, 'SIGTERM') add a safety check that the PID is still
the expected Rust process: first test existence with
process.kill(rustProcessPid, 0) inside a try, and then verify the process
identity (e.g., read /proc/<pid>/cmdline or run ps to compare the command/name)
to ensure it matches the Rust adapter binary or expected args; only send SIGTERM
if both checks pass. Implement this check as a helper (e.g., isSameProcess(pid,
expectedCmd)) and use it in the block where rustProcessPid is used to decide
whether to kill.
| webPreferences: { | ||
| preload: preloadPath, | ||
| contextIsolation: true, | ||
| nodeIntegration: false, | ||
| sandbox: false, | ||
| }, | ||
| }); | ||
|
|
||
| window.webContents.setWindowOpenHandler(({ url }) => { | ||
| void shell.openExternal(url); | ||
| return { action: 'deny' }; | ||
| }); | ||
|
|
||
| // The gateway sends X-Frame-Options: DENY and CSP frame-ancestors 'none', | ||
| // which prevent the page from rendering inside Electron's BrowserWindow. | ||
| // Strip these headers so the dashboard can load in the desktop app. | ||
| window.webContents.session.webRequest.onHeadersReceived((details, callback) => { | ||
| const headers = { ...details.responseHeaders }; | ||
| delete headers['x-frame-options']; | ||
| delete headers['X-Frame-Options']; | ||
|
|
||
| // Relax CSP so the dashboard can run inside Electron. | ||
| // Remove frame-ancestors (blocks embedding) and loosen script-src | ||
| // (blocks inline event handlers like the Connect button's onclick). | ||
| for (const key of Object.keys(headers)) { | ||
| if (key.toLowerCase() === 'content-security-policy') { | ||
| headers[key] = headers[key]!.map((policy) => | ||
| policy | ||
| .replace(/frame-ancestors\s+[^;]+;?\s*/gi, '') | ||
| .replace(/script-src\s+[^;]+/gi, "script-src 'self' 'unsafe-inline' 'unsafe-eval'"), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| callback({ responseHeaders: headers }); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "main.ts" | grep -E "openclaw-desktop|src"Repository: RedWoodOG/openclaw-desktop
Length of output: 114
🏁 Script executed:
git ls-files | grep -E "openclaw-desktop.*main\.ts"Repository: RedWoodOG/openclaw-desktop
Length of output: 98
🏁 Script executed:
wc -l openclaw-desktop/src/main.tsRepository: RedWoodOG/openclaw-desktop
Length of output: 102
🏁 Script executed:
cat -n openclaw-desktop/src/main.tsRepository: RedWoodOG/openclaw-desktop
Length of output: 15735
🏁 Script executed:
rg "getDashboardLaunchUrl" openclaw-desktop/srcRepository: RedWoodOG/openclaw-desktop
Length of output: 540
🏁 Script executed:
rg "will-navigate" openclaw-desktop/srcRepository: RedWoodOG/openclaw-desktop
Length of output: 52
🏁 Script executed:
rg "resourceType" openclaw-desktop/srcRepository: RedWoodOG/openclaw-desktop
Length of output: 52
🌐 Web query:
Electron webRequest onHeadersReceived details parameter resourceType
💡 Result:
In Electron's webRequest.onHeadersReceived listener, the details parameter includes a resourceType property. This is a string indicating the type of resource being requested. Possible values: mainFrame, subFrame, stylesheet, script, image, font, object, xhr, ping, cspReport, media, webSocket, or other. This is documented in the official Electron WebRequest API documentation.
Citations:
- 1: https://electronjs.org/docs/latest/api/web-request
- 2: https://www.electronjs.org/docs/api/web-request/
- 3: https://www.electronjs.org/docs/api/web-request
🏁 Script executed:
cat -n openclaw-desktop/src/gateway.ts | head -100Repository: RedWoodOG/openclaw-desktop
Length of output: 3391
🏁 Script executed:
grep -A 20 "export async function getDashboardLaunchUrl" openclaw-desktop/src/gateway.tsRepository: RedWoodOG/openclaw-desktop
Length of output: 767
🏁 Script executed:
grep -A 20 "export async function getDashboardLaunchUrl" openclaw-desktop/src/gateway-rust-adapter.tsRepository: RedWoodOG/openclaw-desktop
Length of output: 378
Block main-frame navigations and scope the header rewrite to the trusted origin.
setWindowOpenHandler only covers popup creation via window.open(). A normal link click or location.href = ... can replace the page with content from a different origin. The session-wide onHeadersReceived callback then strips X-Frame-Options and rewrites CSP for that new origin too, turning an arbitrary navigation into privileged content. The handler lacks both origin filtering and resourceType checks.
Additionally, the dashboard URL logged at line 195 may contain a token in the URL hash (e.g., #token=... returned by getDashboardLaunchUrl()), leaking it to logs.
Add a will-navigate handler with an allowlist, and restrict the header rewrite to the trusted dashboard origin and main-frame requests only using the resourceType property in the onHeadersReceived details.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/main.ts` around lines 48 - 83, The onHeadersReceived
handler (window.webContents.session.webRequest.onHeadersReceived) is too broad
and strips X-Frame-Options/CSP for all responses and resource types, and
setWindowOpenHandler only covers window.open; add a will-navigate listener to
block or validate navigations against a trusted dashboard origin allowlist and
prevent main-frame navigations to non-allowed origins, and change the
onHeadersReceived logic to only mutate headers when details.resourceType ===
'mainFrame' and the response origin matches the trusted dashboard origin; also
sanitize the dashboard URL when logging from getDashboardLaunchUrl() to remove
any fragment/token before writing to logs.
| const dashboardUrl = (await getDashboardLaunchUrl()) ?? desktopConfig.dashboard.url; | ||
| log.info('Loading dashboard', { dashboardUrl }); |
There was a problem hiding this comment.
Don't log the tokenized dashboard URL.
getDashboardLaunchUrl() returns a session-bearing URL, and this writes it verbatim to electron-log. That leaks the dashboard token into local log files.
Redact the sensitive parts before logging
const dashboardUrl = (await getDashboardLaunchUrl()) ?? desktopConfig.dashboard.url;
- log.info('Loading dashboard', { dashboardUrl });
+ const safeDashboardUrl = new URL(dashboardUrl);
+ safeDashboardUrl.search = '';
+ safeDashboardUrl.hash = '';
+ log.info('Loading dashboard', { dashboardUrl: safeDashboardUrl.toString() });📝 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.
| const dashboardUrl = (await getDashboardLaunchUrl()) ?? desktopConfig.dashboard.url; | |
| log.info('Loading dashboard', { dashboardUrl }); | |
| const dashboardUrl = (await getDashboardLaunchUrl()) ?? desktopConfig.dashboard.url; | |
| const safeDashboardUrl = new URL(dashboardUrl); | |
| safeDashboardUrl.search = ''; | |
| safeDashboardUrl.hash = ''; | |
| log.info('Loading dashboard', { dashboardUrl: safeDashboardUrl.toString() }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/main.ts` around lines 236 - 237, The code is logging a
session-bearing URL from getDashboardLaunchUrl(), leaking tokens; change the
logging to redact sensitive parts: compute dashboardUrl via
getDashboardLaunchUrl() ?? desktopConfig.dashboard.url, but before calling
log.info('Loading dashboard', ...) sanitize the URL returned by
getDashboardLaunchUrl() (or if it contains a query string or known token param
name) by stripping query parameters or replacing token/session params with
"<REDACTED>" and only log the safe components (hostname/path) or the fallback
desktopConfig.dashboard.url; update the log call that uses dashboardUrl to log
the sanitized value instead (referencing getDashboardLaunchUrl(), dashboardUrl
and log.info).
| const api = { | ||
| getGatewayState: (): Promise<GatewayState> => ipcRenderer.invoke('gateway:get-state'), | ||
| refreshGatewayState: (): Promise<GatewayState> => ipcRenderer.invoke('gateway:refresh-state'), | ||
| startGateway: (): Promise<GatewayLaunchResult> => ipcRenderer.invoke('gateway:start'), | ||
| stopGateway: (): Promise<GatewayLaunchResult> => ipcRenderer.invoke('gateway:stop'), | ||
| restartGateway: (): Promise<GatewayLaunchResult> => ipcRenderer.invoke('gateway:restart'), | ||
| showDashboard: (): Promise<void> => ipcRenderer.invoke('window:show-dashboard'), | ||
| minimizeWindow: (): void => { ipcRenderer.send('window:minimize'); }, | ||
| maximizeWindow: (): void => { ipcRenderer.send('window:maximize'); }, | ||
| closeWindow: (): void => { ipcRenderer.send('window:close'); }, | ||
| }; | ||
|
|
||
| contextBridge.exposeInMainWorld('openclawDesktop', api); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd openclaw-desktop && find . -name "main.ts" -o -name "preload.ts" | head -20Repository: RedWoodOG/openclaw-desktop
Length of output: 100
🏁 Script executed:
cat -n openclaw-desktop/src/main.ts | sed -n '1,80p'Repository: RedWoodOG/openclaw-desktop
Length of output: 3345
🏁 Script executed:
rg -n "loadURL|preload" openclaw-desktop/src/main.ts -A 2 -B 2Repository: RedWoodOG/openclaw-desktop
Length of output: 685
🏁 Script executed:
cd openclaw-desktop && grep -n "getDashboardLaunchUrl" src/main.ts -A 5 -B 5Repository: RedWoodOG/openclaw-desktop
Length of output: 817
🏁 Script executed:
cd openclaw-desktop && cat -n src/main.ts | sed -n '200,250p'Repository: RedWoodOG/openclaw-desktop
Length of output: 2587
🏁 Script executed:
cd openclaw-desktop && cat -n src/gateway.ts | head -50Repository: RedWoodOG/openclaw-desktop
Length of output: 1620
🏁 Script executed:
cd openclaw-desktop && cat -n src/preload.tsRepository: RedWoodOG/openclaw-desktop
Length of output: 1248
Limit the preload bridge to trusted/local content.
The preload at line 46 of main.ts is attached to the same BrowserWindow that loads the remote dashboard via loadURL() at line 210. This single bridge exposes startGateway, stopGateway, restartGateway, and showDashboard to any code in that window without origin validation. The fact that lines 61–80 actively strip X-Frame-Options and relax CSP to allow 'unsafe-inline' and 'unsafe-eval' confirms the dashboard is untrusted remote content. Any XSS in the dashboard gains full access to gateway control and window manipulation. Split the bridge or gate it by origin so the remote dashboard only receives the minimal window-control methods it needs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/preload.ts` around lines 4 - 16, The preload currently
exposes the full api object (contextBridge.exposeInMainWorld('openclawDesktop',
api)) including sensitive methods
startGateway/stopGateway/restartGateway/showDashboard to any loaded page;
restrict those by splitting or gating the bridge: expose a minimal safe API
(window control methods like minimizeWindow/maximizeWindow/closeWindow) to all
origins, and only expose or enable
startGateway/stopGateway/restartGateway/showDashboard for trusted local origins
by checking the renderer origin inside preload (e.g., evaluate location.origin
or a validated origin whitelist) before adding those functions to the exposed
object or by exposing a separate interface for trusted content; update the api
construction and the call to contextBridge.exposeInMainWorld accordingly so
untrusted remote dashboard cannot call gateway control functions.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| if (desktopConfig.gateway.backend === 'rust') { | ||
| // Rust runtime splits HTTP (control plane) and WebSocket across different ports. | ||
| const httpControlUrl = `http://127.0.0.1:${desktopConfig.gateway.httpControlPort}`; | ||
| const wsUrl = desktopConfig.gateway.baseUrl.replace(/^http/, 'ws'); |
There was a problem hiding this comment.
Rust backend WebSocket URL defaults to wrong port
High Severity
When OPENCLAW_RUNTIME_BACKEND=rust is set, buildDashboardSessionSeed derives the WebSocket URL from desktopConfig.gateway.baseUrl, which defaults to http://127.0.0.1:18789 (the stock port). This produces ws://127.0.0.1:18789 — but per the PR description, the Rust runtime's WebSocket port is 18800. The config has httpControlPort for the Rust HTTP port (18890) but lacks a dedicated Rust WS port field, so the dashboard session seed sends WebSocket traffic to the stock gateway instead of the Rust runtime.
Additional Locations (1)
| } finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| } |
There was a problem hiding this comment.
Duplicated utility functions across gateway modules
Low Severity
delay, normalizeError, and fetchJson are identically implemented in both gateway.ts and gateway-rust-adapter.ts. Since the adapter already imports from gateway.ts (for types), these utilities could live in a shared module to avoid the maintenance risk of fixing a bug in one copy but not the other.
Additional Locations (1)
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (4)
openclaw-desktop/src/main.ts (3)
194-195:⚠️ Potential issue | 🟠 MajorRedact the dashboard token before logging.
getDashboardLaunchUrl()can return/#token=..., and this logs the full value toelectron-log. That leaks a reusable session token into local log files.Possible fix
const dashboardUrl = (await getDashboardLaunchUrl()) ?? desktopConfig.dashboard.url; - log.info('Loading dashboard', { dashboardUrl }); + const safeDashboardUrl = new URL(dashboardUrl); + safeDashboardUrl.search = ''; + safeDashboardUrl.hash = ''; + log.info('Loading dashboard', { dashboardUrl: safeDashboardUrl.toString() });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@openclaw-desktop/src/main.ts` around lines 194 - 195, The code logs the full dashboardUrl which may include a fragment token (e.g., `#token`=...), leaking secrets; update the logic around getDashboardLaunchUrl()/desktopConfig.dashboard.url so that before calling log.info('Loading dashboard', { dashboardUrl }) you sanitize dashboardUrl by stripping or redacting the fragment token—detect a "#token=" fragment in the dashboardUrl variable and replace the token value with a placeholder like "#token=[REDACTED]" (or remove the fragment entirely) so the logged dashboardUrl contains no reusable token.
208-219:⚠️ Potential issue | 🟠 MajorSeed storage before the first dashboard boot.
loadURL()resolves after the page has loaded, so the SPA has already started with the freshly cleared storage beforeseedDashboardSession()runs. The initial login/connection choice will still use empty/default values; at best the Rust settings take effect only after a manual reload. Seed at document start/preload, or reload once after writing the values.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@openclaw-desktop/src/main.ts` around lines 208 - 219, The SPA is loading before storage is seeded so the initial auth state is incorrect; move seeding to occur before the page starts by calling buildDashboardSessionSeed(...) and await seedDashboardSession(window, seed) before invoking window.loadURL(dashboardUrl), or alternatively perform the seeding in the renderer preload script (so seedDashboardSession runs at document start) or trigger a programmatic reload (window.reload()) immediately after seeding to ensure the SPA picks up the values; update the sequence around loadURL, buildDashboardSessionSeed and seedDashboardSession accordingly.
53-80:⚠️ Potential issue | 🔴 CriticalScope the header rewrite to the trusted dashboard origin.
This session-wide
onHeadersReceivedhook still stripsX-Frame-Optionsand weakens CSP for every response, whilesetWindowOpenHandler()only covers popups. A main-frame navigation to any other origin will inherit the same downgrade. Restrict the rewrite to the expected dashboard origin plusdetails.resourceType === 'mainFrame', and block other main-frame navigations withwill-navigate.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@openclaw-desktop/src/main.ts` around lines 53 - 80, The session-wide onHeadersReceived handler is too broad; update the window.webContents.session.webRequest.onHeadersReceived callback to only modify headers when details.resourceType === 'mainFrame' and details.url matches the trusted dashboard origin (match the exact origin string used for the dashboard), leaving all other responses untouched, and ensure you still return callback({ responseHeaders: details.responseHeaders }) for non-matching requests; additionally register a window.webContents.on('will-navigate', ...) handler that prevents/blocks navigations to any main-frame origin other than the trusted dashboard by calling event.preventDefault() (and optionally opening external links with shell.openExternal) to keep untrusted main-frame navigations from inheriting the relaxed CSP.openclaw-desktop/src/gateway-rust-adapter.ts (1)
18-19:⚠️ Potential issue | 🟠 MajorSerialize concurrent start requests.
This path still has a TOCTOU window between
checkGatewayHealth()andspawn(). Two rapid start/restart invokes can each observe “down”, launch detached children, and leave only the last PID tracked, which makes stop/restart target the wrong process.Also applies to: 213-266
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@openclaw-desktop/src/gateway-rust-adapter.ts` around lines 18 - 19, There is a TOCTOU race between checkGatewayHealth() and spawn() where concurrent start/restart calls can each see the gateway as down and spawn detached children while only the last PID is stored in rustProcess/rustProcessPid; serialize start/restart by adding a start-in-progress mutex/lock or single Promise (e.g., startLock or startPromise) that each start/restart routine must await before performing checkGatewayHealth() and spawn(), and ensure when a child is spawned you set rustProcess and rustProcessPid only while still holding the lock (or verify the spawned child belongs to this invocation) so stray children aren’t clobbered; apply the same serialization to the other start/restart code path where the same checkGatewayHealth()/spawn() sequence is used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@openclaw-desktop/src/gateway-rust-adapter.ts`:
- Around line 131-153: The code assumes any non-undefined healthPayload is
healthy; instead validate the Rust health shape (e.g. healthPayload.ok === true
or healthPayload.result?.ok === true) before returning healthy. After
fetchJson/getGatewayHealthUrl and normalizeError, check the payload shape; if it
doesn't meet the expected shape, set healthError (or wrap it with
normalizeError) and do not enter the healthy branch — only call
parseRustHealthPayload and summarizeRustHealthDetail when the payload passes the
ok/result.ok check so proxy/error pages or {ok:false} responses are treated as
unhealthy.
- Around line 245-295: Create a one-shot promise (e.g., bootFailurePromise) that
listens to rustProcess.on('error') and rustProcess.on('exit') and settles
immediately with a failure (reject or resolve with an error state) when the
process fails or exits before healthy; ensure the handlers still clear
rustProcess/rustProcessPid and normalizeError the failure to include in the
result. Then use Promise.race to race bootFailurePromise against
waitForHealthy(desktopConfig.gateway.startTimeoutMs) so startup returns fast on
real boot failures; if bootFailurePromise wins return the same shape as the
existing failure responses (attempted/action/ok/message/state using
checkGatewayHealth()).
In `@openclaw-desktop/src/main.ts`:
- Around line 104-115: The Rust branch currently builds gatewayWsUrl from
desktopConfig.gateway.baseUrl (via wsUrl) which defaults to the stock HTTP port,
causing connections to hit the wrong runtime; update the logic that returns
gatewayWsUrl in the rust branch to use the dedicated Rust websocket port (e.g.
desktopConfig.gateway.rustWsPort or a rust-specific ws URL) instead of
baseUrl—construct wsUrl from the rust ws port (or replace the port in baseUrl
with desktopConfig.gateway.rustWsPort) so gatewayWsUrl points to the Rust
runtime’s WS port; keep httpControlUrl and token logic unchanged.
---
Duplicate comments:
In `@openclaw-desktop/src/gateway-rust-adapter.ts`:
- Around line 18-19: There is a TOCTOU race between checkGatewayHealth() and
spawn() where concurrent start/restart calls can each see the gateway as down
and spawn detached children while only the last PID is stored in
rustProcess/rustProcessPid; serialize start/restart by adding a
start-in-progress mutex/lock or single Promise (e.g., startLock or startPromise)
that each start/restart routine must await before performing
checkGatewayHealth() and spawn(), and ensure when a child is spawned you set
rustProcess and rustProcessPid only while still holding the lock (or verify the
spawned child belongs to this invocation) so stray children aren’t clobbered;
apply the same serialization to the other start/restart code path where the same
checkGatewayHealth()/spawn() sequence is used.
In `@openclaw-desktop/src/main.ts`:
- Around line 194-195: The code logs the full dashboardUrl which may include a
fragment token (e.g., `#token`=...), leaking secrets; update the logic around
getDashboardLaunchUrl()/desktopConfig.dashboard.url so that before calling
log.info('Loading dashboard', { dashboardUrl }) you sanitize dashboardUrl by
stripping or redacting the fragment token—detect a "#token=" fragment in the
dashboardUrl variable and replace the token value with a placeholder like
"#token=[REDACTED]" (or remove the fragment entirely) so the logged dashboardUrl
contains no reusable token.
- Around line 208-219: The SPA is loading before storage is seeded so the
initial auth state is incorrect; move seeding to occur before the page starts by
calling buildDashboardSessionSeed(...) and await seedDashboardSession(window,
seed) before invoking window.loadURL(dashboardUrl), or alternatively perform the
seeding in the renderer preload script (so seedDashboardSession runs at document
start) or trigger a programmatic reload (window.reload()) immediately after
seeding to ensure the SPA picks up the values; update the sequence around
loadURL, buildDashboardSessionSeed and seedDashboardSession accordingly.
- Around line 53-80: The session-wide onHeadersReceived handler is too broad;
update the window.webContents.session.webRequest.onHeadersReceived callback to
only modify headers when details.resourceType === 'mainFrame' and details.url
matches the trusted dashboard origin (match the exact origin string used for the
dashboard), leaving all other responses untouched, and ensure you still return
callback({ responseHeaders: details.responseHeaders }) for non-matching
requests; additionally register a window.webContents.on('will-navigate', ...)
handler that prevents/blocks navigations to any main-frame origin other than the
trusted dashboard by calling event.preventDefault() (and optionally opening
external links with shell.openExternal) to keep untrusted main-frame navigations
from inheriting the relaxed CSP.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f5e85cbe-d1c7-48c6-8dad-b02ba4d22b21
📒 Files selected for processing (2)
openclaw-desktop/src/gateway-rust-adapter.tsopenclaw-desktop/src/main.ts
| try { | ||
| healthPayload = await fetchJson(getGatewayHealthUrl(), desktopConfig.gateway.healthTimeoutMs); | ||
| } catch (error) { | ||
| healthError = normalizeError(error); | ||
| } | ||
|
|
||
| if (healthPayload !== undefined) { | ||
| const facts = parseRustHealthPayload(healthPayload); | ||
| return { | ||
| healthy: true, | ||
| kind: 'healthy', | ||
| url: desktopConfig.gateway.baseUrl, | ||
| checkedAt, | ||
| detail: summarizeRustHealthDetail(healthPayload, facts), | ||
| payload: healthPayload, | ||
| facts, | ||
| checks: { | ||
| httpHealth: 'ok', | ||
| cliStatus: 'skipped', | ||
| cliHealth: 'skipped', | ||
| }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Don't treat any defined /health payload as healthy.
fetchJson() returns plain text for non-JSON 200s, and this branch promotes any non-undefined payload to healthy: true. A proxy/error page or { ok: false } response on the control port will suppress startup and send the app into a dead dashboard session. Validate the expected Rust ok/result.ok shape before returning a healthy state.
Possible fix
+function isHealthyRustPayload(payload: unknown): boolean {
+ if (!payload || typeof payload !== 'object') return false;
+ const root = payload as Record<string, unknown>;
+ if (root.ok !== true) return false;
+ if (typeof root.result === 'object' && root.result) {
+ const result = root.result as Record<string, unknown>;
+ return !('ok' in result) || result.ok === true;
+ }
+ return true;
+}
+
export async function checkGatewayHealth(): Promise<GatewayState> {
const checkedAt = new Date().toISOString();
@@
- if (healthPayload !== undefined) {
+ if (healthPayload !== undefined && isHealthyRustPayload(healthPayload)) {
const facts = parseRustHealthPayload(healthPayload);
return {
healthy: true,
@@
};
}
+
+ if (healthPayload !== undefined) {
+ healthError = 'Rust runtime health endpoint returned an unexpected payload.';
+ }📝 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.
| try { | |
| healthPayload = await fetchJson(getGatewayHealthUrl(), desktopConfig.gateway.healthTimeoutMs); | |
| } catch (error) { | |
| healthError = normalizeError(error); | |
| } | |
| if (healthPayload !== undefined) { | |
| const facts = parseRustHealthPayload(healthPayload); | |
| return { | |
| healthy: true, | |
| kind: 'healthy', | |
| url: desktopConfig.gateway.baseUrl, | |
| checkedAt, | |
| detail: summarizeRustHealthDetail(healthPayload, facts), | |
| payload: healthPayload, | |
| facts, | |
| checks: { | |
| httpHealth: 'ok', | |
| cliStatus: 'skipped', | |
| cliHealth: 'skipped', | |
| }, | |
| }; | |
| } | |
| function isHealthyRustPayload(payload: unknown): boolean { | |
| if (!payload || typeof payload !== 'object') return false; | |
| const root = payload as Record<string, unknown>; | |
| if (root.ok !== true) return false; | |
| if (typeof root.result === 'object' && root.result) { | |
| const result = root.result as Record<string, unknown>; | |
| return !('ok' in result) || result.ok === true; | |
| } | |
| return true; | |
| } | |
| export async function checkGatewayHealth(): Promise<GatewayState> { | |
| const checkedAt = new Date().toISOString(); | |
| let healthPayload: unknown; | |
| let healthError: string | null = null; | |
| try { | |
| healthPayload = await fetchJson(getGatewayHealthUrl(), desktopConfig.gateway.healthTimeoutMs); | |
| } catch (error) { | |
| healthError = normalizeError(error); | |
| } | |
| if (healthPayload !== undefined && isHealthyRustPayload(healthPayload)) { | |
| const facts = parseRustHealthPayload(healthPayload); | |
| return { | |
| healthy: true, | |
| kind: 'healthy', | |
| url: desktopConfig.gateway.baseUrl, | |
| checkedAt, | |
| detail: summarizeRustHealthDetail(healthPayload, facts), | |
| payload: healthPayload, | |
| facts, | |
| checks: { | |
| httpHealth: 'ok', | |
| cliStatus: 'skipped', | |
| cliHealth: 'skipped', | |
| }, | |
| }; | |
| } | |
| if (healthPayload !== undefined) { | |
| healthError = 'Rust runtime health endpoint returned an unexpected payload.'; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/gateway-rust-adapter.ts` around lines 131 - 153, The
code assumes any non-undefined healthPayload is healthy; instead validate the
Rust health shape (e.g. healthPayload.ok === true or healthPayload.result?.ok
=== true) before returning healthy. After fetchJson/getGatewayHealthUrl and
normalizeError, check the payload shape; if it doesn't meet the expected shape,
set healthError (or wrap it with normalizeError) and do not enter the healthy
branch — only call parseRustHealthPayload and summarizeRustHealthDetail when the
payload passes the ok/result.ok check so proxy/error pages or {ok:false}
responses are treated as unhealthy.
| rustProcess = spawn(binaryPath, args, { | ||
| cwd: desktopConfig.gateway.workingDirectory, | ||
| detached: true, | ||
| stdio: 'ignore', | ||
| windowsHide: true, | ||
| }); | ||
|
|
||
| rustProcessPid = rustProcess.pid ?? null; | ||
|
|
||
| rustProcess.on('error', (error) => { | ||
| log.error('Rust runtime process error', { error: normalizeError(error), pid: rustProcessPid }); | ||
| rustProcess = null; | ||
| rustProcessPid = null; | ||
| }); | ||
|
|
||
| rustProcess.on('exit', (code) => { | ||
| log.info('Rust runtime process exited', { code, pid: rustProcessPid }); | ||
| rustProcess = null; | ||
| rustProcessPid = null; | ||
| }); | ||
|
|
||
| rustProcess.unref(); | ||
| } catch (error) { | ||
| return { | ||
| attempted: true, | ||
| action: 'start', | ||
| ok: false, | ||
| message: `Failed to spawn Rust runtime: ${normalizeError(error)}`, | ||
| state: await checkGatewayHealth(), | ||
| }; | ||
| } | ||
|
|
||
| const settledState = await waitForHealthy(desktopConfig.gateway.startTimeoutMs); | ||
| if (settledState) { | ||
| return { | ||
| attempted: true, | ||
| action: 'start', | ||
| ok: true, | ||
| message: 'Rust runtime started and responding to health checks.', | ||
| state: settledState, | ||
| }; | ||
| } | ||
|
|
||
| const fallbackState = await checkGatewayHealth(); | ||
| return { | ||
| attempted: true, | ||
| action: 'start', | ||
| ok: false, | ||
| message: `Rust runtime spawned but did not become healthy within timeout. Current state: ${fallbackState.kind}.`, | ||
| state: fallbackState, | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd -t f gateway-rust-adapter.tsRepository: RedWoodOG/openclaw-desktop
Length of output: 114
🏁 Script executed:
cat -n openclaw-desktop/src/gateway-rust-adapter.ts | head -300Repository: RedWoodOG/openclaw-desktop
Length of output: 10902
Race waitForHealthy() against a boot-failure detector to fail fast on startup errors.
The 'error' and 'exit' handlers (lines 254–264) only log and clear module state; they don't interrupt waitForHealthy() on line 277, which still polls for the full startTimeoutMs. If the binary is not executable, the cwd is invalid, or the process crashes on boot, the caller waits the entire timeout before receiving a generic timeout error instead of the real startup failure. Create a one-shot promise that settles when the process errors or exits early, and race it against waitForHealthy() to return that error immediately.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/gateway-rust-adapter.ts` around lines 245 - 295, Create
a one-shot promise (e.g., bootFailurePromise) that listens to
rustProcess.on('error') and rustProcess.on('exit') and settles immediately with
a failure (reject or resolve with an error state) when the process fails or
exits before healthy; ensure the handlers still clear rustProcess/rustProcessPid
and normalizeError the failure to include in the result. Then use Promise.race
to race bootFailurePromise against
waitForHealthy(desktopConfig.gateway.startTimeoutMs) so startup returns fast on
real boot failures; if bootFailurePromise wins return the same shape as the
existing failure responses (attempted/action/ok/message/state using
checkGatewayHealth()).
| if (desktopConfig.gateway.backend === 'rust') { | ||
| // Rust runtime splits HTTP (control plane) and WebSocket across different ports. | ||
| const httpControlUrl = `http://127.0.0.1:${desktopConfig.gateway.httpControlPort}`; | ||
| const wsUrl = desktopConfig.gateway.baseUrl.replace(/^http/, 'ws'); | ||
| const rustToken = token ?? desktopConfig.gateway.rustToken; | ||
|
|
||
| return { | ||
| dashboardUrl, | ||
| gatewayHttpUrl: httpControlUrl, | ||
| gatewayWsUrl: wsUrl, | ||
| token: rustToken, | ||
| }; |
There was a problem hiding this comment.
Rust mode is still seeded with the stock WebSocket port by default.
In the Rust branch, gatewayWsUrl is derived from desktopConfig.gateway.baseUrl. Per openclaw-desktop/src/config.ts:44-73, baseUrl still defaults to http://127.0.0.1:18789, so setting only OPENCLAW_RUNTIME_BACKEND=rust seeds ws://127.0.0.1:18789 instead of the Rust runtime’s :18800. That breaks the advertised backend switch and can connect the dashboard to the wrong runtime. Use a dedicated Rust WS port/config here instead of reusing baseUrl.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@openclaw-desktop/src/main.ts` around lines 104 - 115, The Rust branch
currently builds gatewayWsUrl from desktopConfig.gateway.baseUrl (via wsUrl)
which defaults to the stock HTTP port, causing connections to hit the wrong
runtime; update the logic that returns gatewayWsUrl in the rust branch to use
the dedicated Rust websocket port (e.g. desktopConfig.gateway.rustWsPort or a
rust-specific ws URL) instead of baseUrl—construct wsUrl from the rust ws port
(or replace the port in baseUrl with desktopConfig.gateway.rustWsPort) so
gatewayWsUrl points to the Rust runtime’s WS port; keep httpControlUrl and token
logic unchanged.


Summary
OPENCLAW_RUNTIME_BACKEND=rustenv var.New files
src/gateway-rust-adapter.ts— Health checks via HTTP, process spawn for start, shutdown RPC for stop, dashboard URL constructionsrc/config.ts— AddedRuntimeBackendtype, Rust binary/config/port/token fieldsWhat this enables
The desktop app can now manage the Rust runtime (openclaw-agent-rs) lifecycle — start, stop, health check — and load its UI, all on separate ports from stock OpenClaw.
OC-FE (Tauri + React frontend) was also built in this session and connects to the Rust runtime on :18800 with auto-connect (no login screen). That code lives in the OC-FE repo.
Test plan
OPENCLAW_RUNTIME_BACKEND=rustwith Rust runtime on :18800 → connects via adapterNote
High Risk
High risk because the app strips security headers (CSP/X-Frame-Options) to embed the dashboard and adds logic to spawn/stop local gateway runtimes via CLI/RPC, which can affect local security posture and runtime stability.
Overview
Introduces a new
openclaw-desktopElectron app for Windows that launches the OpenClaw dashboard in a frameless window, seeds local/session storage for auto-connect, and falls back to a localrenderer/status.htmlrecovery/control page when the dashboard cannot be loaded.Adds gateway lifecycle management via
src/gateway.ts(health/status checks, tokenized dashboard URL retrieval, start/stop/restart with verification) plus an optionalOPENCLAW_RUNTIME_BACKEND=rustpath (src/gateway-rust-adapter.ts) that manages a Rust runtime through an HTTP control plane, direct process spawning, and shutdown RPC.Includes tray + single-instance behavior (
src/tray.ts,src/main.ts), a secure IPC bridge (src/preload.ts), configurable runtime paths/ports via env (src/config.ts), and packaging/build setup (package.json,tsconfig.json, README).Written by Cursor Bugbot for commit 0e9f512. This will update automatically on new commits. Configure here.
Summary by CodeRabbit
New Features
Documentation