Skip to content

fix(control-ui): share gateway token scope across loopback hosts#43254

Closed
ssdiwu wants to merge 2 commits into
openclaw:mainfrom
ssdiwu:fix/control-ui-loopback-token-scope
Closed

fix(control-ui): share gateway token scope across loopback hosts#43254
ssdiwu wants to merge 2 commits into
openclaw:mainfrom
ssdiwu:fix/control-ui-loopback-token-scope

Conversation

@ssdiwu

@ssdiwu ssdiwu commented Mar 11, 2026

Copy link
Copy Markdown

Summary

Fix a Control UI auth footgun where gateway tokens are scoped by exact gatewayUrl, causing same-machine loopback aliases to fragment auth state.

Today these are treated as different token scopes even though they refer to the same local gateway:

  • ws://127.0.0.1:18789
  • ws://localhost:18789
  • ws://::1:18789 / [::1] variants

Because the Control UI stores the token in sessionStorage and keys it by exact gatewayUrl, switching between loopback aliases can produce:

  • unauthorized: gateway token missing
  • Control UI reconnect loops that feel intermittent

What changed

  • In ui/src/ui/storage.ts, normalize loopback hosts to a shared token scope:
    • localhost
    • 127.0.0.1
    • 127.*
    • ::1
    • [::1]
  • Preserve port and pathname in the scope key so distinct local gateways remain isolated.
  • Add unit coverage proving:
    • 127.0.0.1 and localhost reuse the same token scope
    • different loopback ports do not share tokens

Why this is safe

  • Non-loopback hosts keep the existing exact-match behavior.
  • Tokens remain in sessionStorage; this PR does not relax persistence or broaden auth beyond loopback alias normalization.
  • Port boundaries remain intact.

Related

Verification

pnpm exec vitest run --config /tmp/openclaw-vitest-storage-only.config.ts

The temporary config used:

import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: {
    environment: 'node',
    include: ['ui/src/ui/storage.node.test.ts'],
    unstubEnvs: true,
    unstubGlobals: true,
  },
});

@greptile-apps

greptile-apps Bot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real UX pain point in the Control UI by normalising loopback host aliases (localhost, 127.0.0.1, 127.*, [::1]) to a shared sessionStorage token-scope key, preventing unnecessary re-authentication when the same local gateway is reached through different loopback aliases. Non-loopback hosts retain exact-match scoping, and port boundaries are correctly preserved.

Key observations:

  • The normalisation logic in normalizeGatewayTokenScope is correct and well-contained — no auth model changes, no persistence changes.
  • host === "::1" (line 50 in storage.ts) is likely dead code: per the WHATWG URL Living Standard, URL.hostname serialises IPv6 addresses with surrounding brackets, so only host === "[::1]" can ever match a valid ws://[::1]:… URL. The IPv6 path still works correctly thanks to the [::1] check, but the "::1" alternative is misleading.
  • The new tests cover the primary alias-sharing scenario and port isolation; an IPv6 loopback test (ws://[::1]:18789ws://127.0.0.1:18789) is missing, leaving that branch unexercised by the test suite.

Confidence Score: 4/5

  • Safe to merge — the change is narrowly scoped to loopback alias normalisation with no auth model, persistence, or non-loopback behaviour changes.
  • The core fix is correct and well-tested for the primary cases. The only concerns are a likely-dead "::1" branch (harmless, style only) and missing IPv6 test coverage for the [::1] path. Neither blocks merging.
  • No files require special attention — both changed files are straightforward. The "::1" dead-code branch in storage.ts is worth cleaning up but is not a blocker.

Last reviewed commit: 894ba94

Comment thread ui/src/ui/storage.ts Outdated
Comment on lines +50 to +51
host === "::1" ||
host === "[::1]" ||

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.

"::1" check is unreachable dead code

Per the WHATWG URL Living Standard, URL.hostname for an IPv6 address is serialized with surrounding brackets — e.g. new URL("ws://[::1]:18789").hostname returns "[::1]", not "::1". This means the host === "::1" branch can never be entered for a validly-formed URL; only host === "[::1]" (line 51) actually fires.

Keeping the dead branch is harmless in practice (the [::1] check correctly handles IPv6 loopback), but it will mislead future readers about which check is doing the work and may create a false sense of coverage.

Suggested change
host === "::1" ||
host === "[::1]" ||
host === "::1" ||
host === "[::1]" ||

Consider removing the "::1" alternative and leaving only "[::1]", or adding an explanatory comment clarifying the WHATWG URL hostname serialization behaviour.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/storage.ts
Line: 50-51

Comment:
**`"::1"` check is unreachable dead code**

Per the WHATWG URL Living Standard, `URL.hostname` for an IPv6 address is serialized *with* surrounding brackets — e.g. `new URL("ws://[::1]:18789").hostname` returns `"[::1]"`, not `"::1"`. This means the `host === "::1"` branch can never be entered for a validly-formed URL; only `host === "[::1]"` (line 51) actually fires.

Keeping the dead branch is harmless in practice (the `[::1]` check correctly handles IPv6 loopback), but it will mislead future readers about which check is doing the work and may create a false sense of coverage.

```suggestion
      host === "::1" ||
      host === "[::1]" ||
```

Consider removing the `"::1"` alternative and leaving only `"[::1]"`, or adding an explanatory comment clarifying the WHATWG URL hostname serialization behaviour.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +210 to +250
it("reuses a session token across loopback host aliases", async () => {
setTestLocation({
protocol: "http:",
host: "127.0.0.1:18789",
pathname: "/",
});

const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "ws://127.0.0.1:18789",
token: "loopback-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
});

localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl: "ws://localhost:18789",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
}),
);

expect(loadSettings()).toMatchObject({
gatewayUrl: "ws://localhost:18789",
token: "loopback-token",
});
});

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.

Missing IPv6 loopback test coverage

The new tests verify 127.0.0.1localhost alias sharing and port isolation, but there is no test confirming that ws://[::1]:18789 is normalised to the same scope as ws://127.0.0.1:18789. Given that the PR description explicitly lists [::1] as a handled variant and there is a dedicated branch for it in normalizeGatewayTokenScope, a parallel test case would ensure that branch is exercised and that the IPv6 path is not accidentally regressed in future refactors.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/storage.node.test.ts
Line: 210-250

Comment:
**Missing IPv6 loopback test coverage**

The new tests verify `127.0.0.1``localhost` alias sharing and port isolation, but there is no test confirming that `ws://[::1]:18789` is normalised to the same scope as `ws://127.0.0.1:18789`. Given that the PR description explicitly lists `[::1]` as a handled variant and there is a dedicated branch for it in `normalizeGatewayTokenScope`, a parallel test case would ensure that branch is exercised and that the IPv6 path is not accidentally regressed in future refactors.

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: 894ba946e4

ℹ️ 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
host === "127.0.0.1" ||
host === "::1" ||
host === "[::1]" ||
host.startsWith("127.");

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 Badge Avoid treating 127.* hostnames as loopback literals

The new loopback check in normalizeGatewayTokenScope uses host.startsWith("127."), which matches regular DNS hostnames like 127.example.com and 127.0.0.1.nip.io in addition to numeric IP literals. In those cases the code rewrites the host to 127.0.0.1[:port], so non-loopback gateways can collide with local loopback token scopes and load/save the wrong session token. Restrict this logic to actual IPv4 loopback literals (127/8 numeric addresses) rather than a string prefix.

Useful? React with 👍 / 👎.

@ssdiwu

ssdiwu commented Mar 12, 2026

Copy link
Copy Markdown
Author

Follow-up pushed: removed the redundant "::1" branch and added a [::1] <-> 127.0.0.1 loopback scope test.

@ssdiwu

ssdiwu commented Mar 13, 2026

Copy link
Copy Markdown
Author

CI note: the current macos failure looks unrelated to this PR. This branch only touches ui/src/ui/storage.ts and ui/src/ui/storage.node.test.ts, while the failing macOS lane is currently blowing up across unrelated suites/modules (Discord token setup, Telegram media fetch/path checks, Signal localhost RPC, Slack monitor assertions, session-store /srv mkdir). Happy to dig deeper if maintainers think there is a hidden connection, but at first pass this does not look like a regression from the loopback token-scope change itself.

@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 Apr 27, 2026
@clawsweeper

clawsweeper Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Codex review: keeping this open for maintainer follow-up; there is still a little grit to resolve.

Keep this PR open. Current main still scopes Control UI token and session-selection storage by the exact parsed gateway URL, so the localhost/127.0.0.1/[::1] loopback sharing this PR proposes has not been implemented. The PR targets the right storage surface, but the current patch needs a focused security/correctness revision for 127.* DNS hostnames and a rebase to the current UiSettings shape before it is merge-ready.

Best possible solution:

Keep the PR open and request a focused revision in ui/src/ui/storage.ts: normalize only localhost plus actual numeric IPv4 loopback literals and bracketed IPv6 loopback, preserve protocol/port/path boundaries, add positive localhost/127/[::1] same-port sharing tests, add different-port and DNS-hostname negative tests, update fixtures for the current UiSettings fields, then run the targeted Control UI storage test before maintainer review/merge.

What I checked:

  • current_main_exact_scope: normalizeGatewayTokenScope parses the gateway URL and returns ${parsed.protocol}//${parsed.host}${pathname} with no loopback alias normalization, so localhost, 127.0.0.1, and [::1] remain separate scopes on current main. (ui/src/ui/storage.ts:106, bdfb408ce6ee)
  • token_storage_uses_scope: tokenSessionKeyForGateway, loadSessionToken, and persistSessionToken derive the sessionStorage key through normalizeGatewayTokenScope, so exact-host scoping directly controls whether a saved gateway token is reused. (ui/src/ui/storage.ts:125, bdfb408ce6ee)
  • session_selection_uses_scope: resolveScopedSessionSelection and persistSettings also use normalizeGatewayTokenScope for sessionsByGateway, so exact loopback host scoping affects remembered session selection too. (ui/src/ui/storage.ts:134, bdfb408ce6ee)
  • storage_tests_lack_alias_coverage: Current storage tests cover same-URL token loading and different-gateway isolation, but targeted search found no localhost/127/[::1] token-sharing or DNS-hostname negative storage tests. (ui/src/ui/storage.node.test.ts:211, bdfb408ce6ee)
  • docs_confirm_user_surface: The public Control UI docs list both 127.0.0.1 and localhost as local entry points and state that the dashboard token is kept for the current tab session and selected gateway URL, matching the user-visible behavior this PR is trying to improve. Public docs: docs/web/control-ui.md. (docs/web/control-ui.md:21, bdfb408ce6ee)
  • pr_security_review: The provided PR diff uses host.startsWith("127."); Node URL parsing shows DNS names such as 127.example.com and 127.0.0.1.nip.io retain hostnames beginning with 127., so that check would collide non-loopback DNS hosts with local loopback token scopes. (ui/src/ui/storage.ts, 135b62cb69ec)

Remaining risk / open question:

  • Loopback-alias token fragmentation remains unresolved on current main until a corrected implementation lands.
  • Landing the PR as-is could share a browser session token between a real loopback gateway and DNS hostnames whose names start with 127.
  • The PR test fixtures shown in the provided diff are behind the current UiSettings contract and need rebasing.

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

@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Apr 28, 2026
@openclaw-clownfish

Copy link
Copy Markdown
Contributor

ProjectClownfish could not safely update this branch, so it opened a narrow replacement PR instead.

Replacement PR: #73987
Source PR: #43254
Contributor credit is preserved in the replacement PR body and changelog plan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant