mirror of
https://github.com/yuga-hashimoto/openclaw-assistant.git
synced 2026-08-14 07:02:02 +00:00
fix: stabilize OpenClaw pairing approval
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user