mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(gateway): clarify pairing and node auth guidance
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
16
src/node-host/invoke-system-run.test.ts
Normal file
16
src/node-host/invoke-system-run.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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} (仅限令牌)。",
|
||||
|
||||
@@ -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} (僅限令牌)。",
|
||||
|
||||
7
ui/src/ui/views/overview-hints.ts
Normal file
7
ui/src/ui/views/overview-hints.ts
Normal 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");
|
||||
}
|
||||
28
ui/src/ui/views/overview.node.test.ts
Normal file
28
ui/src/ui/views/overview.node.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 <requestId></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>`
|
||||
|
||||
Reference in New Issue
Block a user