Skip to content

Commit 2057d41

Browse files
fix(control-ui): share gateway token scope across loopback hosts
1 parent c2e3b6e commit 2057d41

3 files changed

Lines changed: 117 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- Control UI: share current-tab gateway tokens across true loopback aliases while preserving protocol, port, and path isolation, so localhost, 127/8, and bracketed IPv6 loopback dashboard links can reuse same-machine auth without leaking across distinct gateways. Carries forward #43254; refs #17745. Thanks @ssdiwu.
1617
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
1718
- Plugins/QA: prebuild the private QA channel runtime before plugin gauntlet source runs so wrapper CPU/RSS measurements are not polluted by private QA dist rebuild work. Thanks @vincentkoc.
1819
- Gateway/reload: bound default restart deferral and SIGUSR1 restart drain to five minutes while preserving explicit `deferralTimeoutMs: 0` indefinite waits, so stale active work accounting cannot block config reloads forever. Thanks @vincentkoc.

ui/src/ui/storage.node.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,28 @@ function expectedGatewayUrl(basePath: string): string {
4444
return `${proto}://${location.host}${basePath}`;
4545
}
4646

47+
type UiSettingsFixture = Parameters<typeof saveSettings>[0];
48+
49+
function createUiSettingsFixture(overrides: Partial<UiSettingsFixture> = {}): UiSettingsFixture {
50+
return {
51+
gatewayUrl: expectedGatewayUrl(""),
52+
token: "",
53+
sessionKey: "main",
54+
lastActiveSessionKey: "main",
55+
theme: "claw",
56+
themeMode: "system",
57+
chatFocusMode: false,
58+
chatShowThinking: true,
59+
chatShowToolCalls: true,
60+
splitRatio: 0.6,
61+
navCollapsed: false,
62+
navWidth: 220,
63+
navGroupsCollapsed: {},
64+
borderRadius: 50,
65+
...overrides,
66+
};
67+
}
68+
4769
function createCustomThemeFixture() {
4870
return normalizeImportedCustomTheme(
4971
{
@@ -239,6 +261,68 @@ describe("loadSettings default gateway URL derivation", () => {
239261
});
240262
});
241263

264+
it("shares same-tab tokens across true loopback aliases on the same protocol port and path", async () => {
265+
setTestLocation({
266+
protocol: "http:",
267+
host: "127.0.0.1:18789",
268+
pathname: "/openclaw",
269+
});
270+
271+
saveSettings(
272+
createUiSettingsFixture({
273+
gatewayUrl: "ws://127.0.0.1:18789/openclaw",
274+
token: "loopback-token",
275+
}),
276+
);
277+
278+
expect(sessionStorage.getItem("openclaw.control.token.v1:ws://localhost:18789/openclaw")).toBe(
279+
"loopback-token",
280+
);
281+
282+
localStorage.clear();
283+
284+
for (const host of ["localhost:18789", "127.255.255.255:18789", "[::1]:18789"]) {
285+
setTestLocation({
286+
protocol: "http:",
287+
host,
288+
pathname: "/openclaw",
289+
});
290+
expect(loadSettings()).toMatchObject({
291+
gatewayUrl: expectedGatewayUrl("/openclaw"),
292+
token: "loopback-token",
293+
});
294+
}
295+
});
296+
297+
it("keeps loopback token scope isolated by protocol port path and DNS hostnames", async () => {
298+
setTestLocation({
299+
protocol: "http:",
300+
host: "127.0.0.1:18789",
301+
pathname: "/openclaw",
302+
});
303+
304+
saveSettings(
305+
createUiSettingsFixture({
306+
gatewayUrl: "ws://127.0.0.1:18789/openclaw",
307+
token: "loopback-token",
308+
}),
309+
);
310+
localStorage.clear();
311+
312+
for (const locationParams of [
313+
{ protocol: "http:", host: "127.0.0.1:18790", pathname: "/openclaw" },
314+
{ protocol: "https:", host: "127.0.0.1:18789", pathname: "/openclaw" },
315+
{ protocol: "http:", host: "127.0.0.1:18789", pathname: "/other" },
316+
{ protocol: "http:", host: "127.0.0.1.example:18789", pathname: "/openclaw" },
317+
]) {
318+
setTestLocation(locationParams);
319+
expect(loadSettings()).toMatchObject({
320+
gatewayUrl: expectedGatewayUrl(locationParams.pathname),
321+
token: "",
322+
});
323+
}
324+
});
325+
242326
it("does not reuse a session token for a different gatewayUrl", async () => {
243327
setTestLocation({
244328
protocol: "https:",

ui/src/ui/storage.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,37 @@ const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1";
55
const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1";
66
const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
77
const MAX_SCOPED_SESSION_ENTRIES = 10;
8+
const LOOPBACK_TOKEN_SCOPE_HOST = "localhost";
89

910
function settingsKeyForGateway(gatewayUrl: string): string {
1011
return `${SETTINGS_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
1112
}
1213

14+
function isIpv4LoopbackLiteral(hostname: string): boolean {
15+
const parts = hostname.split(".");
16+
if (parts.length !== 4) {
17+
return false;
18+
}
19+
const octets = parts.map((part) => {
20+
if (!/^\d+$/.test(part)) {
21+
return Number.NaN;
22+
}
23+
return Number(part);
24+
});
25+
return (
26+
octets[0] === 127 &&
27+
octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
28+
);
29+
}
30+
31+
function normalizeGatewayTokenScopeHost(hostname: string): string {
32+
const normalized = hostname.toLowerCase();
33+
if (normalized === "localhost" || normalized === "[::1]" || isIpv4LoopbackLiteral(normalized)) {
34+
return LOOPBACK_TOKEN_SCOPE_HOST;
35+
}
36+
return hostname;
37+
}
38+
1339
type ScopedSessionSelection = {
1440
sessionKey: string;
1541
lastActiveSessionKey: string;
@@ -116,7 +142,12 @@ function normalizeGatewayTokenScope(gatewayUrl: string): string {
116142
const parsed = base ? new URL(trimmed, base) : new URL(trimmed);
117143
const pathname =
118144
parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname;
119-
return `${parsed.protocol}//${parsed.host}${pathname}`;
145+
const scopeHost = normalizeGatewayTokenScopeHost(parsed.hostname);
146+
const host =
147+
scopeHost === parsed.hostname
148+
? parsed.host
149+
: `${scopeHost}${parsed.port ? `:${parsed.port}` : ""}`;
150+
return `${parsed.protocol}//${host}${pathname}`;
120151
} catch {
121152
return trimmed;
122153
}

0 commit comments

Comments
 (0)