fix(ui): resolve stale gateway URL under nginx reverse-proxy multi-instance setups#69647
fix(ui): resolve stale gateway URL under nginx reverse-proxy multi-instance setups#69647leifengfeng wants to merge 8 commits into
Conversation
…stance setups When multiple OpenClaw instances are served behind a single nginx domain with different path prefixes (e.g. /a, /b, /c), switching between instances keeps the WebSocket URL pinned to whichever instance was visited first. Even refreshing the page or reopening the browser does not correct it. Root cause: loadSettings() falls back to the shared legacy localStorage key when the current path's scoped key is absent. The legacy key holds the first instance's gatewayUrl, and the existing equality check treats it as a user-override rather than a stale default. Add isStaleGatewayUrl() to detect cached URLs that share the same host but differ in pathname — a sign they originated from a sibling reverse-proxy path rather than an intentional user choice. When detected, the page-derived default is used instead. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Greptile SummaryThis PR adds Confidence Score: 4/5Safe to merge for the described multi-instance nginx bug, with a known tradeoff that same-host custom gateway overrides will be silently discarded. The core fix is correct and solves the stated bug. The one P2 concern — intentional same-host, different-path gateway overrides being silently dropped — is a product of the chosen heuristic and is acknowledged by the PR description (only cross-host overrides are preserved). No security issues or runtime errors introduced. Reducing from 5 to 4 because the silent discard of deliberate user config is a real (if unusual) UX regression worth a second look before merging. ui/src/ui/storage.ts — specifically the scope of the isStaleGatewayUrl check relative to which fallback key triggered the load. Prompt To Fix All With AIThis is a comment left during a code review.
Path: ui/src/ui/storage.ts
Line: 229-232
Comment:
**Stale check applied to all storage sources, not just the legacy key**
`isStaleGatewayUrl` is applied regardless of which key (`scopedKey`, `default`, or `LEGACY_SETTINGS_KEY`) the settings came from. The stated bug originates specifically from `LEGACY_SETTINGS_KEY` contaminating sibling-path sessions, but the fix also affects settings read from the scoped key or the `default` key.
More concretely: `persistSettings` always writes to both the scoped key and `LEGACY_SETTINGS_KEY` (line 343–344). If a user on `/a` deliberately changes their gateway to `wss://same-host/custom`, the legacy key will hold that value. On the next visit to `/a`, the scoped key is empty, the legacy key is read, and `isStaleGatewayUrl("wss://same-host/custom", "wss://same-host/a")` → `true` → the intentional override is silently discarded. The PR description acknowledges only cross-host overrides are preserved; same-host, different-path overrides are not — consider documenting this limitation or limiting the staleness check to reads that fell through to `LEGACY_SETTINGS_KEY`.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(ui): resolve stale gateway URL under..." | Re-trigger Greptile Re-review progress:
|
| const gatewayUrl = | ||
| parsedGatewayUrl === pageDerivedUrl || isStaleGatewayUrl(parsedGatewayUrl, pageDerivedUrl) | ||
| ? defaultUrl | ||
| : parsedGatewayUrl; |
There was a problem hiding this comment.
Stale check applied to all storage sources, not just the legacy key
isStaleGatewayUrl is applied regardless of which key (scopedKey, default, or LEGACY_SETTINGS_KEY) the settings came from. The stated bug originates specifically from LEGACY_SETTINGS_KEY contaminating sibling-path sessions, but the fix also affects settings read from the scoped key or the default key.
More concretely: persistSettings always writes to both the scoped key and LEGACY_SETTINGS_KEY (line 343–344). If a user on /a deliberately changes their gateway to wss://same-host/custom, the legacy key will hold that value. On the next visit to /a, the scoped key is empty, the legacy key is read, and isStaleGatewayUrl("wss://same-host/custom", "wss://same-host/a") → true → the intentional override is silently discarded. The PR description acknowledges only cross-host overrides are preserved; same-host, different-path overrides are not — consider documenting this limitation or limiting the staleness check to reads that fell through to LEGACY_SETTINGS_KEY.
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/storage.ts
Line: 229-232
Comment:
**Stale check applied to all storage sources, not just the legacy key**
`isStaleGatewayUrl` is applied regardless of which key (`scopedKey`, `default`, or `LEGACY_SETTINGS_KEY`) the settings came from. The stated bug originates specifically from `LEGACY_SETTINGS_KEY` contaminating sibling-path sessions, but the fix also affects settings read from the scoped key or the `default` key.
More concretely: `persistSettings` always writes to both the scoped key and `LEGACY_SETTINGS_KEY` (line 343–344). If a user on `/a` deliberately changes their gateway to `wss://same-host/custom`, the legacy key will hold that value. On the next visit to `/a`, the scoped key is empty, the legacy key is read, and `isStaleGatewayUrl("wss://same-host/custom", "wss://same-host/a")` → `true` → the intentional override is silently discarded. The PR description acknowledges only cross-host overrides are preserved; same-host, different-path overrides are not — consider documenting this limitation or limiting the staleness check to reads that fell through to `LEGACY_SETTINGS_KEY`.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a6f8ed0ee0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| try { | ||
| const cached = new URL(cachedUrl); | ||
| const current = new URL(currentUrl); | ||
| return cached.host === current.host && cached.pathname !== current.pathname; |
There was a problem hiding this comment.
Preserve explicit same-host gateway path overrides
The new stale-URL check treats every same-host pathname mismatch as cache corruption, so a deliberate override like wss://example.com/ws (with the UI served at /openclaw) is now rewritten to the page-derived URL on load. This happens when reading fallback persisted settings (legacy/default keys), and it breaks valid reverse-proxy deployments where UI and WebSocket live on different paths under the same host by forcing reconnection to the wrong endpoint after refresh.
Useful? React with 👍 / 👎.
The previous isStaleGatewayUrl() check was too broad — it rewrote any same-host different-path URL, breaking valid reverse-proxy deployments where the UI and WebSocket endpoint intentionally live on different paths under the same host (e.g. UI at /openclaw, WS at /ws). Replace the heuristic with an explicit gatewayUrlAutoDerived flag in PersistedUiSettings. The flag is set to true when the gateway URL matches the page-derived default, and false when the user sets it manually. On load, an auto-derived URL from a different page context is discarded in favor of the current page default; a user-set URL is always preserved. A missing flag (pre-existing data) defaults to false, preserving backward compatibility. Co-Authored-By: Claude Opus 4.6 <[email protected]>
…flag Two existing tests use toEqual for exact JSON shape matching of persisted settings. Add the new gatewayUrlAutoDerived field to the expected objects so they pass with the flag-based fix. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a362403b1f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| parsedGatewayUrl === pageDerivedUrl || parsed.gatewayUrlAutoDerived === true | ||
| ? defaultUrl |
There was a problem hiding this comment.
Migrate legacy unflagged defaults when switching base paths
On upgraded clients, previously saved settings in openclaw.control.settings.v1 have no gatewayUrlAutoDerived flag, so loadSettings() still preserves stale cross-instance URLs: if /a was saved before upgrade and the user opens /b, the fallback legacy record is read and this condition keeps /a because the flag is undefined. That means the reverse-proxy multi-instance routing bug remains for existing users until a later write happens to set the new flag, so the fix does not reliably apply at upgrade time.
Useful? React with 👍 / 👎.
…t upgrade
The gatewayUrlAutoDerived flag correctly distinguishes auto-derived from
user-set URLs for new data, but pre-existing records without the flag
still carry stale cross-instance URLs on the first load after upgrade.
Instead of a heuristic (which breaks valid same-host different-path
overrides like wss://host/ws on UI path /openclaw), persistSettings now
also writes a page-scoped key keyed by the current page-derived gateway
URL. This key is only written when absent, so a user's manual override
already saved under the page-scoped key is preserved.
On the first visit after upgrade:
1. loadSettings reads legacy data (stale URL, no flag).
2. The stale URL is preserved for this one load (safe default).
3. persistSettings writes the page-derived scoped key with the flag.
4. On the next load the scoped key is found directly — no fallback
to the legacy key, and the correct page-derived URL is used.
This also adds a resolveGatewayUrl helper that centralises the three-tier
gateway URL resolution logic (page-match → flag → preserve).
Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 706a0bfa6e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (pageDerivedKey !== scopedKey && storage?.getItem(pageDerivedKey) === null) { | ||
| storage?.setItem(pageDerivedKey, serialized); |
There was a problem hiding this comment.
Update page-scoped cache when override URL changes
persistSettings only writes pageDerivedKey when that key is missing, so after a user saves one custom gateway URL and later changes it, the page-scoped entry keeps the old URL while scopedKey/legacy are updated. On the next reload, loadSettings() reads the page-scoped key first and restores the stale gateway URL, so user endpoint changes do not persist once a page-scoped record already exists.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: be138626fd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // whether this was auto-derived or user-set, so preserve it. | ||
| // The fix takes effect on the *next* load: persistSettings writes | ||
| // the page-scoped key and the flag, so a subsequent page visit | ||
| // will find its own scoped key and never reach this path. | ||
| return stored; |
There was a problem hiding this comment.
Migrate unflagged legacy gateway URL on load
For upgraded clients that only have legacy settings (no gatewayUrlAutoDerived), this branch keeps the previously cached URL unchanged, so visiting a different reverse-proxy base path (for example /b after /a) still reconnects to the old backend. Fresh evidence in this revision is that the undefined-flag path returns stored directly with no host/path migration logic, so stale cross-instance URLs are not corrected at read time.
Useful? React with 👍 / 👎.
| if (pageDerivedKey !== scopedKey && storage?.getItem(pageDerivedKey) === null) { | ||
| storage?.setItem(pageDerivedKey, serialized); |
There was a problem hiding this comment.
Rewrite page-scoped entry when persisting new settings
This guard prevents updating an existing page-derived key, which means later saves under a custom gateway only update scopedKey/legacy while the page-scoped record stays stale. On reload, loadSettings() reads the page-scoped key first and restores outdated values, so user changes (gateway and other UI settings saved in this blob) can be silently reverted after refresh once that key already exists.
Useful? React with 👍 / 👎.
The "only write when missing" guard prevented the page-scoped localStorage key from being updated when the user changed their gateway endpoint. After an initial seed, subsequent saves to a different URL left the page-scoped entry stale, so a reload would reconnect to the previous endpoint. Remove the guard so persistSettings always writes the page-derived scoped key alongside the gatewayUrl-scoped key and the legacy key. This ensures the page-scoped entry stays in sync with the user's current choice. Update the session-token test to match: the last-saved URL is now what loadSettings returns, and gwUrl's token remains isolated in sessionStorage (verified explicitly). Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
Thanks for the context here. I swept through the related work, and this is now duplicate or superseded. Keep open: current main and v2026.6.1 still preserve a stale legacy gateway URL, so the PR is not obsolete, but the branch is not merge-ready because its unflagged legacy path still returns the stale stored URL before the first connection and it lacks real reverse-proxy proof. Canonical path: Close this stale PR. The latest review rated it F, the branch still lacks merge-ready proof, and there has been no human follow-up after the durable review. So I’m closing this here because the remaining work is already tracked in the canonical issue. Review detailsBest possible solution: Close this stale PR. The latest review rated it F, the branch still lacks merge-ready proof, and there has been no human follow-up after the durable review. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main and v2026.6.1 preserve a non-matching legacy Is this the best way to solve the issue? No. The PR is aimed at the right owner path, but its unflagged legacy branch still returns the stale stored URL before first connection unless another path happens to save settings first. Security review: Security review cleared: The diff touches browser local/session storage logic and adjacent unit tests only; I found no concrete security or supply-chain concern. AGENTS.md: found and applied where relevant. What I checked:
Likely related people:
Codex review notes: model gpt-5.5, reasoning high; reviewed against fa614d0907e8. |
|
ClawSweeper PR egg 🎁 Pass real behavior proof to wake the egg and unlock a hatchable treat. Where did the egg go?
|
|
This pull request has been automatically marked as stale due to inactivity. |
Summary
/a,/b,/c), switching between instances keeps the WebSocket URL pinned to whichever instance was visited first. Refreshing or reopening the browser does not correct it.loadSettings()falls back to the shared legacy localStorage key when the current path's scoped key is absent. The legacy key holds the first instance'sgatewayUrl, and the existing equality check treats it as a user-override rather than a stale default.isStaleGatewayUrl()to detect cached URLs that share the same host but differ in pathname — indicating they originated from a sibling reverse-proxy path rather than an intentional user choice. When detected, the page-derived default is used instead.Changes
ui/src/ui/storage.ts: AddedisStaleGatewayUrl()helper and updatedloadSettings()gateway URL resolution to ignore stale cross-path cached values while preserving intentionally different (cross-host) user overrides.Test plan
/aand/b) pointing to separate OpenClaw instances/a/chat, verify WebSocket connects to the/abackend/b/chat, verify WebSocket connects to the/bbackend (not/a)/a/chat, verify WebSocket still connects to/a🤖 Generated with Claude Code