diff --git a/docs/gateway/security/audit-checks.md b/docs/gateway/security/audit-checks.md
index 962231f1e9b3..dff3cedac5a1 100644
--- a/docs/gateway/security/audit-checks.md
+++ b/docs/gateway/security/audit-checks.md
@@ -54,7 +54,6 @@ exhaustive):
| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
| `gateway.control_ui.allowed_origins_wildcard` | warn/critical | `allowedOrigins=["*"]` disables browser-origin allowlisting | `gateway.controlUi.allowedOrigins` | no |
| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
-| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
| `gateway.control_ui.device_auth_disabled` | critical | Retired device-auth bypass migration still permits remediation access | complete **Secure this browser** pairing (`gateway.controlUi.deviceAuthMigration` state) | no |
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
| `gateway.token_too_short` | warn | Short shared token is easier to brute force | `gateway.auth.token` | no |
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md
index e94ac8f63bdc..59963a8ae812 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -663,11 +663,9 @@ Trusted proxy headers do not make node device pairing automatically trusted - `g
The Control UI needs a secure context (HTTPS or localhost) to generate device identity.
-- `gateway.controlUi.allowInsecureAuth`: local compatibility toggle. On localhost, allows Control UI auth without device identity when the page loads over non-secure HTTP. Does not bypass pairing checks and does not relax remote (non-localhost) device identity requirements. Prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
+- Token/password auth does not replace browser device identity over remote plain HTTP. Use HTTPS (for example, Tailscale Serve) or open the UI on `127.0.0.1` from the Gateway host.
- `gateway.controlUi.dangerouslyDisableDeviceAuth`: retired break-glass input. Older configs preserve authenticated, pairing-only Control UI access for remediation until a browser reopened over HTTPS or localhost completes the bounded, explicit self-pairing migration; do not add it to current config.
-- Separate from those flags, a successful `gateway.auth.mode: "trusted-proxy"` can admit **operator** Control UI sessions without device identity - an intentional auth-mode behavior, not an `allowInsecureAuth` shortcut, and it does not extend to node-role Control UI sessions.
-
-`openclaw security audit` warns when `allowInsecureAuth` is enabled.
+- Separately, successful `gateway.auth.mode: "trusted-proxy"` authentication can admit **operator** Control UI sessions without device identity. This does not extend to node-role Control UI sessions.
### Insecure/dangerous flags
@@ -675,7 +673,6 @@ The Control UI needs a secure context (HTTPS or localhost) to generate device id
- - `gateway.controlUi.allowInsecureAuth=true`
- `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true`
- pending Control UI device-auth migration imported from retired `gateway.controlUi.dangerouslyDisableDeviceAuth=true`
- `security.audit.suppressions configured ()`
diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md
index 1b01c838b9c1..dc4a797ce3ac 100644
--- a/docs/gateway/tailscale.md
+++ b/docs/gateway/tailscale.md
@@ -65,11 +65,12 @@ Use this to have the gateway listen directly on the Tailnet IP, with no Serve/Fu
}
```
-Connect from another Tailnet device:
+Connect a native or CLI client from another Tailnet device:
-- Control UI: `http://:18789/`
- WebSocket: `ws://:18789`
+Do not use the direct plain-HTTP address for the browser Control UI. Remote plain HTTP cannot create browser device identity, and token/password auth does not replace it. Use Tailscale Serve for the Control UI.
+
When a bindable Tailnet IPv4 is present, the Gateway also requires `http://127.0.0.1:18789` for authenticated same-host clients. If no Tailnet address is available at startup, it falls back to loopback only; restart after Tailscale becomes available to add direct Tailnet access. Neither path adds LAN or public exposure.
diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md
index e50ee1947649..ddb055186e16 100644
--- a/docs/help/faq-first-run.md
+++ b/docs/help/faq-first-run.md
@@ -107,7 +107,6 @@ and troubleshooting see the main [FAQ](/help/faq).
- **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https:///`. With `gateway.auth.allowTailscale: true`, identity headers satisfy Control UI/WebSocket auth (no pasted shared secret, assumes a trusted gateway host); HTTP APIs still need shared-secret auth unless you deliberately use private-ingress `none` or trusted-proxy HTTP auth.
Concurrent bad-auth Serve attempts from the same client are serialized before the failed-auth limiter records them, so a second bad retry can already show `retry later`.
- - **Tailnet bind**: run `openclaw gateway --bind tailnet --token ""` (or configure password auth), open `http://:18789/`, paste the matching shared secret in dashboard settings.
- **Identity-aware reverse proxy**: keep the Gateway behind a trusted proxy, set `gateway.auth.mode: "trusted-proxy"`, open the proxy URL. Same-host loopback proxies need explicit `gateway.auth.trustedProxy.allowLoopback: true`.
- **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@gateway-host`, then open `http://127.0.0.1:18789/`. Shared-secret auth still applies over the tunnel; paste the configured token or password if prompted.
diff --git a/docs/install/digitalocean.md b/docs/install/digitalocean.md
index 940dec1e7b65..8b5225810878 100644
--- a/docs/install/digitalocean.md
+++ b/docs/install/digitalocean.md
@@ -116,15 +116,6 @@ DigitalOcean is a straightforward paid VPS path. For cheaper or free options:
Tailscale Serve authenticates Control UI and WebSocket traffic via tailnet identity headers, which assumes the gateway host itself is trusted. HTTP API endpoints still follow the gateway's normal auth mode (token/password) regardless. To require explicit shared-secret credentials over Serve, set `gateway.auth.allowTailscale: false` and use `gateway.auth.mode: "token"` or `"password"`.
- **Option C: Tailnet bind (no Serve)**
-
- ```bash
- openclaw config set gateway.bind tailnet
- openclaw gateway restart
- ```
-
- Then open `http://:18789` (token required).
-
diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index 0e610ccf003c..77ba70d8e8e2 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -118,7 +118,7 @@ The assistant avatar override follows the same browser-local pattern: uploaded o
## Runtime config endpoint
-The Control UI fetches its runtime settings from `/control-ui-config.json`, resolved relative to the gateway's Control UI base path (for example `/__openclaw__/control-ui-config.json` under base path `/__openclaw__/`). That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires a valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
+The Control UI fetches its runtime settings from `/control-ui-config.json`, resolved relative to the gateway's Control UI base path (for example `/__openclaw__/control-ui-config.json` under base path `/__openclaw__/`). That endpoint is gated by gateway HTTP auth: unauthenticated browsers cannot fetch it, and a successful fetch requires a valid gateway token/password or trusted-proxy identity. Tailscale header auth applies to the Control UI WebSocket, not this HTTP endpoint.
## Gateway host status
@@ -614,40 +614,25 @@ constrained `min(...)`, `max(...)`, `clamp(...)`, `calc(...)`, and
## Tailnet access (recommended)
-
-
- Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS:
+Keep the Gateway on loopback and let Tailscale Serve proxy it with HTTPS:
- ```bash
- openclaw gateway --tailscale serve
- ```
+```bash
+openclaw gateway --tailscale serve
+```
- Open `https:///` (or your configured `gateway.controlUi.basePath`).
+Open `https:///` (or your configured `gateway.controlUi.basePath`).
- By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. For Control UI operator sessions with browser device identity, this verified Serve path also skips the device-pairing round trip; device-less browsers and node-role connections still follow the normal device checks. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic, then use `gateway.auth.mode: "token"` or `"password"`.
+By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw verifies the identity by resolving the `x-forwarded-for` address with `tailscale whois` and matching it to the header, and only accepts these when the request hits loopback with Tailscale's `x-forwarded-*` headers. For Control UI operator sessions with browser device identity, this verified Serve path also skips the device-pairing round trip; device-less browsers and node-role connections still follow the normal device checks. Set `gateway.auth.allowTailscale: false` if you want to require explicit shared-secret credentials even for Serve traffic, then use `gateway.auth.mode: "token"` or `"password"`.
- For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel.
+For that async Serve identity path, failed auth attempts for the same client IP and auth scope are serialized before rate-limit writes. Concurrent bad retries from the same browser can therefore show `retry later` on the second request instead of two plain mismatches racing in parallel.
-
- Tokenless Serve auth assumes the gateway host is trusted. If untrusted local code may run on that host, require token/password auth.
-
-
-
-
- ```bash
- openclaw gateway --bind tailnet --token "$(openssl rand -hex 32)"
- ```
-
- Open `http://:18789/` (or your configured `gateway.controlUi.basePath`).
-
- Paste the matching shared secret into the UI settings (sent as `connect.params.auth.token` or `connect.params.auth.password`).
-
-
-
+
+Tokenless Serve auth assumes the gateway host is trusted. If untrusted local code may run on that host, require token/password auth.
+
## Insecure HTTP
-If you open the dashboard over plain HTTP (`http://` or `http://`), the browser runs in a **non-secure context** and blocks WebCrypto. By default, OpenClaw **blocks** Control UI connections without device identity.
+If you open the dashboard over plain HTTP (`http://` or `http://`), the browser runs in a **non-secure context** and blocks WebCrypto. OpenClaw rejects token/password Control UI connections without device identity; a shared secret cannot replace browser identity.
The supported device-less exception is successful operator Control UI auth
through `gateway.auth.mode: "trusted-proxy"`. There is no persistent config
@@ -659,7 +644,7 @@ switch that disables device identity.
- Successful trusted-proxy auth can admit **operator** Control UI sessions without device identity.
- This does **not** extend to node-role Control UI sessions.
- - Same-host loopback reverse proxies still do not satisfy trusted-proxy auth; see [Trusted proxy auth](/gateway/trusted-proxy-auth).
+ - A same-host loopback reverse proxy requires both loopback in `gateway.trustedProxies` and `gateway.auth.trustedProxy.allowLoopback: true`; see [Trusted proxy auth](/gateway/trusted-proxy-auth).
diff --git a/docs/web/index.md b/docs/web/index.md
index c9a0c6e4c1be..0f2817b1ae19 100644
--- a/docs/web/index.md
+++ b/docs/web/index.md
@@ -57,26 +57,6 @@ When `hooks.enabled=true`, the Gateway also exposes a webhook endpoint on the sa
Open `https:///` (or your configured `gateway.controlUi.basePath`).
-
-
- ```json5
- {
- gateway: {
- bind: "tailnet",
- controlUi: { enabled: true },
- auth: { mode: "token", token: "your-token" },
- },
- }
- ```
-
- Start the gateway (this non-loopback example uses shared-secret token auth):
-
- ```bash
- openclaw gateway
- ```
-
- Open `http://:18789/` (or your configured `gateway.controlUi.basePath`).
-
```json5
diff --git a/src/gateway/server/ws-connection/connect-auth.ts b/src/gateway/server/ws-connection/connect-auth.ts
index 31c145e4b702..80bdf793dfef 100644
--- a/src/gateway/server/ws-connection/connect-auth.ts
+++ b/src/gateway/server/ws-connection/connect-auth.ts
@@ -68,7 +68,6 @@ export async function authenticateGatewayConnect(
} = context.handler;
const {
connectParams,
- configSnapshot,
trustedProxies,
allowRealIpFallback,
peerLabel,
@@ -105,7 +104,6 @@ export async function authenticateGatewayConnect(
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
const controlUiAuthPolicy = resolveControlUiAuthPolicy({
isControlUi,
- controlUiConfig: configSnapshot.gateway?.controlUi,
deviceRaw,
deviceAuthMigrationPending: context.handler.isControlUiDeviceAuthMigrationPending?.(),
});
diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts
index ec9b6c57249d..be3618ea0a3d 100644
--- a/src/gateway/server/ws-connection/connect-policy.test.ts
+++ b/src/gateway/server/ws-connection/connect-policy.test.ts
@@ -33,7 +33,6 @@ function deviceRaw(id: string): DeviceRaw {
function authPolicy(params: Partial = {}) {
return resolveControlUiAuthPolicy({
isControlUi: params.isControlUi ?? false,
- controlUiConfig: params.controlUiConfig,
deviceRaw: params.deviceRaw ?? null,
deviceAuthMigrationPending: params.deviceAuthMigrationPending,
});
@@ -93,16 +92,14 @@ function expectClearsUnboundScopes(overrides: Partial,
describe("ws connect policy", () => {
test("resolves control-ui auth policy", () => {
- const bypass = authPolicy({
+ const controlUi = authPolicy({
isControlUi: true,
- controlUiConfig: { dangerouslyDisableDeviceAuth: true },
deviceRaw: deviceRaw("dev-1"),
});
- expect(bypass.device?.id).toBe("dev-1");
+ expect(controlUi.device?.id).toBe("dev-1");
const regular = authPolicy({
isControlUi: false,
- controlUiConfig: { dangerouslyDisableDeviceAuth: true },
deviceRaw: deviceRaw("dev-2"),
});
expect(regular.device?.id).toBe("dev-2");
@@ -160,18 +157,7 @@ describe("ws connect policy", () => {
test("evaluates missing-device decisions", () => {
const policy = authPolicy();
- const controlUiStrict = authPolicy({
- isControlUi: true,
- controlUiConfig: { allowInsecureAuth: true, dangerouslyDisableDeviceAuth: false },
- });
- const controlUiNoInsecure = authPolicy({
- isControlUi: true,
- controlUiConfig: { dangerouslyDisableDeviceAuth: false },
- });
- const bypass = authPolicy({
- isControlUi: true,
- controlUiConfig: { dangerouslyDisableDeviceAuth: true },
- });
+ const controlUi = authPolicy({ isControlUi: true });
expectMissingDeviceDecision(
{
@@ -182,34 +168,21 @@ describe("ws connect policy", () => {
"allow",
);
- // Remote Control UI with allowInsecureAuth -> still rejected.
expectMissingDeviceDecision(
{
role: "operator",
isControlUi: true,
- controlUiAuthPolicy: controlUiStrict,
+ controlUiAuthPolicy: controlUi,
isLocalClient: false,
},
"reject-control-ui-insecure-auth",
);
- // The retired insecure-auth flag no longer bypasses device identity locally.
expectMissingDeviceDecision(
{
role: "operator",
isControlUi: true,
- controlUiAuthPolicy: controlUiStrict,
- isLocalClient: true,
- },
- "reject-control-ui-insecure-auth",
- );
-
- // Control UI without allowInsecureAuth, even on localhost -> rejected.
- expectMissingDeviceDecision(
- {
- role: "operator",
- isControlUi: true,
- controlUiAuthPolicy: controlUiNoInsecure,
+ controlUiAuthPolicy: controlUi,
isLocalClient: true,
},
"reject-control-ui-insecure-auth",
@@ -263,7 +236,7 @@ describe("ws connect policy", () => {
{
role: "operator",
isControlUi: true,
- controlUiAuthPolicy: controlUiNoInsecure,
+ controlUiAuthPolicy: controlUi,
trustedProxyAuthOk: true,
sharedAuthOk: false,
hasSharedAuth: false,
@@ -275,7 +248,7 @@ describe("ws connect policy", () => {
{
role: "operator",
isControlUi: true,
- controlUiAuthPolicy: bypass,
+ controlUiAuthPolicy: controlUi,
sharedAuthOk: false,
authOk: false,
hasSharedAuth: false,
@@ -283,12 +256,11 @@ describe("ws connect policy", () => {
"reject-control-ui-insecure-auth",
);
- // Retired bypass input cannot admit device-less node-role registrations.
expectMissingDeviceDecision(
{
role: "node",
isControlUi: true,
- controlUiAuthPolicy: bypass,
+ controlUiAuthPolicy: controlUi,
sharedAuthOk: false,
authOk: false,
hasSharedAuth: false,
@@ -297,15 +269,10 @@ describe("ws connect policy", () => {
);
});
- test("retired device-auth bypass input does not skip pairing", () => {
- const bypass = authPolicy({
- isControlUi: true,
- controlUiConfig: { dangerouslyDisableDeviceAuth: true },
- });
+ test("strict control-ui policy does not skip pairing", () => {
const strict = authPolicy({ isControlUi: true });
- expectSkipPairing(bypass, "operator", false);
- expectSkipPairing(bypass, "node", false);
+ expectSkipPairing(strict, "node", false);
expectSkipPairing(strict, "operator", false);
expectSkipPairing(strict, "operator", false, { pairingComplete: true });
});
@@ -413,10 +380,7 @@ describe("ws connect policy", () => {
test("clears unbound scopes for device-less shared auth outside explicit preservation cases", () => {
const nonControlUi = authPolicy();
- const controlUi = authPolicy({
- isControlUi: true,
- controlUiConfig: { allowInsecureAuth: true },
- });
+ const controlUi = authPolicy({ isControlUi: true });
expectClearsUnboundScopes({ controlUiAuthPolicy: nonControlUi }, true);
expectClearsUnboundScopes(
diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts
index a1e35c56f162..0de7a4d3a227 100644
--- a/src/gateway/server/ws-connection/connect-policy.ts
+++ b/src/gateway/server/ws-connection/connect-policy.ts
@@ -11,11 +11,9 @@ type ControlUiAuthPolicy = {
export function resolveControlUiAuthPolicy(params: {
isControlUi: boolean;
- controlUiConfig: unknown;
deviceRaw: ConnectParams["device"] | null | undefined;
deviceAuthMigrationPending?: boolean;
}): ControlUiAuthPolicy {
- void params.controlUiConfig;
return {
isControlUi: params.isControlUi,
device: params.deviceRaw,
diff --git a/src/tui/gateway-chat.scopes.test.ts b/src/tui/gateway-chat.scopes.test.ts
index f62ca10b7cd6..90cb738481bc 100644
--- a/src/tui/gateway-chat.scopes.test.ts
+++ b/src/tui/gateway-chat.scopes.test.ts
@@ -122,7 +122,6 @@ describe("GatewayChatClient operator scopes", () => {
const client = new RealGatewayChatClient({
url: "ws://127.0.0.1:18789",
...(token ? { token } : {}),
- allowInsecureLocalOperatorUi: false,
});
clients.push(client);
const socketCount = sockets.length;
diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts
index 2ce50aa2645f..325dd3feff50 100644
--- a/src/tui/gateway-chat.test.ts
+++ b/src/tui/gateway-chat.test.ts
@@ -182,7 +182,6 @@ describe("resolveGatewayConnection", () => {
token: undefined,
password: undefined,
tlsFingerprint: "sha256:selected",
- allowInsecureLocalOperatorUi: false,
});
expect(loadConfig).not.toHaveBeenCalled();
},
@@ -220,7 +219,6 @@ describe("resolveGatewayConnection", () => {
url: "wss://override.example/ws",
...expected,
preauthHandshakeTimeoutMs: undefined,
- allowInsecureLocalOperatorUi: false,
});
});
@@ -590,46 +588,6 @@ describe("resolveGatewayConnection", () => {
},
);
});
-
- it("keeps loopback local connections on device-authenticated operator UI", async () => {
- loadConfig.mockReturnValue({
- gateway: {
- mode: "local",
- controlUi: {
- allowInsecureAuth: true,
- },
- auth: {
- mode: "token",
- token: "config-token",
- },
- },
- });
-
- const result = await resolveGatewayConnection({});
- expect(result.allowInsecureLocalOperatorUi).toBe(false);
- });
-
- it("keeps a loopback URL override on device-authenticated operator UI", async () => {
- loadConfig.mockReturnValue({
- gateway: {
- mode: "local",
- controlUi: {
- allowInsecureAuth: true,
- },
- auth: {
- mode: "token",
- token: "config-token",
- },
- },
- });
-
- const result = await resolveGatewayConnection({
- url: "ws://127.0.0.1:18791",
- token: "override-token",
- });
- expect(result.allowInsecureLocalOperatorUi).toBe(false);
- expect(result.token).toBe("override-token");
- });
});
describe("GatewayChatClient", () => {
@@ -641,7 +599,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
let finishStop: (() => void) | undefined;
const stopAndWait = vi.fn(
@@ -691,10 +648,8 @@ describe("GatewayChatClient", () => {
token: "test-token",
tlsFingerprint: "sha256:11:22:33:44",
preauthHandshakeTimeoutMs: 30_000,
- allowInsecureLocalOperatorUi: true,
});
- expect(client.connection.allowInsecureLocalOperatorUi).toBe(true);
expect(constructedOptions).toHaveLength(1);
expect(constructedOptions[0]).toMatchObject({
clientName: "openclaw-tui",
@@ -703,8 +658,8 @@ describe("GatewayChatClient", () => {
scopes: ["operator.admin", "operator.read", "operator.write", "operator.approvals"],
preauthHandshakeTimeoutMs: 30_000,
tlsFingerprint: "sha256:11:22:33:44",
- deviceIdentity: null,
});
+ expect(constructedOptions[0]).not.toHaveProperty("deviceIdentity");
const onConnectError = vi.fn();
const onDisconnected = vi.fn();
client.onConnectError = onConnectError;
@@ -761,7 +716,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
client.onDisconnected = onDisconnected;
@@ -783,7 +737,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const request = vi
.fn()
@@ -811,7 +764,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const request = vi.fn().mockResolvedValue({ messages: [] });
(client as unknown as { client: { request: typeof request } }).client.request = request;
@@ -850,7 +802,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const request = vi.fn().mockResolvedValue({ ok: true, aborted: true });
(client as unknown as { client: { request: typeof request } }).client.request = request;
@@ -867,7 +818,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const request = vi
.fn()
@@ -896,7 +846,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const request = vi
.fn()
@@ -933,7 +882,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const request = vi
.fn()
@@ -971,7 +919,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const request = vi.fn().mockResolvedValue({ runId: "run-gateway", status: "timeout" });
(client as unknown as { client: { request: typeof request } }).client.request = request;
@@ -989,7 +936,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const command = {
name: "tts",
@@ -1016,7 +962,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const pending = [{ id: "plugin:skill-1" }];
const request = vi.fn().mockResolvedValueOnce(pending).mockResolvedValueOnce({ ok: true });
@@ -1038,7 +983,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const suggestion = {
id: "task_1",
@@ -1082,7 +1026,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
client.hello = {
features: {
@@ -1101,7 +1044,6 @@ describe("GatewayChatClient", () => {
const client = new GatewayChatClient({
url: "ws://127.0.0.1:18789",
token: "test-token",
- allowInsecureLocalOperatorUi: true,
});
const request = vi.fn();
client.hello = { features: { methods: ["chat.history"] } } as never;
diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts
index c5788aff9e16..8efa70f3515e 100644
--- a/src/tui/gateway-chat.ts
+++ b/src/tui/gateway-chat.ts
@@ -67,7 +67,6 @@ type ResolvedGatewayConnection = {
password?: string;
tlsFingerprint?: string;
preauthHandshakeTimeoutMs?: number;
- allowInsecureLocalOperatorUi: boolean;
};
function throwGatewayAuthResolutionError(reason: string): never {
@@ -161,7 +160,6 @@ export class GatewayChatClient implements TuiBackend {
platform: process.platform,
mode: GATEWAY_CLIENT_MODES.UI,
scopes: ["operator.admin", "operator.read", "operator.write", "operator.approvals"],
- deviceIdentity: connection.allowInsecureLocalOperatorUi ? null : undefined,
caps: [
GATEWAY_CLIENT_CAPS.AGENT_KIND,
GATEWAY_CLIENT_CAPS.PLUGIN_APPROVALS,
@@ -469,7 +467,6 @@ function resolveBoundGatewayConnection(
token: explicitAuth.token,
password: explicitAuth.password,
...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}),
- allowInsecureLocalOperatorUi: false,
};
}
@@ -505,7 +502,6 @@ async function resolveGatewayConnection(
...(urlOverride ? { url: urlOverride } : {}),
...(activeLocalGatewayPort ? { localPortOverride: activeLocalGatewayPort } : {}),
}).url;
- const allowInsecureLocalOperatorUi = false;
if (urlOverride) {
return {
@@ -513,7 +509,6 @@ async function resolveGatewayConnection(
token: explicitAuth.token,
password: explicitAuth.password,
...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}),
- allowInsecureLocalOperatorUi,
};
}
@@ -535,7 +530,6 @@ async function resolveGatewayConnection(
...((opts.tlsFingerprint ?? config.gateway?.remote?.tlsFingerprint)
? { tlsFingerprint: opts.tlsFingerprint ?? config.gateway?.remote?.tlsFingerprint }
: {}),
- allowInsecureLocalOperatorUi: false,
};
}
@@ -551,7 +545,6 @@ async function resolveGatewayConnection(
token: resolved.token,
password: resolved.password,
...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}),
- allowInsecureLocalOperatorUi,
};
}
@@ -576,6 +569,5 @@ async function resolveGatewayConnection(
token: resolved.token,
password: resolved.password,
...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}),
- allowInsecureLocalOperatorUi,
};
}
diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts
index 2f61d2488d19..808d13e8af94 100644
--- a/src/tui/tui-pty-local.e2e.test.ts
+++ b/src/tui/tui-pty-local.e2e.test.ts
@@ -746,7 +746,6 @@ async function startSharedGatewayFixture(): Promise {
controlClient = new GatewayChatClient({
url: gateway.url,
token: gateway.gatewayToken,
- allowInsecureLocalOperatorUi: false,
});
controlClient.onConnected = () => {
controlClientConnected = true;
@@ -860,7 +859,6 @@ async function startGatewayModeTui(
const controlClient = new GatewayChatClient({
url: shared.gateway.url,
token: shared.gateway.gatewayToken,
- allowInsecureLocalOperatorUi: false,
});
let controlClientConnected = false;
controlClient.onConnected = () => {
@@ -1573,7 +1571,6 @@ export default {
eventProbe = new GatewayChatClient({
url: fixture.gateway.url,
token: fixture.gateway.gatewayToken,
- allowInsecureLocalOperatorUi: false,
});
eventProbe.onConnected = () => {
probeConnected = true;
@@ -1725,7 +1722,6 @@ export default {
const historyClient = new GatewayChatClient({
url: shared.gateway.url,
token: shared.gateway.gatewayToken,
- allowInsecureLocalOperatorUi: false,
});
let historyClientConnected = false;
historyClient.onConnected = () => {
@@ -1809,7 +1805,6 @@ export default {
const controlClient = new GatewayChatClient({
url: shared.gateway.url,
token: shared.gateway.gatewayToken,
- allowInsecureLocalOperatorUi: false,
});
let controlClientConnected = false;
controlClient.onConnected = () => {
@@ -2267,7 +2262,6 @@ export default {
const queueClient = new GatewayChatClient({
url: fixture.gateway.url,
token: fixture.gateway.gatewayToken,
- allowInsecureLocalOperatorUi: false,
});
try {
let queueClientConnected = false;
diff --git a/test/gateway-queued-session-rotation.e2e.test.ts b/test/gateway-queued-session-rotation.e2e.test.ts
index 41d18b9a6ed6..b9afd001ed3d 100644
--- a/test/gateway-queued-session-rotation.e2e.test.ts
+++ b/test/gateway-queued-session-rotation.e2e.test.ts
@@ -264,7 +264,6 @@ describe("Gateway queued session rotation", () => {
const client = new GatewayChatClient({
url: instance.url,
token: "secret-token",
- allowInsecureLocalOperatorUi: false,
});
client.start();
await client.waitForReady();
diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts
index 8eafc7b7d295..7f3e7090eaa5 100644
--- a/ui/src/api/gateway.ts
+++ b/ui/src/api/gateway.ts
@@ -437,8 +437,7 @@ export class GatewayBrowserClient {
const explicitPassword = this.opts.password?.trim() || undefined;
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
- // Over plain HTTP, we skip device identity and fall back to token-only auth.
- // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
+ // Token/password auth cannot replace browser device identity over plain HTTP.
const isSecureContext = typeof crypto !== "undefined" && Boolean(crypto.subtle);
let deviceIdentity: Awaited> | null = null;
let selectedAuth: GatewayConnectAuthSelection = {
diff --git a/ui/src/components/login-gate.test.ts b/ui/src/components/login-gate.test.ts
index e956db7ca6b1..104ff5d5ccfd 100644
--- a/ui/src/components/login-gate.test.ts
+++ b/ui/src/components/login-gate.test.ts
@@ -101,6 +101,22 @@ describe("login gate failure recovery", () => {
]);
});
+ it("offers only supported recovery for an insecure browser context", async () => {
+ const element = await mountFailure(
+ "device identity required",
+ ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
+ );
+
+ const steps = Array.from(
+ element.querySelectorAll(".login-gate__failure-steps li"),
+ (entry) => entry.textContent?.trim(),
+ );
+ expect(steps).toEqual([
+ "Use HTTPS/Tailscale Serve, or open http://127.0.0.1:18789 on the Gateway host.",
+ "Do not use a remote plain-HTTP URL; a token or password cannot replace browser device identity.",
+ ]);
+ });
+
it.each(["click", "Enter", " ", "nested button"])(
"surfaces denied gateway-command copying from the %s interaction",
async (interaction) => {
diff --git a/ui/src/components/login-gate.ts b/ui/src/components/login-gate.ts
index 3f4c1db78a2e..42f88dbd527f 100644
--- a/ui/src/components/login-gate.ts
+++ b/ui/src/components/login-gate.ts
@@ -178,11 +178,7 @@ function resolveLoginFailureFeedback(
docsHref: "https://docs.openclaw.ai/web/control-ui#insecure-http",
titleKey: "login.failure.insecure.title",
summaryKey: "login.failure.insecure.summary",
- stepKeys: [
- "login.failure.insecure.stepHttps",
- "login.failure.insecure.stepLocalCompat",
- "login.failure.insecure.stepAvoidDisable",
- ],
+ stepKeys: ["login.failure.insecure.stepHttps", "login.failure.insecure.stepAvoidDisable"],
});
}
diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts
index 1dc2651ee8e9..620653778f52 100644
--- a/ui/src/i18n/locales/en.ts
+++ b/ui/src/i18n/locales/en.ts
@@ -4247,9 +4247,8 @@ export const en: TranslationMap = {
summary:
"This page is running over plain HTTP, so the browser cannot create the device identity the Gateway expects.",
stepHttps: "Use HTTPS/Tailscale Serve, or open http://127.0.0.1:18789 on the Gateway host.",
- stepLocalCompat:
- "For local token-only compatibility, set gateway.controlUi.allowInsecureAuth: true.",
- stepAvoidDisable: "Avoid disabling device auth for remote HTTP access.",
+ stepAvoidDisable:
+ "Do not use a remote plain-HTTP URL; a token or password cannot replace browser device identity.",
},
origin: {
title: "Browser origin not allowed",
diff --git a/ui/src/pages/chat/connect-error.test.ts b/ui/src/pages/chat/connect-error.test.ts
index 86e6ba61fade..6eb7a9f6af9a 100644
--- a/ui/src/pages/chat/connect-error.test.ts
+++ b/ui/src/pages/chat/connect-error.test.ts
@@ -33,4 +33,13 @@ describe("formatConnectError", () => {
}),
).toBe("device role upgrade requires approval (approved: operator; requested: node)");
});
+
+ it("routes missing browser identity to a supported secure context", () => {
+ expect(
+ formatConnectError({
+ message: "device identity required",
+ details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED },
+ }),
+ ).toBe("device identity required (use HTTPS or localhost)");
+ });
});
diff --git a/ui/src/pages/chat/connect-error.ts b/ui/src/pages/chat/connect-error.ts
index b86de0d7a94f..49a563a82c89 100644
--- a/ui/src/pages/chat/connect-error.ts
+++ b/ui/src/pages/chat/connect-error.ts
@@ -73,7 +73,7 @@ function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): st
case ConnectErrorDetailCodes.PAIRING_REQUIRED:
return formatPairingRequiredError(error);
case ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED:
- return "device identity required (use HTTPS/localhost or allow insecure auth explicitly)";
+ return "device identity required (use HTTPS or localhost)";
case ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED:
return "origin not allowed (open the Control UI from the gateway host or allow it in gateway.controlUi.allowedOrigins)";
case ConnectErrorDetailCodes.AUTH_TOKEN_MISSING: