fix: add dedicated tui gateway client auth

This commit is contained in:
Shakker
2026-03-27 10:01:16 +00:00
committed by Shakker
parent 3d609b112e
commit 2b96569e2d
7 changed files with 104 additions and 10 deletions

View File

@@ -1,6 +1,7 @@
export const GATEWAY_CLIENT_IDS = {
WEBCHAT_UI: "webchat-ui",
CONTROL_UI: "openclaw-control-ui",
TUI: "openclaw-tui",
WEBCHAT: "webchat",
CLI: "cli",
GATEWAY_CLIENT: "gateway-client",

View File

@@ -122,12 +122,13 @@ export function registerControlUiAndPairingSuite(): void {
ws: WebSocket;
token?: string;
password?: string;
client?: { id: string; version: string; platform: string; mode: string };
}) => {
const res = await connectReq(params.ws, {
...(params.token ? { token: params.token } : {}),
...(params.password ? { password: params.password } : {}),
device: null,
client: { ...CONTROL_UI_CLIENT },
client: { ...(params.client ?? CONTROL_UI_CLIENT) },
});
expect(res.ok).toBe(true);
await expectStatusAndHealthOk(params.ws);
@@ -296,6 +297,26 @@ export function registerControlUiAndPairingSuite(): void {
restoreGatewayToken(prevToken);
});
test("allows localhost tui without device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
const { server, ws, prevToken } = await startServerWithClient("secret", {
wsHeaders: { origin: "http://127.0.0.1" },
});
await connectControlUiWithoutDeviceAndExpectOk({
ws,
token: "secret",
client: {
id: GATEWAY_CLIENT_NAMES.TUI,
version: "1.0.0",
platform: "darwin",
mode: GATEWAY_CLIENT_MODES.UI,
},
});
ws.close();
await server.close();
restoreGatewayToken(prevToken);
});
test("allows control ui password-only auth on localhost when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
testState.gatewayAuth = { mode: "password", password: "secret" }; // pragma: allowlist secret

View File

@@ -1,6 +1,9 @@
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import {
isGatewayCliClient,
isOperatorUiClient,
isWebchatClient,
} from "../../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
export type AuthProvidedKind = "token" | "bootstrap-token" | "device-token" | "password" | "none";
@@ -12,7 +15,7 @@ export function formatGatewayAuthFailureMessage(params: {
}): string {
const { authMode, authProvided, reason, client } = params;
const isCli = isGatewayCliClient(client);
const isControlUi = client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const isControlUi = isOperatorUiClient(client);
const isWebchat = isWebchatClient(client);
const uiHint = "open the dashboard URL and paste the token in Control UI settings";
const tokenHint = isCli

View File

@@ -23,7 +23,11 @@ import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import {
isGatewayCliClient,
isOperatorUiClient,
isWebchatClient,
} from "../../../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../../../version.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
@@ -42,7 +46,6 @@ import {
} from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import {
ConnectErrorDetailCodes,
resolveDeviceAuthConnectErrorDetailCode,
@@ -401,7 +404,7 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role;
connectParams.scopes = scopes;
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const isControlUi = isOperatorUiClient(connectParams.client);
const isWebchat = isWebchatConnect(connectParams);
if (enforceOriginCheckForAnyClient || isControlUi || isWebchat) {
const hostHeaderOriginFallbackEnabled =

View File

@@ -8,7 +8,7 @@ import {
} from "../gateway/gateway-connection.test-mocks.js";
import { captureEnv, withEnvAsync } from "../test-utils/env.js";
const { resolveGatewayConnection } = await import("./gateway-chat.js");
const { GatewayChatClient, resolveGatewayConnection } = await import("./gateway-chat.js");
async function fileExists(filePath: string): Promise<boolean> {
try {
@@ -131,6 +131,7 @@ describe("resolveGatewayConnection", () => {
expect(result).toEqual({
url: "wss://override.example/ws",
...expected,
allowInsecureLocalOperatorUi: false,
});
});
it("uses config auth token for local mode when both config and env tokens are set", async () => {
@@ -349,4 +350,45 @@ describe("resolveGatewayConnection", () => {
},
);
});
it("marks loopback local connections for insecure operator ui auth when enabled", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "local",
controlUi: {
allowInsecureAuth: true,
},
auth: {
mode: "token",
token: "config-token",
},
},
});
const result = await resolveGatewayConnection({});
expect(result.allowInsecureLocalOperatorUi).toBe(true);
});
});
describe("GatewayChatClient", () => {
it("identifies the TUI as a tui client and skips device identity on insecure local ui paths", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
allowInsecureLocalOperatorUi: true,
});
expect(
(client as unknown as { client: { opts: { clientName?: string; mode?: string } } }).client
.opts.clientName,
).toBe("openclaw-tui");
expect(
(client as unknown as { client: { opts: { clientName?: string; mode?: string } } }).client
.opts.mode,
).toBe("ui");
expect(
(client as unknown as { client: { opts: { deviceIdentity?: unknown } } }).client.opts
.deviceIdentity,
).toBeUndefined();
});
});

View File

@@ -8,6 +8,7 @@ import {
resolveExplicitGatewayAuth,
} from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { isLoopbackHost } from "../gateway/net.js";
import { GATEWAY_CLIENT_CAPS } from "../gateway/protocol/client-info.js";
import {
type HelloOk,
@@ -46,6 +47,7 @@ type ResolvedGatewayConnection = {
url: string;
token?: string;
password?: string;
allowInsecureLocalOperatorUi?: boolean;
};
function trimToUndefined(value: unknown): string | undefined {
@@ -152,11 +154,12 @@ export class GatewayChatClient {
url: connection.url,
token: connection.token,
password: connection.password,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientName: GATEWAY_CLIENT_NAMES.TUI,
clientDisplayName: "openclaw-tui",
clientVersion: VERSION,
platform: process.platform,
mode: GATEWAY_CLIENT_MODES.UI,
deviceIdentity: connection.allowInsecureLocalOperatorUi ? null : undefined,
caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS],
instanceId: randomUUID(),
minProtocol: PROTOCOL_VERSION,
@@ -291,12 +294,23 @@ export async function resolveGatewayConnection(
config,
...(urlOverride ? { url: urlOverride } : {}),
}).url;
const allowInsecureLocalOperatorUi = (() => {
if (config.gateway?.controlUi?.allowInsecureAuth !== true) {
return false;
}
try {
return isLoopbackHost(new URL(url).hostname);
} catch {
return false;
}
})();
if (urlOverride) {
return {
url,
token: explicitAuth.token,
password: explicitAuth.password,
allowInsecureLocalOperatorUi: false,
};
}
@@ -328,7 +342,7 @@ export async function resolveGatewayConnection(
"Missing gateway auth credentials.",
);
}
return { url, token, password };
return { url, token, password, allowInsecureLocalOperatorUi: false };
}
if (gatewayAuthMode === "none" || gatewayAuthMode === "trusted-proxy") {
@@ -336,6 +350,7 @@ export async function resolveGatewayConnection(
url,
token: explicitAuth.token ?? envToken,
password: explicitAuth.password ?? envPassword,
allowInsecureLocalOperatorUi,
};
}
@@ -368,6 +383,7 @@ export async function resolveGatewayConnection(
url,
token: explicitAuth.token ?? envToken,
password,
allowInsecureLocalOperatorUi,
};
}
@@ -395,6 +411,7 @@ export async function resolveGatewayConnection(
url,
token,
password: explicitAuth.password ?? envPassword,
allowInsecureLocalOperatorUi,
};
}
@@ -421,6 +438,7 @@ export async function resolveGatewayConnection(
url,
token: explicitAuth.token ?? envToken,
password,
allowInsecureLocalOperatorUi,
};
}
@@ -429,5 +447,6 @@ export async function resolveGatewayConnection(
url,
token,
password: explicitAuth.password ?? envPassword,
allowInsecureLocalOperatorUi,
};
}

View File

@@ -42,6 +42,11 @@ export function isGatewayCliClient(client?: GatewayClientInfoLike | null): boole
return normalizeGatewayClientMode(client?.mode) === GATEWAY_CLIENT_MODES.CLI;
}
export function isOperatorUiClient(client?: GatewayClientInfoLike | null): boolean {
const clientId = normalizeGatewayClientName(client?.id);
return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI || clientId === GATEWAY_CLIENT_NAMES.TUI;
}
export function isInternalMessageChannel(raw?: string | null): raw is InternalMessageChannel {
return normalizeMessageChannel(raw) === INTERNAL_MESSAGE_CHANNEL;
}