diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7d15e8f4f..7f90265afde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index 59c8a342d35..1bc8fd90c2c 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -69,5 +69,7 @@ Flags: - `--invoke-timeout `: node invoke timeout (default `30000`). - `--needs-screen-recording`: require screen recording permission. - `--raw `: 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 `: agent-scoped approvals/allowlists (defaults to configured agent). - `--ask `, `--security `: overrides. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 430d07299db..21dd651f554 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -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 ` and `--delivery `. - 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). diff --git a/docs/nodes/troubleshooting.md b/docs/nodes/troubleshooting.md index ce815cdf00e..c8ba10bac49 100644 --- a/docs/nodes/troubleshooting.md +++ b/docs/nodes/troubleshooting.md @@ -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 diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 22c0e472508..a9b1b97c14a 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -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(); diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index 4354f05000c..70cac275a86 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -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 { 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, }; } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index e59d852fc7d..6df1bedefb2 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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); } } diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts new file mode 100644 index 00000000000..424bad83ed6 --- /dev/null +++ b/src/node-host/invoke-system-run.test.ts @@ -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"); + }); +}); diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 92f3b632b62..87df62926ae 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -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; } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1f4cbe9c86a..71210283f4e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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).", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 13b7ab92268..c13b9786a06 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -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).", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index f7e9a99d2e0..df2a9503db8 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -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} (仅限令牌)。", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index a64c7bf0953..b3591f19cf3 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -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} (僅限令牌)。", diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts new file mode 100644 index 00000000000..c7ff78b9e69 --- /dev/null +++ b/ui/src/ui/views/overview-hints.ts @@ -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"); +} diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts new file mode 100644 index 00000000000..b8096661a3a --- /dev/null +++ b/ui/src/ui/views/overview.node.test.ts @@ -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); + }); +}); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 61b82b232ec..3f523fc535a 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -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` +
+ ${t("overview.pairing.hint")} +
+ openclaw devices list
+ openclaw devices approve <requestId> +
+
+ ${t("overview.pairing.mobileHint")} +
+ +
+ `; + })(); + const authHint = (() => { if (props.connected || !props.lastError) { return null; @@ -299,6 +328,7 @@ export function renderOverview(props: OverviewProps) { props.lastError ? html`
${props.lastError}
+ ${pairingHint ?? ""} ${authHint ?? ""} ${insecureContextHint ?? ""}
`