fix: enforce secure control ui auth

This commit is contained in:
Peter Steinberger
2026-01-21 23:58:30 +00:00
parent b4776af38c
commit f76e3c1419
18 changed files with 294 additions and 48 deletions

View File

@@ -14,6 +14,9 @@ Docs: https://docs.clawd.bot
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. - Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
### Fixes ### Fixes
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380) - Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
- Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell. - Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell.

View File

@@ -2671,6 +2671,8 @@ Control UI base path:
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served. - `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`. - Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
- Default: root (`/`) (unchanged). - Default: root (`/`) (unchanged).
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
Related docs: Related docs:
- [Control UI](/web/control-ui) - [Control UI](/web/control-ui)

View File

@@ -198,6 +198,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- **Local** connects include loopback and the gateway hosts own tailnet address - **Local** connects include loopback and the gateway hosts own tailnet address
(so samehost tailnet binds can still autoapprove). (so samehost tailnet binds can still autoapprove).
- All WS clients must include `device` identity during `connect` (operator + node). - All WS clients must include `device` identity during `connect` (operator + node).
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled.
- Non-local connections must sign the server-provided `connect.challenge` nonce. - Non-local connections must sign the server-provided `connect.challenge` nonce.
## TLS + pinning ## TLS + pinning

View File

@@ -52,6 +52,15 @@ When the audit prints findings, treat this as a priority order:
5. **Plugins/extensions**: only load what you explicitly trust. 5. **Plugins/extensions**: only load what you explicitly trust.
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools. 6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
## Control UI over HTTP
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
to **token-only auth** on plain HTTP and skips device pairing. This is a security
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
`clawdbot security audit` warns when this setting is enabled.
## Local session logs live on disk ## Local session logs live on disk
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`. Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.

View File

@@ -31,6 +31,19 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
## Common Issues ## Common Issues
### Control UI fails on HTTP ("device identity required" / "connect failed")
If you open the dashboard over plain HTTP (e.g. `http://<lan-ip>:18789/` or
`http://<tailscale-ip>:18789/`), the browser runs in a **non-secure context** and
blocks WebCrypto, so device identity cant be generated.
**Fix:**
- Prefer HTTPS via [Tailscale Serve](/gateway/tailscale).
- Or open locally on the gateway host: `http://127.0.0.1:18789/`.
- If you must stay on HTTP, enable `gateway.controlUi.allowInsecureAuth: true` and
use a gateway token (token-only; no device identity/pairing). See
[Control UI](/web/control-ui#insecure-http).
### CI Secrets Scan Failed ### CI Secrets Scan Failed
This means `detect-secrets` found new candidates not yet in the baseline. This means `detect-secrets` found new candidates not yet in the baseline.

View File

@@ -38,6 +38,11 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway troubleshooting](/gateway/troubleshooting)
- [Gateway authentication](/gateway/authentication) - [Gateway authentication](/gateway/authentication)
### Control UI fails on HTTP (device identity required)
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http)
### Service says running, but RPC probe fails ### Service says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway troubleshooting](/gateway/troubleshooting)

View File

@@ -86,6 +86,33 @@ Then open:
Paste the token into the UI settings (sent as `connect.params.auth.token`). Paste the token into the UI settings (sent as `connect.params.auth.token`).
## Insecure HTTP
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`),
the browser runs in a **non-secure context** and blocks WebCrypto. By default,
Clawdbot **blocks** Control UI connections without device identity.
**Recommended fix:** use HTTPS (Tailscale Serve) or open the UI locally:
- `https://<magicdns>/` (Serve)
- `http://127.0.0.1:18789/` (on the gateway host)
**Downgrade example (token-only over HTTP):**
```json5
{
gateway: {
controlUi: { allowInsecureAuth: true },
bind: "tailnet",
auth: { mode: "token", token: "replace-me" }
}
}
```
This disables device identity + pairing for the Control UI. Use only if you
trust the network.
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
## Building the UI ## Building the UI
The Gateway serves static files from `dist/control-ui`. Build them with: The Gateway serves static files from `dist/control-ui`. Build them with:

View File

@@ -187,6 +187,7 @@ const FIELD_LABELS: Record<string, string> = {
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
"tools.web.fetch.userAgent": "Web Fetch User-Agent", "tools.web.fetch.userAgent": "Web Fetch User-Agent",
"gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode", "gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)", "gateway.reload.debounceMs": "Config Reload Debounce (ms)",
@@ -345,6 +346,8 @@ const FIELD_HELP: Record<string, string> = {
"gateway.auth.password": "Required for Tailscale funnel.", "gateway.auth.password": "Required for Tailscale funnel.",
"gateway.controlUi.basePath": "gateway.controlUi.basePath":
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).", "Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
"gateway.controlUi.allowInsecureAuth":
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
"gateway.http.endpoints.chatCompletions.enabled": "gateway.http.endpoints.chatCompletions.enabled":
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',

View File

@@ -51,6 +51,8 @@ export type GatewayControlUiConfig = {
enabled?: boolean; enabled?: boolean;
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */ /** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
basePath?: string; basePath?: string;
/** Allow token-only auth over insecure HTTP (default: false). */
allowInsecureAuth?: boolean;
}; };
export type GatewayAuthMode = "token" | "password"; export type GatewayAuthMode = "token" | "password";

View File

@@ -282,6 +282,7 @@ export const ClawdbotSchema = z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
basePath: z.string().optional(), basePath: z.string().optional(),
allowInsecureAuth: z.boolean().optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@@ -11,6 +11,7 @@ import {
startServerWithClient, startServerWithClient,
testState, testState,
} from "./test-helpers.js"; } from "./test-helpers.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
installGatewayTestHooks(); installGatewayTestHooks();
@@ -127,6 +128,52 @@ describe("gateway server auth/connect", () => {
await server.close(); await server.close();
}); });
test("rejects control ui without device identity by default", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, {
token: "secret",
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("secure context");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("allows control ui without device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
const { server, ws, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, {
token: "secret",
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(res.ok).toBe(true);
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts device token auth for paired device", async () => { test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } = const { approveDevicePairing, getPairedDevice, listDevicePairing } =

View File

@@ -39,6 +39,7 @@ import {
validateConnectParams, validateConnectParams,
validateRequestFrame, validateRequestFrame,
} from "../../protocol/index.js"; } from "../../protocol/index.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js"; import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
import { handleGatewayRequest } from "../../server-methods.js"; import { handleGatewayRequest } from "../../server-methods.js";
@@ -293,24 +294,53 @@ export function attachGatewayWsMessageHandler(params: {
const device = connectParams.device; const device = connectParams.device;
let devicePublicKey: string | null = null; let devicePublicKey: string | null = null;
// Allow token-authenticated connections (e.g., control-ui) to skip device identity const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasTokenAuth = !!connectParams.auth?.token; const hasPasswordAuth = Boolean(connectParams.auth?.password);
if (!device && !hasTokenAuth) { const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
setHandshakeState("failed");
setCloseCause("device-required", { if (!device) {
client: connectParams.client.id, const allowInsecureControlUi =
clientDisplayName: connectParams.client.displayName, isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
mode: connectParams.client.mode, const canSkipDevice =
version: connectParams.client.version, isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
});
send({ if (isControlUi && !allowInsecureControlUi) {
type: "res", const errorMessage = "control ui requires HTTPS or localhost (secure context)";
id: frame.id, setHandshakeState("failed");
ok: false, setCloseCause("control-ui-insecure-auth", {
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"), client: connectParams.client.id,
}); clientDisplayName: connectParams.client.displayName,
close(1008, "device identity required"); mode: connectParams.client.mode,
return; version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, errorMessage),
});
close(1008, errorMessage);
return;
}
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
if (!canSkipDevice) {
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
});
close(1008, "device identity required");
return;
}
} }
if (device) { if (device) {
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey); const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);

View File

@@ -210,6 +210,7 @@ export const testState = {
cronEnabled: false as boolean | undefined, cronEnabled: false as boolean | undefined,
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined, gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
gatewayAuth: undefined as Record<string, unknown> | undefined, gatewayAuth: undefined as Record<string, unknown> | undefined,
gatewayControlUi: undefined as Record<string, unknown> | undefined,
hooksConfig: undefined as HooksConfig | undefined, hooksConfig: undefined as HooksConfig | undefined,
canvasHostPort: undefined as number | undefined, canvasHostPort: undefined as number | undefined,
legacyIssues: [] as Array<{ path: string; message: string }>, legacyIssues: [] as Array<{ path: string; message: string }>,
@@ -443,6 +444,7 @@ vi.mock("../config/config.js", async () => {
: {}; : {};
if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind; if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind;
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth; if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
if (testState.gatewayControlUi) fileGateway.controlUi = testState.gatewayControlUi;
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined; const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
const fileCanvasHost = const fileCanvasHost =

View File

@@ -101,6 +101,7 @@ export function installGatewayTestHooks() {
testTailnetIPv4.value = undefined; testTailnetIPv4.value = undefined;
testState.gatewayBind = undefined; testState.gatewayBind = undefined;
testState.gatewayAuth = undefined; testState.gatewayAuth = undefined;
testState.gatewayControlUi = undefined;
testState.hooksConfig = undefined; testState.hooksConfig = undefined;
testState.canvasHostPort = undefined; testState.canvasHostPort = undefined;
testState.legacyIssues = []; testState.legacyIssues = [];
@@ -280,7 +281,7 @@ export async function connectReq(
signature: string; signature: string;
signedAt: number; signedAt: number;
nonce?: string; nonce?: string;
}; } | null;
}, },
): Promise<ConnectResponse> { ): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto"); const { randomUUID } = await import("node:crypto");
@@ -294,6 +295,7 @@ export async function connectReq(
const role = opts?.role ?? "operator"; const role = opts?.role ?? "operator";
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : []; const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => { const device = (() => {
if (opts?.device === null) return undefined;
if (opts?.device) return opts.device; if (opts?.device) return opts.device;
const identity = loadOrCreateDeviceIdentity(); const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now(); const signedAtMs = Date.now();

View File

@@ -227,6 +227,29 @@ describe("security audit", () => {
} }
}); });
it("warns when control UI allows insecure auth", async () => {
const cfg: ClawdbotConfig = {
gateway: {
controlUi: { allowInsecureAuth: true },
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.control_ui.insecure_auth",
severity: "warn",
}),
]),
);
});
it("warns when multiple DM senders share the main session", async () => { it("warns when multiple DM senders share the main session", async () => {
const cfg: ClawdbotConfig = { session: { dmScope: "main" } }; const cfg: ClawdbotConfig = { session: { dmScope: "main" } };
const plugins: ChannelPlugin[] = [ const plugins: ChannelPlugin[] = [

View File

@@ -235,6 +235,17 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
}); });
} }
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
findings.push({
checkId: "gateway.control_ui.insecure_auth",
severity: "warn",
title: "Control UI allows insecure HTTP auth",
detail:
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.",
});
}
const token = const token =
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null; typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
if (auth.mode === "token" && token && token.length < 24) { if (auth.mode === "token" && token && token.length < 24) {

View File

@@ -126,15 +126,27 @@ export class GatewayBrowserClient {
window.clearTimeout(this.connectTimer); window.clearTimeout(this.connectTimer);
this.connectTimer = null; this.connectTimer = null;
} }
const deviceIdentity = await loadOrCreateDeviceIdentity();
// 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.
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator"; const role = "operator";
const storedToken = loadDeviceAuthToken({ let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
deviceId: deviceIdentity.deviceId, let canFallbackToShared = false;
role, let authToken = this.opts.token;
})?.token;
const authToken = storedToken ?? this.opts.token; if (isSecureContext) {
const canFallbackToShared = Boolean(storedToken && this.opts.token); deviceIdentity = await loadOrCreateDeviceIdentity();
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
})?.token;
authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
}
const auth = const auth =
authToken || this.opts.password authToken || this.opts.password
? { ? {
@@ -142,19 +154,39 @@ export class GatewayBrowserClient {
password: this.opts.password, password: this.opts.password,
} }
: undefined; : undefined;
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined; let device:
const payload = buildDeviceAuthPayload({ | {
deviceId: deviceIdentity.deviceId, id: string;
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, publicKey: string;
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, signature: string;
role, signedAt: number;
scopes, nonce: string | undefined;
signedAtMs, }
token: authToken ?? null, | undefined;
nonce,
}); if (isSecureContext && deviceIdentity) {
const signature = await signDevicePayload(deviceIdentity.privateKey, payload); const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
device = {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
const params = { const params = {
minProtocol: 3, minProtocol: 3,
maxProtocol: 3, maxProtocol: 3,
@@ -167,13 +199,7 @@ export class GatewayBrowserClient {
}, },
role, role,
scopes, scopes,
device: { device,
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
},
caps: [], caps: [],
auth, auth,
userAgent: navigator.userAgent, userAgent: navigator.userAgent,
@@ -182,7 +208,7 @@ export class GatewayBrowserClient {
void this.request<GatewayHelloOk>("connect", params) void this.request<GatewayHelloOk>("connect", params)
.then((hello) => { .then((hello) => {
if (hello?.auth?.deviceToken) { if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({ storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId, deviceId: deviceIdentity.deviceId,
role: hello.auth.role ?? role, role: hello.auth.role ?? role,
@@ -194,7 +220,7 @@ export class GatewayBrowserClient {
this.opts.onHello?.(hello); this.opts.onHello?.(hello);
}) })
.catch(() => { .catch(() => {
if (canFallbackToShared) { if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
} }
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");

View File

@@ -77,6 +77,44 @@ export function renderOverview(props: OverviewProps) {
</div> </div>
`; `;
})(); })();
const insecureContextHint = (() => {
if (props.connected || !props.lastError) return null;
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
if (isSecureContext !== false) return null;
const lower = props.lastError.toLowerCase();
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
return null;
}
return html`
<div class="muted" style="margin-top: 8px;">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
<div style="margin-top: 6px;">
If you must stay on HTTP, set
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
</div>
<div style="margin-top: 6px;">
<a
class="session-link"
href="https://docs.clawd.bot/gateway/tailscale"
target="_blank"
rel="noreferrer"
title="Tailscale Serve docs (opens in new tab)"
>Docs: Tailscale Serve</a
>
<span class="muted"> · </span>
<a
class="session-link"
href="https://docs.clawd.bot/web/control-ui#insecure-http"
target="_blank"
rel="noreferrer"
title="Insecure HTTP docs (opens in new tab)"
>Docs: Insecure HTTP</a
>
</div>
</div>
`;
})();
return html` return html`
<section class="grid grid-cols-2"> <section class="grid grid-cols-2">
@@ -167,6 +205,7 @@ export function renderOverview(props: OverviewProps) {
? html`<div class="callout danger" style="margin-top: 14px;"> ? html`<div class="callout danger" style="margin-top: 14px;">
<div>${props.lastError}</div> <div>${props.lastError}</div>
${authHint ?? ""} ${authHint ?? ""}
${insecureContextHint ?? ""}
</div>` </div>`
: html`<div class="callout" style="margin-top: 14px;"> : html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage. Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.