fix(gateway): clarify pairing and node auth guidance

This commit is contained in:
Peter Steinberger
2026-02-22 19:50:29 +01:00
parent 53ed7a0f5c
commit 0c1f491a02
16 changed files with 202 additions and 22 deletions

View File

@@ -82,8 +82,11 @@ Docs: https://docs.openclaw.ai
- Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728.
- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen.
- Gateway/Auth: preserve `OPENCLAW_GATEWAY_PASSWORD` env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation.
- Gateway/Auth: preserve shared-token `gateway token mismatch` auth errors when `auth.token` fallback device-token checks fail, and reserve `device token mismatch` guidance for explicit `auth.deviceToken` failures.
- Gateway/Tools: when agent tools pass an allowlisted `gatewayUrl` override, resolve local override tokens from env/config fallback but keep remote overrides strict to `gateway.remote.token`, preventing local token leakage to remote targets.
- Gateway/Client: keep cached device-auth tokens on `device token mismatch` closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures.
- Node host/Exec: keep strict Windows allowlist behavior for `cmd.exe /c` shell-wrapper runs, and return explicit approval guidance when blocked (`SYSTEM_RUN_DENIED: allowlist miss`).
- Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with `1008 pairing required`.
- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`).
- Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3.
- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -69,5 +69,7 @@ Flags:
- `--invoke-timeout <ms>`: node invoke timeout (default `30000`).
- `--needs-screen-recording`: require screen recording permission.
- `--raw <command>`: run a shell string (`/bin/sh -lc` or `cmd.exe /c`).
In allowlist mode on Windows node hosts, `cmd.exe /c` shell-wrapper runs require approval
(allowlist entry alone does not auto-allow the wrapper form).
- `--agent <id>`: agent-scoped approvals/allowlists (defaults to configured agent).
- `--ask <off|on-miss|always>`, `--security <deny|allowlist|full>`: overrides.

View File

@@ -279,6 +279,7 @@ Notes:
- `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form).
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).

View File

@@ -86,6 +86,8 @@ If pairing is fine but `system.run` fails, fix exec approvals/allowlist.
- `LOCATION_BACKGROUND_UNAVAILABLE` → app is backgrounded but only While Using permission exists.
- `SYSTEM_RUN_DENIED: approval required` → exec request needs explicit approval.
- `SYSTEM_RUN_DENIED: allowlist miss` → command blocked by allowlist mode.
On Windows node hosts, shell-wrapper forms like `cmd.exe /c ...` are treated as allowlist misses in
allowlist mode unless approved via ask flow.
## Fast recovery loop

View File

@@ -993,6 +993,42 @@ describe("gateway server auth/connect", () => {
restoreGatewayToken(prevToken);
});
test("keeps shared token mismatch reason when token fallback device-token check fails", async () => {
const { server, ws, port, prevToken } = await startServerWithClient("secret");
await ensurePairedDeviceTokenForCurrentIdentity(ws);
ws.close();
const ws2 = await openWs(port);
const res2 = await connectReq(ws2, { token: "wrong" });
expect(res2.ok).toBe(false);
expect(res2.error?.message ?? "").toContain("gateway token mismatch");
expect(res2.error?.message ?? "").not.toContain("device token mismatch");
ws2.close();
await server.close();
restoreGatewayToken(prevToken);
});
test("reports device token mismatch when explicit auth.deviceToken is wrong", async () => {
const { server, ws, port, prevToken } = await startServerWithClient("secret");
await ensurePairedDeviceTokenForCurrentIdentity(ws);
ws.close();
const ws2 = await openWs(port);
const res2 = await connectReq(ws2, {
skipDefaultAuth: true,
deviceToken: "not-a-valid-device-token",
});
expect(res2.ok).toBe(false);
expect(res2.error?.message ?? "").toContain("device token mismatch");
ws2.close();
await server.close();
restoreGatewayToken(prevToken);
});
test("keeps shared-secret lockout separate from device-token auth", async () => {
const { server, port, prevToken, deviceToken } =
await startRateLimitedTokenServerWithPairedDeviceToken();

View File

@@ -17,6 +17,8 @@ type HandshakeConnectAuth = {
password?: string;
};
export type DeviceTokenCandidateSource = "explicit-device-token" | "shared-token-fallback";
export type ConnectAuthState = {
authResult: GatewayAuthResult;
authOk: boolean;
@@ -24,6 +26,7 @@ export type ConnectAuthState = {
sharedAuthOk: boolean;
sharedAuthProvided: boolean;
deviceTokenCandidate?: string;
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
};
function trimToUndefined(value: string | undefined): string | undefined {
@@ -45,14 +48,19 @@ function resolveSharedConnectAuth(
return { token, password };
}
function resolveDeviceTokenCandidate(
connectAuth: HandshakeConnectAuth | null | undefined,
): string | undefined {
function resolveDeviceTokenCandidate(connectAuth: HandshakeConnectAuth | null | undefined): {
token?: string;
source?: DeviceTokenCandidateSource;
} {
const explicitDeviceToken = trimToUndefined(connectAuth?.deviceToken);
if (explicitDeviceToken) {
return explicitDeviceToken;
return { token: explicitDeviceToken, source: "explicit-device-token" };
}
return trimToUndefined(connectAuth?.token);
const fallbackToken = trimToUndefined(connectAuth?.token);
if (!fallbackToken) {
return {};
}
return { token: fallbackToken, source: "shared-token-fallback" };
}
export async function resolveConnectAuthState(params: {
@@ -67,9 +75,8 @@ export async function resolveConnectAuthState(params: {
}): Promise<ConnectAuthState> {
const sharedConnectAuth = resolveSharedConnectAuth(params.connectAuth);
const sharedAuthProvided = Boolean(sharedConnectAuth);
const deviceTokenCandidate = params.hasDeviceIdentity
? resolveDeviceTokenCandidate(params.connectAuth)
: undefined;
const { token: deviceTokenCandidate, source: deviceTokenCandidateSource } =
params.hasDeviceIdentity ? resolveDeviceTokenCandidate(params.connectAuth) : {};
const hasDeviceTokenCandidate = Boolean(deviceTokenCandidate);
let authResult: GatewayAuthResult = await authorizeWsControlUiGatewayConnect({
@@ -129,5 +136,6 @@ export async function resolveConnectAuthState(params: {
sharedAuthOk,
sharedAuthProvided,
deviceTokenCandidate,
deviceTokenCandidateSource,
};
}

View File

@@ -355,17 +355,23 @@ export function attachGatewayWsMessageHandler(params: {
});
const device = controlUiAuthPolicy.device;
let { authResult, authOk, authMethod, sharedAuthOk, deviceTokenCandidate } =
await resolveConnectAuthState({
resolvedAuth,
connectAuth: connectParams.auth,
hasDeviceIdentity: Boolean(device),
req: upgradeReq,
trustedProxies,
allowRealIpFallback,
rateLimiter,
clientIp,
});
let {
authResult,
authOk,
authMethod,
sharedAuthOk,
deviceTokenCandidate,
deviceTokenCandidateSource,
} = await resolveConnectAuthState({
resolvedAuth,
connectAuth: connectParams.auth,
hasDeviceIdentity: Boolean(device),
req: upgradeReq,
trustedProxies,
allowRealIpFallback,
rateLimiter,
clientIp,
});
const rejectUnauthorized = (failedAuth: GatewayAuthResult) => {
markHandshakeFailure("unauthorized", {
authMode: resolvedAuth.mode,
@@ -532,7 +538,11 @@ export function attachGatewayWsMessageHandler(params: {
authMethod = "device-token";
rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
} else {
authResult = { ok: false, reason: "device_token_mismatch" };
const mismatchReason =
deviceTokenCandidateSource === "explicit-device-token"
? "device_token_mismatch"
: (authResult.reason ?? "device_token_mismatch");
authResult = { ok: false, reason: mismatchReason };
rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js";
describe("formatSystemRunAllowlistMissMessage", () => {
it("returns legacy allowlist miss message by default", () => {
expect(formatSystemRunAllowlistMissMessage()).toBe("SYSTEM_RUN_DENIED: allowlist miss");
});
it("adds Windows shell-wrapper guidance when blocked by cmd.exe policy", () => {
expect(
formatSystemRunAllowlistMissMessage({
windowsShellWrapperBlocked: true,
}),
).toContain("Windows shell wrappers like cmd.exe /c require approval");
});
});

View File

@@ -33,6 +33,19 @@ type SystemRunInvokeResult = {
error?: { code?: string; message?: string } | null;
};
export function formatSystemRunAllowlistMissMessage(params?: {
windowsShellWrapperBlocked?: boolean;
}): string {
if (params?.windowsShellWrapperBlocked) {
return (
"SYSTEM_RUN_DENIED: allowlist miss " +
"(Windows shell wrappers like cmd.exe /c require approval; " +
"approve once/always or run with --ask on-miss|always)"
);
}
return "SYSTEM_RUN_DENIED: allowlist miss";
}
export async function handleSystemRunInvoke(opts: {
client: GatewayClient;
params: SystemRunParams;
@@ -163,7 +176,8 @@ export async function handleSystemRunInvoke(opts: {
const cmdInvocation = shellCommand
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
: opts.isCmdExeInvocation(argv);
if (security === "allowlist" && isWindows && cmdInvocation) {
const windowsShellWrapperBlocked = security === "allowlist" && isWindows && cmdInvocation;
if (windowsShellWrapperBlocked) {
analysisOk = false;
allowlistSatisfied = false;
}
@@ -317,7 +331,10 @@ export async function handleSystemRunInvoke(opts: {
);
await opts.sendInvokeResult({
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" },
error: {
code: "UNAVAILABLE",
message: formatSystemRunAllowlistMissMessage({ windowsShellWrapperBlocked }),
},
});
return;
}

View File

@@ -97,6 +97,11 @@ export const en: TranslationMap = {
failed:
"Auth failed. Re-copy a tokenized URL with {command}, or update the token, then click Connect.",
},
pairing: {
hint: "This device needs pairing approval from the gateway host.",
mobileHint:
"On mobile? Copy the full URL (including #token=...) from openclaw dashboard --no-open on your desktop.",
},
insecure: {
hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.",
stayHttp: "If you must stay on HTTP, set {config} (token-only).",

View File

@@ -99,6 +99,11 @@ export const pt_BR: TranslationMap = {
failed:
"Falha na autenticação. Recopie uma URL com token usando {command}, ou atualize o token e clique em Conectar.",
},
pairing: {
hint: "Este dispositivo precisa de aprovação de pareamento do host do gateway.",
mobileHint:
"No celular? Copie a URL completa (incluindo #token=...) executando openclaw dashboard --no-open no desktop.",
},
insecure: {
hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.",
stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).",

View File

@@ -96,6 +96,11 @@ export const zh_CN: TranslationMap = {
required: "此网关需要身份验证。添加令牌或密码,然后点击连接。",
failed: "身份验证失败。请使用 {command} 重新复制令牌化 URL或更新令牌然后点击连接。",
},
pairing: {
hint: "此设备需要网关主机的配对批准。",
mobileHint:
"在手机上?从桌面运行 openclaw dashboard --no-open 复制完整 URL包括 #token=...)。",
},
insecure: {
hint: "此页面为 HTTP因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
stayHttp: "如果您必须保持 HTTP请设置 {config} (仅限令牌)。",

View File

@@ -96,6 +96,11 @@ export const zh_TW: TranslationMap = {
required: "此網關需要身份驗證。添加令牌或密碼,然後點擊連接。",
failed: "身份驗證失敗。請使用 {command} 重新複製令牌化 URL或更新令牌然後點擊連接。",
},
pairing: {
hint: "此裝置需要閘道主機的配對批准。",
mobileHint:
"在手機上?從桌面執行 openclaw dashboard --no-open 複製完整 URL包括 #token=...)。",
},
insecure: {
hint: "此頁面為 HTTP因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。",
stayHttp: "如果您必須保持 HTTP請設置 {config} (僅限令牌)。",

View File

@@ -0,0 +1,7 @@
/** Whether the overview should show device-pairing guidance for this error. */
export function shouldShowPairingHint(connected: boolean, lastError: string | null): boolean {
if (connected || !lastError) {
return false;
}
return lastError.toLowerCase().includes("pairing required");
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { shouldShowPairingHint } from "./overview-hints.ts";
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
});
it("matches case-insensitively", () => {
expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true);
});
it("returns false when connected", () => {
expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false);
});
it("returns false when lastError is null", () => {
expect(shouldShowPairingHint(false, null)).toBe(false);
});
it("returns false for unrelated errors", () => {
expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false);
});
it("returns false for auth errors", () => {
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
});
});

View File

@@ -16,6 +16,7 @@ import type {
import { renderOverviewAttention } from "./overview-attention.ts";
import { renderOverviewCards } from "./overview-cards.ts";
import { renderOverviewEventLog } from "./overview-event-log.ts";
import { shouldShowPairingHint } from "./overview-hints.ts";
import { renderOverviewLogTail } from "./overview-log-tail.ts";
export type OverviewProps = {
@@ -64,6 +65,34 @@ export function renderOverview(props: OverviewProps) {
const authMode = snapshot?.authMode;
const isTrustedProxy = authMode === "trusted-proxy";
const pairingHint = (() => {
if (!shouldShowPairingHint(props.connected, props.lastError)) {
return null;
}
return html`
<div class="muted" style="margin-top: 8px">
${t("overview.pairing.hint")}
<div style="margin-top: 6px">
<span class="mono">openclaw devices list</span><br />
<span class="mono">openclaw devices approve &lt;requestId&gt;</span>
</div>
<div style="margin-top: 6px; font-size: 12px;">
${t("overview.pairing.mobileHint")}
</div>
<div style="margin-top: 6px">
<a
class="session-link"
href="https://docs.openclaw.ai/web/control-ui#device-pairing-first-connection"
target="_blank"
rel="noreferrer"
title="Device pairing docs (opens in new tab)"
>Docs: Device pairing</a
>
</div>
</div>
`;
})();
const authHint = (() => {
if (props.connected || !props.lastError) {
return null;
@@ -299,6 +328,7 @@ export function renderOverview(props: OverviewProps) {
props.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
<div>${props.lastError}</div>
${pairingHint ?? ""}
${authHint ?? ""}
${insecureContextHint ?? ""}
</div>`