fix: stabilize OpenClaw pairing approval

This commit is contained in:
Yu-ga
2026-05-21 17:27:32 +09:00
parent 0a4241a2d5
commit 45e2fbd74c
6 changed files with 155 additions and 10 deletions
@@ -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? {
@@ -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) {
@@ -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())
@@ -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<CommandResult> =
@@ -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)
@@ -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(