Skip to content

fix(ui): resolve stale gateway URL under nginx reverse-proxy multi-instance setups#69647

Closed
leifengfeng wants to merge 8 commits into
openclaw:mainfrom
leifengfeng:fix/stale-gateway-url-nginx-reverse-proxy
Closed

fix(ui): resolve stale gateway URL under nginx reverse-proxy multi-instance setups#69647
leifengfeng wants to merge 8 commits into
openclaw:mainfrom
leifengfeng:fix/stale-gateway-url-nginx-reverse-proxy

Conversation

@leifengfeng

Copy link
Copy Markdown

Summary

  • 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. Refreshing 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.
  • Adds 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: Added isStaleGatewayUrl() helper and updated loadSettings() gateway URL resolution to ignore stale cross-path cached values while preserving intentionally different (cross-host) user overrides.

Test plan

  • Configure nginx with two path-based reverse proxies (e.g. /a and /b) pointing to separate OpenClaw instances
  • Visit /a/chat, verify WebSocket connects to the /a backend
  • Switch to /b/chat, verify WebSocket connects to the /b backend (not /a)
  • Switch back to /a/chat, verify WebSocket still connects to /a
  • Manually enter a custom cross-host gateway URL, verify it is preserved and not overridden
  • Single-instance (no basePath) setup still works as before

🤖 Generated with Claude Code

…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-apps

greptile-apps Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds isStaleGatewayUrl() to storage.ts and plumbs it into loadSettings() to prevent a cached WebSocket URL from a sibling reverse-proxy path (same host, different pathname) from being reused when the browser navigates to a different OpenClaw instance. The fix correctly solves the described multi-instance nginx scenario. One design tradeoff to be aware of: because persistSettings writes to both the scoped key and LEGACY_SETTINGS_KEY simultaneously, an intentional user override to a same-host-but-different-path gateway URL will also be silently discarded on revisit, since the stale check is applied uniformly across all storage-key fallbacks and not limited to the legacy-key path.

Confidence Score: 4/5

Safe 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 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.

Reviews (1): Last reviewed commit: "fix(ui): resolve stale gateway URL under..." | Re-trigger Greptile

Re-review progress:

Comment thread ui/src/ui/storage.ts Outdated
Comment on lines +229 to +232
const gatewayUrl =
parsedGatewayUrl === pageDerivedUrl || isStaleGatewayUrl(parsedGatewayUrl, pageDerivedUrl)
? defaultUrl
: parsedGatewayUrl;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 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".

Comment thread ui/src/ui/storage.ts Outdated
try {
const cached = new URL(cachedUrl);
const current = new URL(currentUrl);
return cached.host === current.host && cached.pathname !== current.pathname;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

leifengfeng and others added 3 commits April 21, 2026 17:13
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]>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 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".

Comment thread ui/src/ui/storage.ts Outdated
Comment on lines +221 to +222
parsedGatewayUrl === pageDerivedUrl || parsed.gatewayUrlAutoDerived === true
? defaultUrl

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge 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]>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 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".

Comment thread ui/src/ui/storage.ts Outdated
Comment on lines +374 to +375
if (pageDerivedKey !== scopedKey && storage?.getItem(pageDerivedKey) === null) {
storage?.setItem(pageDerivedKey, serialized);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 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".

Comment thread ui/src/ui/storage.ts
Comment on lines +113 to +117
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread ui/src/ui/storage.ts Outdated
Comment on lines +374 to +375
if (pageDerivedKey !== scopedKey && storage?.getItem(pageDerivedKey) === null) {
storage?.setItem(pageDerivedKey, serialized);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge 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]>
@clawsweeper

clawsweeper Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

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 details

Best 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 gatewayUrl fallback as an override, so visiting one path with only another path's legacy record keeps the old backend URL.

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:

  • stale F-rated PR: PR was opened 2026-04-21T08:47:21Z, is older than 30 days, and the latest review rated it F.
  • proof blocker: real behavior proof is missing and proof tier is F, so this branch is not merge-ready without contributor follow-up.
  • no human follow-up: live comments and timeline hydrated by apply contain no non-automation activity after the ClawSweeper review.

Likely related people:

  • gittb: Authored the merged basePath default WebSocket URL change that established the reverse-proxy gateway URL behavior this PR builds on. (role: feature introducer; confidence: high; commits: 5d7314db225f; files: ui/src/ui/storage.ts, ui/src/ui/storage.node.test.ts)
  • Vincent Koc: Recent checkout blame and release history attribute current storage/test lines to this author in the shallow checkout. (role: recent area contributor; confidence: medium; commits: 3060ebf0523a, 2e08f0f4221f; files: ui/src/ui/storage.ts, ui/src/ui/storage.node.test.ts)
  • Sally O'Malley: Authored the scoped Control UI session-per-gateway storage work adjacent to this gateway URL persistence path. (role: adjacent owner; confidence: medium; commits: d37e3d582fd8; files: ui/src/ui/storage.ts, ui/src/ui/storage.node.test.ts)
  • ObitaBot: Authored the localStorage settings key scoping change that is directly related to cross-deployment settings isolation. (role: adjacent contributor; confidence: medium; commits: 5ece9afa8b57; files: ui/src/ui/storage.ts)

Codex review notes: model gpt-5.5, reasoning high; reviewed against fa614d0907e8.

@openclaw-barnacle openclaw-barnacle Bot added the triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. label May 6, 2026
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. labels May 20, 2026
@clawsweeper

clawsweeper Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper PR egg

🎁 Pass real behavior proof to wake the egg and unlock a hatchable treat.

Where did the egg go?
  • The egg game starts only after the PR passes the real-behavior proof check.
  • Before that, no creature or rarity is rolled. The treat waits for real proof.
  • This is still just collectible flavor: proof affects review readiness, not creature quality.

@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Jun 7, 2026
@leifengfeng leifengfeng closed this by deleting the head repository Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. P2 Normal backlog priority with limited blast radius. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: S stale Marked as stale due to inactivity status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant