From 45e2fbd74ca391335e8fe8f07176e8cf01d52783 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Thu, 21 May 2026 17:27:32 +0900 Subject: [PATCH] fix: stabilize OpenClaw pairing approval --- .../com/openclaw/assistant/SecurePrefs.kt | 12 ++- .../ui/components/PairingRequiredCard.kt | 24 +++++ .../ui/setup/HermesImportActivity.kt | 6 +- .../ui/terminal/TerminalCommandClient.kt | 4 +- .../ui/setup/PairingUriParserTest.kt | 24 ++++- .../hermes-mobile-bridge/hermes_pair.py | 95 ++++++++++++++++++- 6 files changed, 155 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/openclaw/assistant/SecurePrefs.kt b/app/src/main/java/com/openclaw/assistant/SecurePrefs.kt index 981a481..bbecfd8 100644 --- a/app/src/main/java/com/openclaw/assistant/SecurePrefs.kt +++ b/app/src/main/java/com/openclaw/assistant/SecurePrefs.kt @@ -199,23 +199,31 @@ class SecurePrefs(context: Context) { fun loadTerminalCommandUrl(): String? { val key = "terminal.command.url.${_instanceId.value}" val stored = prefs.getString(key, null)?.trim() + ?: prefs.getString("terminal.command.url", null)?.trim() return stored?.takeIf { it.isNotEmpty() } } fun saveTerminalCommandUrl(url: String) { val key = "terminal.command.url.${_instanceId.value}" - prefs.edit { putString(key, url.trim()) } + prefs.edit { + putString(key, url.trim()) + putString("terminal.command.url", url.trim()) + } } fun loadTerminalCommandSecret(): String? { val key = "terminal.command.secret.${_instanceId.value}" val stored = prefs.getString(key, null)?.trim() + ?: prefs.getString("terminal.command.secret", null)?.trim() return stored?.takeIf { it.isNotEmpty() } } fun saveTerminalCommandSecret(secret: String) { val key = "terminal.command.secret.${_instanceId.value}" - prefs.edit { putString(key, secret.trim()) } + prefs.edit { + putString(key, secret.trim()) + putString("terminal.command.secret", secret.trim()) + } } fun loadGatewayTlsFingerprint(stableId: String): String? { diff --git a/app/src/main/java/com/openclaw/assistant/ui/components/PairingRequiredCard.kt b/app/src/main/java/com/openclaw/assistant/ui/components/PairingRequiredCard.kt index cf12de0..f5634d7 100644 --- a/app/src/main/java/com/openclaw/assistant/ui/components/PairingRequiredCard.kt +++ b/app/src/main/java/com/openclaw/assistant/ui/components/PairingRequiredCard.kt @@ -49,6 +49,19 @@ fun PairingRequiredCard(deviceId: String, displayName: String = "") { suspend fun approveWithBestRoute(isAuto: Boolean) { runningCommand = true + suspend fun approveThroughGateway(): Boolean { + commandStatus = context.getString( + if (isAuto) R.string.pairing_gateway_auto_running else R.string.pairing_gateway_running, + ) + val gatewayResult = nodeRuntime.approvePendingPairingForDevice(deviceId) + if (gatewayResult.approved) { + commandStatus = context.getString(R.string.pairing_gateway_approve_sent) + nodeRuntime.refreshGatewayConnection() + return true + } + return false + } + if (TerminalCommandClient.isConfigured(context) || HermesTerminalClient.resolveEndpoint(context) != null) { commandStatus = context.getString( if (isAuto) R.string.pairing_terminal_auto_running else R.string.pairing_terminal_running, @@ -64,6 +77,12 @@ fun PairingRequiredCard(deviceId: String, displayName: String = "") { commandStatus = context.getString(R.string.pairing_terminal_approve_sent) nodeRuntime.refreshGatewayConnection() } else { + runningCommand = true + if (approveThroughGateway()) { + runningCommand = false + return + } + runningCommand = false commandStatus = context.getString( if (isAuto) R.string.pairing_terminal_auto_failed else R.string.pairing_terminal_failed_copied, ) @@ -76,6 +95,11 @@ fun PairingRequiredCard(deviceId: String, displayName: String = "") { return } + if (approveThroughGateway()) { + runningCommand = false + return + } + runningCommand = false commandStatus = context.getString(R.string.pairing_host_command_required, approveCommand) if (!isAuto) { diff --git a/app/src/main/java/com/openclaw/assistant/ui/setup/HermesImportActivity.kt b/app/src/main/java/com/openclaw/assistant/ui/setup/HermesImportActivity.kt index 65a86e2..63a1fe3 100644 --- a/app/src/main/java/com/openclaw/assistant/ui/setup/HermesImportActivity.kt +++ b/app/src/main/java/com/openclaw/assistant/ui/setup/HermesImportActivity.kt @@ -714,12 +714,16 @@ internal fun applyPairingPayload( val parsed = GatewayConfigUtils.parseGatewayEndpoint(decoded.url) ?: return@let val runtime = (context.applicationContext as OpenClawApplication).nodeRuntime val settings = SettingsRepository.getInstance(context) + val hasHostApproval = + !payload.terminalCommandUrl.isNullOrBlank() && !payload.terminalCommandSecret.isNullOrBlank() + val bootstrapToken = + if (hasHostApproval && decoded.password != null) "" else decoded.bootstrapToken.orEmpty() runtime.setManualHost(parsed.host) runtime.setManualPort(parsed.port) runtime.setManualTls(parsed.tls) runtime.prefs.saveTerminalCommandUrl(payload.terminalCommandUrl.orEmpty()) runtime.prefs.saveTerminalCommandSecret(payload.terminalCommandSecret.orEmpty()) - runtime.setGatewayBootstrapToken(decoded.bootstrapToken.orEmpty()) + runtime.setGatewayBootstrapToken(bootstrapToken) runtime.setGatewayPassword(decoded.password.orEmpty()) runtime.setGatewayToken("") runtime.prefs.saveGatewayToken(decoded.token.orEmpty()) diff --git a/app/src/main/java/com/openclaw/assistant/ui/terminal/TerminalCommandClient.kt b/app/src/main/java/com/openclaw/assistant/ui/terminal/TerminalCommandClient.kt index ad5454e..4a84965 100644 --- a/app/src/main/java/com/openclaw/assistant/ui/terminal/TerminalCommandClient.kt +++ b/app/src/main/java/com/openclaw/assistant/ui/terminal/TerminalCommandClient.kt @@ -21,7 +21,9 @@ object TerminalCommandClient { fun isConfigured(context: Context): Boolean { val prefs = (context.applicationContext as OpenClawApplication).nodeRuntime.prefs - return !prefs.loadTerminalCommandUrl().isNullOrBlank() && !prefs.loadTerminalCommandSecret().isNullOrBlank() + val hasUrl = !prefs.loadTerminalCommandUrl().isNullOrBlank() + val hasSecret = !prefs.loadTerminalCommandSecret().isNullOrBlank() + return hasUrl && hasSecret } suspend fun run(context: Context, command: String, timeoutSeconds: Int = 30): Result = diff --git a/app/src/test/java/com/openclaw/assistant/ui/setup/PairingUriParserTest.kt b/app/src/test/java/com/openclaw/assistant/ui/setup/PairingUriParserTest.kt index aec3102..e16dce4 100644 --- a/app/src/test/java/com/openclaw/assistant/ui/setup/PairingUriParserTest.kt +++ b/app/src/test/java/com/openclaw/assistant/ui/setup/PairingUriParserTest.kt @@ -68,23 +68,33 @@ class PairingUriParserTest { assertEquals(listOf("https://ok"), p.secondaryUrls) } - @Test fun `combined setup uri supports Hermes urls and OpenClaw code`() { + @Test fun `combined setup uri supports Hermes urls OpenClaw code and approval endpoint`() { val u = uri( scheme = "agentvoice", host = "setup", u = listOf("http://tail:8642", "http://lan:8642", "http://127.0.0.1:8642"), - params = mapOf("hk" to "api-key", "hm" to "default", "hr" to "0", "hs" to "1", "oc" to "abc") + params = mapOf( + "hk" to "api-key", + "hm" to "default", + "hr" to "0", + "hs" to "1", + "oc" to "abc", + "oau" to "https://terminal.example.com/run", + "oas" to "terminal-secret", + ) ) val p = parsePairingUri(u)!! assertEquals("abc", p.openClawSetupCode) + assertEquals("https://terminal.example.com/run", p.terminalCommandUrl) + assertEquals("terminal-secret", p.terminalCommandSecret) val h = p.hermes!! assertEquals("http://tail:8642", h.baseUrl) assertEquals(listOf("http://lan:8642"), h.secondaryUrls) assertEquals("api-key", h.apiKey) } - @Test fun `combined setup json supports Hermes urls and OpenClaw code`() { + @Test fun `combined setup json supports Hermes urls OpenClaw code and approval endpoint`() { val raw = """ { "type": "agent_voice_setup", @@ -100,13 +110,19 @@ class PairingUriParserTest { } }, "openclaw": { - "setupCode": "openclaw-code" + "setupCode": "openclaw-code", + "approval": { + "url": "https://terminal.example.com/run", + "secret": "terminal-secret" + } } } """.trimIndent() val p = parsePairingPayload(raw)!! assertEquals("openclaw-code", p.openClawSetupCode) + assertEquals("https://terminal.example.com/run", p.terminalCommandUrl) + assertEquals("terminal-secret", p.terminalCommandSecret) val h = p.hermes!! assertEquals("http://tail:8642", h.baseUrl) assertEquals(listOf("http://lan:8642"), h.secondaryUrls) diff --git a/integrations/hermes-mobile-bridge/hermes_pair.py b/integrations/hermes-mobile-bridge/hermes_pair.py index 3460a8b..8b696b8 100644 --- a/integrations/hermes-mobile-bridge/hermes_pair.py +++ b/integrations/hermes-mobile-bridge/hermes_pair.py @@ -524,6 +524,7 @@ import os import re import subprocess import sys +import time import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -551,6 +552,82 @@ def resolve_openclaw_latest_approval(command, proc, timeout): return proc return run_command(f"openclaw devices approve {match.group(1)}", timeout) +def approve_latest_openclaw(timeout): + last_error = None + approved_stdout = "" + approved_stderr = "" + attempts = max(1, min(int(timeout * 2), 120)) + for _ in range(attempts): + list_proc = run_command("openclaw devices list --json", timeout) + if list_proc.returncode != 0: + return list_proc + try: + payload = json.loads(list_proc.stdout or "{}") + pending = payload.get("pending") or [] + if not pending: + last_error = "No pending OpenClaw pairing request.\n" + time.sleep(0.5) + continue + operator_pending = [ + item for item in pending + if item.get("role") == "operator" + or "operator" in (item.get("roles") or []) + or any(str(scope).startswith("operator.") for scope in (item.get("scopes") or [])) + ] + if not operator_pending: + selected = max(pending, key=lambda item: int(item.get("ts") or 0)) + request_id = str(selected.get("requestId") or "").strip() + if not request_id: + last_error = "Latest OpenClaw pairing request has no requestId.\n" + time.sleep(0.5) + continue + approve_proc = run_command(f"openclaw devices approve {request_id}", timeout) + if approve_proc.returncode == 0: + approved_stdout += approve_proc.stdout or "" + approved_stderr += approve_proc.stderr or "" + last_error = "Approved a preliminary OpenClaw node request; waiting for operator request.\n" + time.sleep(0.5) + continue + if "unknown requestId" in ((approve_proc.stderr or "") + (approve_proc.stdout or "")): + last_error = approve_proc.stderr or approve_proc.stdout or "OpenClaw pairing request disappeared before approval.\n" + time.sleep(0.4) + continue + return approve_proc + selected = max(operator_pending, key=lambda item: int(item.get("ts") or 0)) + request_id = str(selected.get("requestId") or "").strip() + except Exception as exc: + return subprocess.CompletedProcess( + args="openclaw devices approve --latest", + returncode=1, + stdout=list_proc.stdout, + stderr=f"Could not parse OpenClaw pending device list: {exc}\n", + ) + if not request_id: + return subprocess.CompletedProcess( + args="openclaw devices approve --latest", + returncode=1, + stdout=list_proc.stdout, + stderr="Latest OpenClaw pairing request has no requestId.\n", + ) + approve_proc = run_command(f"openclaw devices approve {request_id}", timeout) + if approve_proc.returncode == 0: + return subprocess.CompletedProcess( + args="openclaw devices approve --latest", + returncode=0, + stdout=approved_stdout + (approve_proc.stdout or ""), + stderr=approved_stderr + (approve_proc.stderr or ""), + ) + if "unknown requestId" not in ((approve_proc.stderr or "") + (approve_proc.stdout or "")): + return approve_proc + last_error = approve_proc.stderr or approve_proc.stdout or "OpenClaw pairing request disappeared before approval.\n" + time.sleep(0.4) + return subprocess.CompletedProcess( + args="openclaw devices approve --latest", + returncode=1, + stdout="", + stderr=last_error or "No pending OpenClaw operator pairing request.\n", + ) + class TerminalHandler(BaseHTTPRequestHandler): server_version = "AgentVoiceTerminal/1" @@ -590,8 +667,11 @@ class TerminalHandler(BaseHTTPRequestHandler): timeout = int(body.get("timeoutSeconds") or 30) timeout = max(1, min(timeout, 120)) try: - proc = run_command(command, timeout) - proc = resolve_openclaw_latest_approval(command, proc, timeout) + if command == "openclaw devices approve --latest": + proc = approve_latest_openclaw(timeout) + else: + proc = run_command(command, timeout) + proc = resolve_openclaw_latest_approval(command, proc, timeout) except subprocess.TimeoutExpired: self._json(504, {"ok": False, "error": "timeout"}) return @@ -995,6 +1075,16 @@ def setup_code_with_url(code: str, url: str) -> str: return encode_setup_code(payload) +def setup_code_for_host_approval(code: Optional[str], approval: Optional[dict]) -> Optional[str]: + if not code or not approval or not approval.get("url") or not approval.get("secret"): + return code + payload = decode_setup_code(code) + if not payload or not payload.get("password"): + return code + payload.pop("bootstrapToken", None) + return encode_setup_code(payload) + + def openclaw_setup_code_from_config() -> Optional[str]: for path in readable_existing(OPENCLAW_JSON_PATHS): cfg = read_json(path) @@ -1721,6 +1811,7 @@ def main(argv: Optional[List[str]] = None) -> int: if not openclaw_setup_code: print("OpenClaw was detected, but no setup code could be resolved. Skipping OpenClaw.", file=sys.stderr) include_openclaw = False + openclaw_setup_code = setup_code_for_host_approval(openclaw_setup_code, terminal_command_pairing) if not include_hermes and not include_openclaw: raise SystemExit(