Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0961d3b82 | ||
|
|
dbd4481bb1 |
No files matched your search
@@ -26,8 +26,6 @@
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: parse systemd ExecStart arguments when whitespace is present. (#995) — thanks @roshanasingh4.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
|
||||
@@ -16,7 +16,6 @@ import com.clawdbot.android.bridge.BridgeDiscovery
|
||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||
import com.clawdbot.android.bridge.BridgePairingClient
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.bridge.BridgeTlsParams
|
||||
import com.clawdbot.android.node.CameraCaptureManager
|
||||
import com.clawdbot.android.node.LocationCaptureManager
|
||||
import com.clawdbot.android.BuildConfig
|
||||
@@ -161,9 +160,6 @@ class NodeRuntime(context: Context) {
|
||||
onInvoke = { req ->
|
||||
handleInvoke(req.command, req.paramsJson)
|
||||
},
|
||||
onTlsFingerprint = { stableId, fingerprint ->
|
||||
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
|
||||
},
|
||||
)
|
||||
|
||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||
@@ -492,17 +488,12 @@ class NodeRuntime(context: Context) {
|
||||
scope.launch {
|
||||
_statusText.value = "Connecting…"
|
||||
val storedToken = prefs.loadBridgeToken()
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
val resolved =
|
||||
if (storedToken.isNullOrBlank()) {
|
||||
_statusText.value = "Pairing…"
|
||||
BridgePairingClient().pairAndHello(
|
||||
endpoint = endpoint,
|
||||
hello = buildPairingHello(token = null),
|
||||
tls = tls,
|
||||
onTlsFingerprint = { fingerprint ->
|
||||
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||
@@ -519,7 +510,6 @@ class NodeRuntime(context: Context) {
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello = buildSessionHello(token = authToken),
|
||||
tls = tls,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -566,41 +556,6 @@ class NodeRuntime(context: Context) {
|
||||
session.disconnect()
|
||||
}
|
||||
|
||||
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
|
||||
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
val manual = endpoint.stableId.startsWith("manual|")
|
||||
|
||||
if (hinted) {
|
||||
return BridgeTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return BridgeTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
return BridgeTlsParams(
|
||||
required = false,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = true,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
scope.launch {
|
||||
val trimmed = payloadJson.trim()
|
||||
|
||||
@@ -147,16 +147,6 @@ class SecurePrefs(context: Context) {
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
fun loadBridgeTlsFingerprint(stableId: String): String? {
|
||||
val key = "bridge.tls.$stableId"
|
||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
|
||||
val key = "bridge.tls.$stableId"
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
|
||||
@@ -143,8 +143,6 @@ class BridgeDiscovery(
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val bridgePort = txtInt(resolved, "bridgePort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val tlsEnabled = txtBool(resolved, "bridgeTls")
|
||||
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
BridgeEndpoint(
|
||||
@@ -157,8 +155,6 @@ class BridgeDiscovery(
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
@@ -213,11 +209,6 @@ class BridgeDiscovery(
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private suspend fun refreshUnicast(domain: String) {
|
||||
val ptrName = "${serviceType}${domain}"
|
||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||
@@ -261,8 +252,6 @@ class BridgeDiscovery(
|
||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
|
||||
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
BridgeEndpoint(
|
||||
@@ -275,8 +264,6 @@ class BridgeDiscovery(
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -487,11 +474,6 @@ class BridgeDiscovery(
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
|
||||
private fun decodeDnsTxtString(raw: String): String {
|
||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||
|
||||
@@ -10,8 +10,6 @@ data class BridgeEndpoint(
|
||||
val gatewayPort: Int? = null,
|
||||
val bridgePort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
val tlsEnabled: Boolean = false,
|
||||
val tlsFingerprintSha256: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||
@@ -20,8 +18,6 @@ data class BridgeEndpoint(
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
tlsFingerprintSha256 = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
+77
-101
@@ -14,6 +14,7 @@ import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
class BridgePairingClient {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -32,120 +33,95 @@ class BridgePairingClient {
|
||||
|
||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||
|
||||
suspend fun pairAndHello(
|
||||
endpoint: BridgeEndpoint,
|
||||
hello: Hello,
|
||||
tls: BridgeTlsParams? = null,
|
||||
onTlsFingerprint: ((String) -> Unit)? = null,
|
||||
): PairResult =
|
||||
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (tls != null) {
|
||||
try {
|
||||
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
||||
} catch (e: Exception) {
|
||||
if (tls.required) throw e
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
try {
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
pairAndHelloWithTls(endpoint, hello, null, null)
|
||||
}
|
||||
|
||||
private fun pairAndHelloWithTls(
|
||||
endpoint: BridgeEndpoint,
|
||||
hello: Hello,
|
||||
tls: BridgeTlsParams?,
|
||||
onTlsFingerprint: ((String) -> Unit)?,
|
||||
): PairResult {
|
||||
val socket =
|
||||
createBridgeSocket(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(fingerprint)
|
||||
}
|
||||
socket.tcpNoDelay = true
|
||||
try {
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 60_000
|
||||
startTlsHandshakeIfNeeded(socket)
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
fun send(line: String) {
|
||||
writer.write(line)
|
||||
writer.write("\n")
|
||||
writer.flush()
|
||||
}
|
||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
when (firstObj["type"].asStringOrNull()) {
|
||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||
"error" -> {
|
||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("pair-request"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("hello"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
return when (firstObj["type"].asStringOrNull()) {
|
||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||
"error" -> {
|
||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||
return PairResult(ok = false, token = null, error = "$code: $message")
|
||||
}
|
||||
|
||||
sendJson(
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("pair-request"))
|
||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||
},
|
||||
)
|
||||
|
||||
while (true) {
|
||||
val nextLine = reader.readLine() ?: break
|
||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||
when (next["type"].asStringOrNull()) {
|
||||
"pair-ok" -> {
|
||||
val token = next["token"].asStringOrNull()
|
||||
return PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||
}
|
||||
"error" -> {
|
||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||
return PairResult(ok = false, token = null, error = "$c: $m")
|
||||
while (true) {
|
||||
val nextLine = reader.readLine() ?: break
|
||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||
when (next["type"].asStringOrNull()) {
|
||||
"pair-ok" -> {
|
||||
val token = next["token"].asStringOrNull()
|
||||
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||
}
|
||||
"error" -> {
|
||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
PairResult(ok = false, token = null, error = "pairing failed")
|
||||
}
|
||||
PairResult(ok = false, token = null, error = "pairing failed")
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
return PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
@@ -35,7 +35,6 @@ class BridgeSession(
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||
) {
|
||||
data class Hello(
|
||||
val nodeId: String,
|
||||
@@ -67,17 +66,11 @@ class BridgeSession(
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
@Volatile private var mainSessionKey: String? = null
|
||||
|
||||
private data class DesiredConnection(
|
||||
val endpoint: BridgeEndpoint,
|
||||
val hello: Hello,
|
||||
val tls: BridgeTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||
private var job: Job? = null
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
||||
desired = DesiredConnection(endpoint, hello, tls)
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||
desired = endpoint to hello
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
@@ -85,7 +78,7 @@ class BridgeSession(
|
||||
|
||||
suspend fun updateHello(hello: Hello) {
|
||||
val target = desired ?: return
|
||||
desired = target.copy(hello = hello)
|
||||
desired = target.first to hello
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
}
|
||||
@@ -172,10 +165,10 @@ class BridgeSession(
|
||||
continue
|
||||
}
|
||||
|
||||
val (endpoint, hello, tls) = target
|
||||
val (endpoint, hello) = target
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(endpoint, hello, tls)
|
||||
connectOnce(endpoint, hello)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
@@ -199,66 +192,50 @@ class BridgeSession(
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (tls != null) {
|
||||
try {
|
||||
connectWithSocket(endpoint, hello, tls)
|
||||
return@withContext
|
||||
} catch (err: Throwable) {
|
||||
if (tls.required) throw err
|
||||
}
|
||||
}
|
||||
connectWithSocket(endpoint, hello, null)
|
||||
}
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
|
||||
private fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
||||
val socket =
|
||||
createBridgeSocket(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||
}
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
startTlsHandshakeIfNeeded(socket)
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||
val conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
|
||||
val conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
try {
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
|
||||
try {
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
?: throw IllegalStateException("unexpected bridge response")
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
mainSessionKey = rawMainSessionKey
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||
?: throw IllegalStateException("unexpected bridge response")
|
||||
when (first["type"].asStringOrNull()) {
|
||||
"hello-ok" -> {
|
||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
mainSessionKey = rawMainSessionKey
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
||||
}
|
||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||
throw IllegalStateException("$code: $msg")
|
||||
}
|
||||
else -> throw IllegalStateException("unexpected bridge response")
|
||||
}
|
||||
"error" -> {
|
||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||
throw IllegalStateException("$code: $msg")
|
||||
}
|
||||
else -> throw IllegalStateException("unexpected bridge response")
|
||||
}
|
||||
|
||||
while (scope.isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
import java.net.Socket
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
data class BridgeTlsParams(
|
||||
val required: Boolean,
|
||||
val expectedFingerprint: String?,
|
||||
val allowTOFU: Boolean,
|
||||
val stableId: String,
|
||||
)
|
||||
|
||||
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
|
||||
if (params == null) return Socket()
|
||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||
val defaultTrust = defaultTrustManager()
|
||||
val trustManager =
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
defaultTrust.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
||||
val fingerprint = sha256Hex(chain[0].encoded)
|
||||
if (expected != null) {
|
||||
if (fingerprint != expected) {
|
||||
throw CertificateException("bridge TLS fingerprint mismatch")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (params.allowTOFU) {
|
||||
onStore?.invoke(fingerprint)
|
||||
return
|
||||
}
|
||||
defaultTrust.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||
return context.socketFactory.createSocket()
|
||||
}
|
||||
|
||||
fun startTlsHandshakeIfNeeded(socket: Socket) {
|
||||
if (socket is SSLSocket) {
|
||||
socket.startHandshake()
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as java.security.KeyStore?)
|
||||
val trust =
|
||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
@@ -10,36 +10,10 @@ actor BridgeClient {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams? = nil,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
do {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onStatus: onStatus)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onStatus: onStatus)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func pairAndHelloOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
self.lineBuffer = Data()
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||
@@ -168,18 +142,6 @@ actor BridgeClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private struct TimeoutError: LocalizedError, Sendable {
|
||||
var purpose: String
|
||||
var seconds: Int
|
||||
|
||||
@@ -10,7 +10,6 @@ protocol BridgePairingClient: Sendable {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
}
|
||||
|
||||
@@ -116,12 +115,9 @@ final class BridgeConnectionController {
|
||||
|
||||
self.didAutoConnect = true
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
||||
self.startAutoConnect(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
return
|
||||
@@ -139,12 +135,10 @@ final class BridgeConnectionController {
|
||||
|
||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
endpoint: target.endpoint,
|
||||
bridgeStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
@@ -188,7 +182,6 @@ final class BridgeConnectionController {
|
||||
private func startAutoConnect(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
token: String,
|
||||
instanceId: String)
|
||||
{
|
||||
@@ -200,7 +193,6 @@ final class BridgeConnectionController {
|
||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
appModel.bridgeStatusText = status
|
||||
@@ -216,7 +208,6 @@ final class BridgeConnectionController {
|
||||
appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: bridgeStableID,
|
||||
tls: tls,
|
||||
hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
@@ -226,47 +217,6 @@ final class BridgeConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
||||
{
|
||||
let stableID = bridge.stableID
|
||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return BridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
let key = "node.displayName"
|
||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
@@ -23,8 +23,6 @@ final class BridgeDiscoveryModel {
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var canvasPort: Int?
|
||||
var tlsEnabled: Bool
|
||||
var tlsFingerprintSha256: String?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
@@ -92,8 +90,6 @@ final class BridgeDiscoveryModel {
|
||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
|
||||
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
|
||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||
default:
|
||||
return nil
|
||||
@@ -218,9 +214,4 @@ final class BridgeDiscoveryModel {
|
||||
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||
return Int(raw)
|
||||
}
|
||||
|
||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
}
|
||||
@@ -69,42 +69,15 @@ actor BridgeSession {
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams? = nil,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
{
|
||||
await self.disconnect()
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func connectOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)?,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||
{
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||
self.connection = connection
|
||||
@@ -282,18 +255,6 @@ actor BridgeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private func timeoutRPC(id: String) async {
|
||||
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
struct BridgeTLSParams: Sendable {
|
||||
let required: Bool
|
||||
let expectedFingerprint: String?
|
||||
let allowTOFU: Bool
|
||||
let storeKey: String?
|
||||
}
|
||||
|
||||
enum BridgeTLSStore {
|
||||
private static let service = "com.clawdbot.bridge.tls"
|
||||
|
||||
static func loadFingerprint(stableID: String) -> String? {
|
||||
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func saveFingerprint(_ value: String, stableID: String) {
|
||||
_ = KeychainStore.saveString(value, service: service, account: stableID)
|
||||
}
|
||||
}
|
||||
|
||||
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
|
||||
guard let params else { return nil }
|
||||
let options = NWProtocolTLS.Options()
|
||||
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
|
||||
let allowTOFU = params.allowTOFU
|
||||
let storeKey = params.storeKey
|
||||
|
||||
sec_protocol_options_set_verify_block(
|
||||
options.securityProtocolOptions,
|
||||
{ _, trust, complete in
|
||||
guard let trust else {
|
||||
complete(false)
|
||||
return
|
||||
}
|
||||
if let cert = SecTrustGetCertificateAtIndex(trust, 0) {
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
let fingerprint = sha256Hex(data)
|
||||
if let expected {
|
||||
complete(fingerprint == expected)
|
||||
return
|
||||
}
|
||||
if allowTOFU {
|
||||
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
||||
complete(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
||||
complete(ok)
|
||||
},
|
||||
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func normalizeBridgeFingerprint(_ raw: String) -> String {
|
||||
raw.lowercased().filter { $0.isHexDigit }
|
||||
}
|
||||
@@ -205,7 +205,6 @@ final class NodeAppModel {
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
hello: BridgeHello)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
@@ -233,7 +232,6 @@ final class NodeAppModel {
|
||||
try await self.bridge.connect(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
|
||||
@@ -407,11 +407,9 @@ struct SettingsTab: View {
|
||||
modelIdentifier: self.modelIdentifier(),
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands())
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: bridge.endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
@@ -428,7 +426,6 @@ struct SettingsTab: View {
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
bridgeStableID: bridge.stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -465,8 +462,6 @@ struct SettingsTab: View {
|
||||
defer { self.connectingBridgeID = nil }
|
||||
|
||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
||||
|
||||
do {
|
||||
let statusStore = self.connectStatus
|
||||
@@ -490,7 +485,6 @@ struct SettingsTab: View {
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
@@ -506,8 +500,7 @@ struct SettingsTab: View {
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -524,47 +517,6 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
||||
{
|
||||
let stableID = bridge.stableID
|
||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return BridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return BridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
|
||||
@@ -27,7 +27,6 @@ private actor MockBridgePairingClient: BridgePairingClient {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
{
|
||||
self.lastToken = hello.token
|
||||
@@ -245,8 +244,6 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||
let account = "bridge-token.ios-test"
|
||||
@@ -295,8 +292,6 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||
name: "Gateway B",
|
||||
@@ -308,8 +303,6 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 28789,
|
||||
bridgePort: 28790,
|
||||
canvasPort: 28793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
|
||||
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||
|
||||
@@ -2,16 +2,77 @@ import Foundation
|
||||
|
||||
enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private enum GatewayProgramArgumentsError: LocalizedError {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .message(message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(
|
||||
port: Int,
|
||||
bind: String) -> Result<[String], GatewayProgramArgumentsError>
|
||||
{
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
#if DEBUG
|
||||
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
|
||||
switch CommandResolver.runtimeResolution() {
|
||||
case let .success(runtime):
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
let searchPaths = CommandResolver.preferredPaths()
|
||||
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
|
||||
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
|
||||
{
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
}
|
||||
|
||||
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||
return loaded
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return result.status == 0
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
@@ -20,44 +81,255 @@ enum GatewayLaunchAgentManager {
|
||||
self.logger.info("launchd enable skipped (disable marker set)")
|
||||
return nil
|
||||
}
|
||||
|
||||
if enabled {
|
||||
self.logger.info("launchd enable requested via CLI port=\(port)")
|
||||
return await self.runDaemonCommand([
|
||||
"install",
|
||||
"--force",
|
||||
"--port",
|
||||
"\(port)",
|
||||
"--runtime",
|
||||
"node",
|
||||
])
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
|
||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||
let desiredToken = self.preferredGatewayToken()
|
||||
let desiredPassword = self.preferredGatewayPassword()
|
||||
let desiredConfig = DesiredConfig(
|
||||
port: port,
|
||||
bind: desiredBind,
|
||||
token: desiredToken,
|
||||
password: desiredPassword)
|
||||
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
|
||||
guard case let .success(programArguments) = programArgumentsResult else {
|
||||
if case let .failure(error) = programArgumentsResult {
|
||||
let message = error.localizedDescription
|
||||
self.logger.error("launchd enable failed: \(message)")
|
||||
return message
|
||||
}
|
||||
return "Failed to resolve gateway command."
|
||||
}
|
||||
|
||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||
let loaded = await self.isLoaded()
|
||||
if loaded {
|
||||
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
|
||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||
await self.ensureEnabled()
|
||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||
self.writePlist(programArguments: programArguments)
|
||||
|
||||
await self.ensureEnabled()
|
||||
if loaded {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
if bootstrap.status != 0 {
|
||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Failed to bootstrap gateway launchd job"
|
||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
await self.ensureEnabled()
|
||||
return nil
|
||||
}
|
||||
|
||||
self.logger.info("launchd disable requested via CLI")
|
||||
return await self.runDaemonCommand(["uninstall"])
|
||||
self.logger.info("launchd disable requested")
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
await self.ensureDisabled()
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
static func kickstart() async {
|
||||
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
||||
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
|
||||
private static func writePlist(programArguments: [String]) {
|
||||
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let token = self.preferredGatewayToken()
|
||||
let password = self.preferredGatewayPassword()
|
||||
var envEntries = """
|
||||
<key>PATH</key>
|
||||
<string>\(preferredPath)</string>
|
||||
"""
|
||||
if let token {
|
||||
let escapedToken = self.escapePlistValue(token)
|
||||
envEntries += """
|
||||
<key>CLAWDBOT_GATEWAY_TOKEN</key>
|
||||
<string>\(escapedToken)</string>
|
||||
"""
|
||||
}
|
||||
if let password {
|
||||
let escapedPassword = self.escapePlistValue(password)
|
||||
envEntries += """
|
||||
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
|
||||
<string>\(escapedPassword)</string>
|
||||
"""
|
||||
}
|
||||
let argsXml = programArguments
|
||||
.map { "<string>\(self.escapePlistValue($0))</string>" }
|
||||
.joined(separator: "\n ")
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>\(gatewayLaunchdLabel)</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
\(argsXml)
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
\(envEntries)
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
do {
|
||||
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func preferredGatewayBind() -> String? {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return nil
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
||||
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let bind = gateway["bind"] as? String
|
||||
{
|
||||
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func preferredGatewayToken() -> String? {
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func preferredGatewayPassword() -> String? {
|
||||
// First check environment variable
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
// Then check config file (gateway.auth.password)
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func escapePlistValue(_ raw: String) -> String {
|
||||
raw
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private struct DesiredConfig: Equatable {
|
||||
let port: Int
|
||||
let bind: String
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private struct InstalledConfig: Equatable {
|
||||
let port: Int?
|
||||
let bind: String?
|
||||
let token: String?
|
||||
let password: String?
|
||||
|
||||
func matches(_ desired: DesiredConfig) -> Bool {
|
||||
guard self.port == desired.port else { return false }
|
||||
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||
guard self.token == desired.token else { return false }
|
||||
guard self.password == desired.password else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func readPlistConfig() -> InstalledConfig? {
|
||||
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||
return InstalledConfig(
|
||||
port: snapshot.port,
|
||||
bind: snapshot.bind,
|
||||
token: snapshot.token,
|
||||
password: snapshot.password)
|
||||
}
|
||||
|
||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||
LaunchAgentPlist.snapshot(url: self.plistURL)
|
||||
}
|
||||
|
||||
static func launchdGatewayLogPath() -> String {
|
||||
let snapshot = self.launchdConfigSnapshot()
|
||||
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!stdout.isEmpty
|
||||
{
|
||||
return stdout
|
||||
private static func ensureEnabled() async {
|
||||
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if msg.isEmpty {
|
||||
self.logger.warning("launchd enable failed")
|
||||
} else {
|
||||
self.logger.warning("launchd enable failed: \(msg)")
|
||||
}
|
||||
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!stderr.isEmpty
|
||||
{
|
||||
return stderr
|
||||
}
|
||||
|
||||
private static func ensureDisabled() async {
|
||||
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
guard result.status != 0 else { return }
|
||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if msg.isEmpty {
|
||||
self.logger.warning("launchd disable failed")
|
||||
} else {
|
||||
self.logger.warning("launchd disable failed: \(msg)")
|
||||
}
|
||||
return LogLocator.launchdGatewayLogPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,99 +339,20 @@ extension GatewayLaunchAgentManager {
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
return FileManager.default.fileExists(atPath: marker.path)
|
||||
}
|
||||
}
|
||||
|
||||
private static func readDaemonLoaded() async -> Bool? {
|
||||
let result = await self.runDaemonCommand(["status", "--json", "--no-probe"], timeout: 15, quiet: true)
|
||||
guard result.success, let payload = result.payload else { return nil }
|
||||
guard
|
||||
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||
let service = json["service"] as? [String: Any],
|
||||
let loaded = service["loaded"] as? Bool
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return loaded
|
||||
#if DEBUG
|
||||
extension GatewayLaunchAgentManager {
|
||||
static func _testPreferredGatewayBind() -> String? {
|
||||
self.preferredGatewayBind()
|
||||
}
|
||||
|
||||
private struct CommandResult {
|
||||
let success: Bool
|
||||
let payload: Data?
|
||||
let message: String?
|
||||
static func _testPreferredGatewayToken() -> String? {
|
||||
self.preferredGatewayToken()
|
||||
}
|
||||
|
||||
private struct ParsedDaemonJson {
|
||||
let text: String
|
||||
let object: [String: Any]
|
||||
}
|
||||
|
||||
private static func runDaemonCommand(
|
||||
_ args: [String],
|
||||
timeout: Double = 15,
|
||||
quiet: Bool = false) async -> String?
|
||||
{
|
||||
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
|
||||
if result.success { return nil }
|
||||
return result.message ?? "Gateway daemon command failed"
|
||||
}
|
||||
|
||||
private static func runDaemonCommandResult(
|
||||
_ args: [String],
|
||||
timeout: Double,
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
extraArgs: self.withJsonFlag(args))
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
|
||||
let ok = parsed?.object["ok"] as? Bool
|
||||
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
|
||||
let payload = parsed?.text.data(using: .utf8)
|
||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||
let success = ok ?? response.success
|
||||
if success {
|
||||
return CommandResult(success: true, payload: payload, message: nil)
|
||||
}
|
||||
|
||||
if quiet {
|
||||
return CommandResult(success: false, payload: payload, message: message)
|
||||
}
|
||||
|
||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
|
||||
?? "Gateway daemon command failed (\(exit))"
|
||||
self.logger.error("\(fullMessage, privacy: .public)")
|
||||
return CommandResult(success: false, payload: payload, message: detail)
|
||||
}
|
||||
|
||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||
if args.contains("--json") { return args }
|
||||
return args + ["--json"]
|
||||
}
|
||||
|
||||
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
return ParsedDaemonJson(text: jsonText, object: object)
|
||||
}
|
||||
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
static func _testEscapePlistValue(_ raw: String) -> String {
|
||||
self.escapePlistValue(raw)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -140,7 +140,7 @@ final class GatewayProcessManager {
|
||||
|
||||
func refreshLog() {
|
||||
guard self.logRefreshTask == nil else { return }
|
||||
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
|
||||
let path = LogLocator.launchdGatewayLogPath
|
||||
let limit = self.logLimit
|
||||
self.logRefreshTask = Task { [weak self] in
|
||||
let log = await Task.detached(priority: .utility) {
|
||||
@@ -354,7 +354,7 @@ final class GatewayProcessManager {
|
||||
|
||||
func clearLog() {
|
||||
self.log = ""
|
||||
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||
self.logger.debug("gateway log cleared")
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,6 @@ enum Launchctl {
|
||||
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
|
||||
let programArguments: [String]
|
||||
let environment: [String: String]
|
||||
let stdoutPath: String?
|
||||
let stderrPath: String?
|
||||
|
||||
let port: Int?
|
||||
let bind: String?
|
||||
@@ -55,10 +53,6 @@ enum LaunchAgentPlist {
|
||||
guard let root = rootAny as? [String: Any] else { return nil }
|
||||
let programArguments = root["ProgramArguments"] as? [String] ?? []
|
||||
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
|
||||
let stdoutPath = (root["StandardOutPath"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let stderrPath = (root["StandardErrorPath"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let port = Self.extractFlagInt(programArguments, flag: "--port")
|
||||
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
|
||||
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
@@ -66,8 +60,6 @@ enum LaunchAgentPlist {
|
||||
return LaunchAgentPlistSnapshot(
|
||||
programArguments: programArguments,
|
||||
environment: env,
|
||||
stdoutPath: stdoutPath,
|
||||
stderrPath: stderrPath,
|
||||
port: port,
|
||||
bind: bind,
|
||||
token: token,
|
||||
|
||||
@@ -32,7 +32,6 @@ enum MacNodeConfigFile {
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -11,39 +11,10 @@ actor MacNodeBridgePairingClient {
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
silent: Bool,
|
||||
tls: MacNodeBridgeTLSParams? = nil,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
do {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
silent: silent,
|
||||
tls: tls,
|
||||
onStatus: onStatus)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
silent: silent,
|
||||
tls: nil,
|
||||
onStatus: onStatus)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func pairAndHelloOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
silent: Bool,
|
||||
tls: MacNodeBridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
self.lineBuffer = Data()
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await AsyncTimeout.withTimeout(
|
||||
@@ -193,18 +164,6 @@ actor MacNodeBridgePairingClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private func startAndWaitForReady(
|
||||
_ connection: NWConnection,
|
||||
queue: DispatchQueue) async throws
|
||||
|
||||
@@ -36,7 +36,6 @@ actor MacNodeBridgeSession {
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: MacNodeBridgeTLSParams? = nil,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
@@ -45,35 +44,15 @@ actor MacNodeBridgeSession {
|
||||
await self.disconnect()
|
||||
self.disconnectHandler = onDisconnected
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func connectOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: MacNodeBridgeTLSParams?,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||
{
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
tcpOptions.enableKeepalive = true
|
||||
tcpOptions.keepaliveIdle = 30
|
||||
tcpOptions.keepaliveInterval = 15
|
||||
tcpOptions.keepaliveCount = 3
|
||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
||||
self.connection = connection
|
||||
@@ -283,25 +262,6 @@ actor MacNodeBridgeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
tcpOptions.enableKeepalive = true
|
||||
tcpOptions.keepaliveIdle = 30
|
||||
tcpOptions.keepaliveInterval = 15
|
||||
tcpOptions.keepaliveCount = 3
|
||||
|
||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
||||
return params
|
||||
}
|
||||
|
||||
private func failRPC(id: String, error: Error) async {
|
||||
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
||||
cont.resume(throwing: error)
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
struct MacNodeBridgeTLSParams: Sendable {
|
||||
let required: Bool
|
||||
let expectedFingerprint: String?
|
||||
let allowTOFU: Bool
|
||||
let storeKey: String?
|
||||
}
|
||||
|
||||
enum MacNodeBridgeTLSStore {
|
||||
private static let suiteName = "com.clawdbot.shared"
|
||||
private static let keyPrefix = "mac.node.bridge.tls."
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
static func loadFingerprint(stableID: String) -> String? {
|
||||
let key = keyPrefix + stableID
|
||||
let raw = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw?.isEmpty == false ? raw : nil
|
||||
}
|
||||
|
||||
static func saveFingerprint(_ value: String, stableID: String) {
|
||||
let key = keyPrefix + stableID
|
||||
defaults.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.Options? {
|
||||
guard let params else { return nil }
|
||||
let options = NWProtocolTLS.Options()
|
||||
let expected = params.expectedFingerprint.map(normalizeMacNodeFingerprint)
|
||||
let allowTOFU = params.allowTOFU
|
||||
let storeKey = params.storeKey
|
||||
|
||||
sec_protocol_options_set_verify_block(
|
||||
options.securityProtocolOptions,
|
||||
{ _, trust, complete in
|
||||
guard let trust else {
|
||||
complete(false)
|
||||
return
|
||||
}
|
||||
if let cert = SecTrustGetCertificateAtIndex(trust, 0) {
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
let fingerprint = sha256Hex(data)
|
||||
if let expected {
|
||||
complete(fingerprint == expected)
|
||||
return
|
||||
}
|
||||
if allowTOFU {
|
||||
if let storeKey { MacNodeBridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
||||
complete(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
||||
complete(ok)
|
||||
},
|
||||
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
|
||||
raw.lowercased().filter { $0.isHexDigit }
|
||||
}
|
||||
@@ -4,12 +4,6 @@ import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
private struct BridgeTarget {
|
||||
let endpoint: NWEndpoint
|
||||
let stableID: String
|
||||
let tls: MacNodeBridgeTLSParams?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MacNodeModeCoordinator {
|
||||
static let shared = MacNodeModeCoordinator()
|
||||
@@ -69,7 +63,7 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
continue
|
||||
@@ -79,11 +73,10 @@ final class MacNodeModeCoordinator {
|
||||
do {
|
||||
let hello = await self.makeHello()
|
||||
self.logger.info(
|
||||
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
|
||||
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
|
||||
try await self.session.connect(
|
||||
endpoint: target.endpoint,
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: target.tls,
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||
if let mainSessionKey {
|
||||
@@ -103,7 +96,7 @@ final class MacNodeModeCoordinator {
|
||||
return await self.runtime.handleInvoke(req)
|
||||
})
|
||||
} catch {
|
||||
if await self.tryPair(target: target, error: error) {
|
||||
if await self.tryPair(endpoint: endpoint, error: error) {
|
||||
continue
|
||||
}
|
||||
self.logger.error(
|
||||
@@ -180,7 +173,7 @@ final class MacNodeModeCoordinator {
|
||||
return commands
|
||||
}
|
||||
|
||||
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
|
||||
private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
|
||||
let text = error.localizedDescription.uppercased()
|
||||
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
||||
|
||||
@@ -190,10 +183,9 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
let hello = await self.makeHello()
|
||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||
endpoint: target.endpoint,
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
silent: shouldSilent,
|
||||
tls: target.tls,
|
||||
onStatus: { [weak self] status in
|
||||
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||
})
|
||||
@@ -211,7 +203,7 @@ final class MacNodeModeCoordinator {
|
||||
"mac-\(InstanceIdentity.instanceId)"
|
||||
}
|
||||
|
||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
guard let port = Self.loopbackBridgePort(),
|
||||
let endpointPort = NWEndpoint.Port(rawValue: port)
|
||||
else {
|
||||
@@ -219,10 +211,7 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
|
||||
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
|
||||
guard reachable else { return nil }
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
return reachable ? endpoint : nil
|
||||
}
|
||||
|
||||
static func loopbackBridgePort() -> UInt16? {
|
||||
@@ -315,7 +304,7 @@ final class MacNodeModeCoordinator {
|
||||
})
|
||||
}
|
||||
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
||||
if mode == .remote {
|
||||
do {
|
||||
@@ -327,10 +316,7 @@ final class MacNodeModeCoordinator {
|
||||
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
||||
self.logger.info(
|
||||
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
}
|
||||
self.logger.error(
|
||||
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
||||
@@ -363,10 +349,7 @@ final class MacNodeModeCoordinator {
|
||||
"mac node bridge tunnel ready " +
|
||||
"localPort=\(localPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
}
|
||||
} catch {
|
||||
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -377,8 +360,8 @@ final class MacNodeModeCoordinator {
|
||||
tunnel.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||
return target
|
||||
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||
return endpoint
|
||||
}
|
||||
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
@@ -398,14 +381,14 @@ final class MacNodeModeCoordinator {
|
||||
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
final class DiscoveryState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var resolved = false
|
||||
var browsers: [NWBrowser] = []
|
||||
var continuation: CheckedContinuation<BridgeTarget?, Never>?
|
||||
var continuation: CheckedContinuation<NWEndpoint?, Never>?
|
||||
|
||||
func finish(_ target: BridgeTarget?) {
|
||||
func finish(_ endpoint: NWEndpoint?) {
|
||||
self.lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if self.resolved { return }
|
||||
@@ -413,7 +396,7 @@ final class MacNodeModeCoordinator {
|
||||
for browser in self.browsers {
|
||||
browser.cancel()
|
||||
}
|
||||
self.continuation?.resume(returning: target)
|
||||
self.continuation?.resume(returning: endpoint)
|
||||
self.continuation = nil
|
||||
}
|
||||
}
|
||||
@@ -439,12 +422,12 @@ final class MacNodeModeCoordinator {
|
||||
return false
|
||||
})
|
||||
{
|
||||
state.finish(Self.targetFromResult(match))
|
||||
state.finish(match.endpoint)
|
||||
return
|
||||
}
|
||||
|
||||
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
||||
state.finish(Self.targetFromResult(result))
|
||||
state.finish(result.endpoint)
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { browserState in
|
||||
@@ -462,72 +445,6 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
||||
let endpoint = result.endpoint
|
||||
guard case .service = endpoint else { return nil }
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
||||
let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls")
|
||||
let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256")
|
||||
let tlsParams = Self.resolveDiscoveredTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: tlsEnabled,
|
||||
tlsFingerprintSha256: tlsFingerprint)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
|
||||
private static func resolveDiscoveredTLSParams(
|
||||
stableID: String,
|
||||
tlsEnabled: Bool,
|
||||
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
||||
{
|
||||
let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if tlsEnabled || tlsFingerprintSha256 != nil {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
||||
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return raw.isEmpty ? nil : raw
|
||||
}
|
||||
|
||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
enum MacNodeTokenStore {
|
||||
|
||||
@@ -455,7 +455,14 @@ actor MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
var env = params.env
|
||||
if wasAllowlisted, let overrides = env {
|
||||
var merged = ProcessInfo.processInfo.environment
|
||||
for (key, value) in overrides where key != "PATH" {
|
||||
merged[key] = value
|
||||
}
|
||||
env = merged
|
||||
}
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
@@ -564,35 +571,6 @@ actor MacNodeRuntime {
|
||||
SystemRunPolicy.load()
|
||||
}
|
||||
|
||||
private static let blockedEnvKeys: Set<String> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYOPT",
|
||||
]
|
||||
|
||||
private static let blockedEnvPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
]
|
||||
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
||||
guard let overrides else { return nil }
|
||||
var merged = ProcessInfo.processInfo.environment
|
||||
for (rawKey, value) in overrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
if blockedEnvKeys.contains(upper) { continue }
|
||||
if blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private nonisolated static func locationMode() -> ClawdbotLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
return ClawdbotLocationMode(rawValue: raw) ?? .off
|
||||
|
||||
@@ -7,8 +7,6 @@ import Testing
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||
stdoutPath: nil,
|
||||
stderrPath: nil,
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: "launchd-token",
|
||||
@@ -33,8 +31,6 @@ import Testing
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
|
||||
stdoutPath: nil,
|
||||
stderrPath: nil,
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: "launchd-token",
|
||||
@@ -52,8 +48,6 @@ import Testing
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
|
||||
stdoutPath: nil,
|
||||
stderrPath: nil,
|
||||
port: nil,
|
||||
bind: nil,
|
||||
token: nil,
|
||||
|
||||
@@ -112,6 +112,20 @@ struct LowCoverageHelperTests {
|
||||
_ = PresenceReporter._testPrimaryIPv4Address()
|
||||
}
|
||||
|
||||
@Test func gatewayLaunchAgentHelpers() async throws {
|
||||
await TestIsolation.withEnvValues(
|
||||
[
|
||||
"CLAWDBOT_GATEWAY_BIND": "Lan",
|
||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||
]) {
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
|
||||
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
|
||||
#expect(
|
||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||
"a&b<c>"'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func portGuardianParsesListenersAndBuildsReports() {
|
||||
let output = """
|
||||
p123
|
||||
|
||||
@@ -15,6 +15,3 @@ Related:
|
||||
|
||||
Tip: run `clawdbot daemon --help` for platform-specific flags.
|
||||
|
||||
Notes:
|
||||
- `daemon status` supports `--json` for scripting.
|
||||
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
||||
+1
-2
@@ -594,9 +594,8 @@ Notes:
|
||||
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
||||
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
|
||||
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
|
||||
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
|
||||
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
|
||||
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
|
||||
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
|
||||
|
||||
### `logs`
|
||||
Tail Gateway file logs via RPC.
|
||||
|
||||
@@ -28,12 +28,8 @@ If you are building an operator client (CLI, web UI, automations), use the
|
||||
## Transport
|
||||
|
||||
- TCP, one JSON object per line (JSONL).
|
||||
- Optional TLS (when `bridge.tls.enabled` is true).
|
||||
- Gateway owns the listener (default `18790`).
|
||||
|
||||
When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
||||
`bridgeTlsSha256` so nodes can pin the certificate.
|
||||
|
||||
## Handshake + pairing
|
||||
|
||||
1) Client sends `hello` with node metadata + token (if already paired).
|
||||
|
||||
@@ -2396,7 +2396,7 @@ Defaults:
|
||||
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
|
||||
- profile color: `#FF4500` (lobster-orange)
|
||||
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
|
||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
- Auto-detect order: Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -2732,29 +2732,12 @@ Bind modes:
|
||||
- `loopback`: `127.0.0.1` (local only)
|
||||
- `auto`: prefer tailnet IP if present, else `lan`
|
||||
|
||||
TLS:
|
||||
- `bridge.tls.enabled`: enable TLS for bridge connections (TLS-only when enabled).
|
||||
- `bridge.tls.autoGenerate`: generate a self-signed cert when no cert/key are present (default: true).
|
||||
- `bridge.tls.certPath` / `bridge.tls.keyPath`: PEM paths for the bridge certificate + private key.
|
||||
- `bridge.tls.caPath`: optional PEM CA bundle (custom roots or future mTLS).
|
||||
|
||||
When TLS is enabled, the Gateway advertises `bridgeTls=1` and `bridgeTlsSha256` in discovery TXT
|
||||
records so nodes can pin the certificate. Manual connections use trust-on-first-use if no
|
||||
fingerprint is stored yet.
|
||||
Auto-generated certs require `openssl` on PATH; if generation fails, the bridge will not start.
|
||||
|
||||
```json5
|
||||
{
|
||||
bridge: {
|
||||
enabled: true,
|
||||
port: 18790,
|
||||
bind: "tailnet",
|
||||
tls: {
|
||||
enabled: true,
|
||||
// Uses ~/.clawdbot/bridge/tls/bridge-{cert,key}.pem when omitted.
|
||||
// certPath: "~/.clawdbot/bridge/tls/bridge-cert.pem",
|
||||
// keyPath: "~/.clawdbot/bridge/tls/bridge-key.pem"
|
||||
}
|
||||
bind: "tailnet"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -84,7 +84,7 @@ Schema:
|
||||
Notes:
|
||||
- `allowlist` entries are JSON-encoded argv arrays.
|
||||
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment.
|
||||
- Allowlisted runs ignore `PATH` overrides; other env vars are merged with the app’s environment.
|
||||
|
||||
## Deep links
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ curl -s http://127.0.0.1:18791/tabs
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `browser.enabled` | Enable browser control | `true` |
|
||||
| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) |
|
||||
| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected |
|
||||
| `browser.headless` | Run without GUI | `false` |
|
||||
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
|
||||
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |
|
||||
|
||||
@@ -71,7 +71,7 @@ Notes:
|
||||
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
- Auto-detect order: Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
|
||||
## Use Brave (or another Chromium-based browser)
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("node:fs", () => {
|
||||
const existsSync = vi.fn();
|
||||
const readFileSync = vi.fn();
|
||||
return {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
default: { existsSync, readFileSync },
|
||||
};
|
||||
});
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
describe("browser default executable detection", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("prefers default Chromium browser on macOS", async () => {
|
||||
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
||||
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
||||
if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) {
|
||||
return "com.google.Chrome";
|
||||
}
|
||||
if (cmd === "/usr/bin/osascript" && argsStr.includes("POSIX path")) {
|
||||
return "/Applications/Google Chrome.app";
|
||||
}
|
||||
if (cmd === "/usr/bin/defaults") {
|
||||
return "Google Chrome";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||
);
|
||||
|
||||
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
|
||||
const exe = resolveBrowserExecutableForPlatform(
|
||||
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
|
||||
"darwin",
|
||||
);
|
||||
|
||||
expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome");
|
||||
expect(exe?.kind).toBe("chrome");
|
||||
});
|
||||
|
||||
it("falls back when default browser is non-Chromium on macOS", async () => {
|
||||
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
||||
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
||||
if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) {
|
||||
return "com.apple.Safari";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes("Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||
);
|
||||
|
||||
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
|
||||
const exe = resolveBrowserExecutableForPlatform(
|
||||
{} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
|
||||
"darwin",
|
||||
);
|
||||
|
||||
expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -10,83 +9,6 @@ export type BrowserExecutable = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
const CHROMIUM_BUNDLE_IDS = new Set([
|
||||
"com.google.Chrome",
|
||||
"com.google.Chrome.beta",
|
||||
"com.google.Chrome.canary",
|
||||
"com.google.Chrome.dev",
|
||||
"com.brave.Browser",
|
||||
"com.brave.Browser.beta",
|
||||
"com.brave.Browser.nightly",
|
||||
"com.microsoft.Edge",
|
||||
"com.microsoft.EdgeBeta",
|
||||
"com.microsoft.EdgeDev",
|
||||
"com.microsoft.EdgeCanary",
|
||||
"org.chromium.Chromium",
|
||||
"com.vivaldi.Vivaldi",
|
||||
"com.operasoftware.Opera",
|
||||
"com.operasoftware.OperaGX",
|
||||
"com.yandex.desktop.yandex-browser",
|
||||
"company.thebrowser.Browser", // Arc
|
||||
]);
|
||||
|
||||
const CHROMIUM_DESKTOP_IDS = new Set([
|
||||
"google-chrome.desktop",
|
||||
"google-chrome-beta.desktop",
|
||||
"google-chrome-unstable.desktop",
|
||||
"brave-browser.desktop",
|
||||
"microsoft-edge.desktop",
|
||||
"microsoft-edge-beta.desktop",
|
||||
"microsoft-edge-dev.desktop",
|
||||
"microsoft-edge-canary.desktop",
|
||||
"chromium.desktop",
|
||||
"chromium-browser.desktop",
|
||||
"vivaldi.desktop",
|
||||
"vivaldi-stable.desktop",
|
||||
"opera.desktop",
|
||||
"opera-gx.desktop",
|
||||
"yandex-browser.desktop",
|
||||
"org.chromium.Chromium.desktop",
|
||||
]);
|
||||
|
||||
const CHROMIUM_EXE_NAMES = new Set([
|
||||
"chrome.exe",
|
||||
"msedge.exe",
|
||||
"brave.exe",
|
||||
"brave-browser.exe",
|
||||
"chromium.exe",
|
||||
"vivaldi.exe",
|
||||
"opera.exe",
|
||||
"launcher.exe",
|
||||
"yandex.exe",
|
||||
"yandexbrowser.exe",
|
||||
// mac/linux names
|
||||
"google chrome",
|
||||
"google chrome canary",
|
||||
"brave browser",
|
||||
"microsoft edge",
|
||||
"chromium",
|
||||
"chrome",
|
||||
"brave",
|
||||
"msedge",
|
||||
"brave-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"google-chrome-beta",
|
||||
"google-chrome-unstable",
|
||||
"microsoft-edge",
|
||||
"microsoft-edge-beta",
|
||||
"microsoft-edge-dev",
|
||||
"microsoft-edge-canary",
|
||||
"chromium-browser",
|
||||
"vivaldi",
|
||||
"vivaldi-stable",
|
||||
"opera",
|
||||
"opera-stable",
|
||||
"opera-gx",
|
||||
"yandex-browser",
|
||||
]);
|
||||
|
||||
function exists(filePath: string) {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
@@ -95,223 +17,6 @@ function exists(filePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function execText(command: string, args: string[], timeoutMs = 1200): string | null {
|
||||
try {
|
||||
const output = execFileSync(command, args, {
|
||||
timeout: timeoutMs,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return String(output ?? "").trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] {
|
||||
const id = identifier.toLowerCase();
|
||||
if (id.includes("brave")) return "brave";
|
||||
if (id.includes("edge")) return "edge";
|
||||
if (id.includes("chromium")) return "chromium";
|
||||
if (id.includes("canary")) return "canary";
|
||||
if (id.includes("opera") || id.includes("vivaldi") || id.includes("yandex") || id.includes("thebrowser")) {
|
||||
return "chromium";
|
||||
}
|
||||
return "chrome";
|
||||
}
|
||||
|
||||
function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("brave")) return "brave";
|
||||
if (lower.includes("edge") || lower.includes("msedge")) return "edge";
|
||||
if (lower.includes("chromium")) return "chromium";
|
||||
if (lower.includes("canary") || lower.includes("sxs")) return "canary";
|
||||
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) return "chromium";
|
||||
return "chrome";
|
||||
}
|
||||
|
||||
function detectDefaultChromiumExecutable(
|
||||
platform: NodeJS.Platform,
|
||||
): BrowserExecutable | null {
|
||||
if (platform === "darwin") return detectDefaultChromiumExecutableMac();
|
||||
if (platform === "linux") return detectDefaultChromiumExecutableLinux();
|
||||
if (platform === "win32") return detectDefaultChromiumExecutableWindows();
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
|
||||
const bundleId = execText("/usr/bin/osascript", [
|
||||
"-e",
|
||||
'id of application (path to default application for URL "http://example.com")',
|
||||
]);
|
||||
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId.trim())) return null;
|
||||
const appPathRaw = execText("/usr/bin/osascript", [
|
||||
"-e",
|
||||
'POSIX path of (path to default application for URL "http://example.com")',
|
||||
]);
|
||||
if (!appPathRaw) return null;
|
||||
const appPath = appPathRaw.trim().replace(/\/$/, "");
|
||||
const exeName = execText("/usr/bin/defaults", [
|
||||
"read",
|
||||
path.join(appPath, "Contents", "Info"),
|
||||
"CFBundleExecutable",
|
||||
]);
|
||||
if (!exeName) return null;
|
||||
const exePath = path.join(appPath, "Contents", "MacOS", exeName.trim());
|
||||
if (!exists(exePath)) return null;
|
||||
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
|
||||
}
|
||||
|
||||
function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
|
||||
const desktopId =
|
||||
execText("xdg-settings", ["get", "default-web-browser"]) ||
|
||||
execText("xdg-mime", ["query", "default", "x-scheme-handler/http"]);
|
||||
if (!desktopId) return null;
|
||||
const trimmed = desktopId.trim();
|
||||
if (!CHROMIUM_DESKTOP_IDS.has(trimmed)) return null;
|
||||
const desktopPath = findDesktopFilePath(trimmed);
|
||||
if (!desktopPath) return null;
|
||||
const execLine = readDesktopExecLine(desktopPath);
|
||||
if (!execLine) return null;
|
||||
const command = extractExecutableFromExecLine(execLine);
|
||||
if (!command) return null;
|
||||
const resolved = resolveLinuxExecutablePath(command);
|
||||
if (!resolved) return null;
|
||||
const exeName = path.posix.basename(resolved).toLowerCase();
|
||||
if (!CHROMIUM_EXE_NAMES.has(exeName)) return null;
|
||||
return { kind: inferKindFromExecutableName(exeName), path: resolved };
|
||||
}
|
||||
|
||||
function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
|
||||
const progId = readWindowsProgId();
|
||||
const command =
|
||||
(progId ? readWindowsCommandForProgId(progId) : null) ||
|
||||
readWindowsCommandForProgId("http");
|
||||
if (!command) return null;
|
||||
const expanded = expandWindowsEnvVars(command);
|
||||
const exePath = extractWindowsExecutablePath(expanded);
|
||||
if (!exePath) return null;
|
||||
if (!exists(exePath)) return null;
|
||||
const exeName = path.win32.basename(exePath).toLowerCase();
|
||||
if (!CHROMIUM_EXE_NAMES.has(exeName)) return null;
|
||||
return { kind: inferKindFromExecutableName(exeName), path: exePath };
|
||||
}
|
||||
|
||||
function findDesktopFilePath(desktopId: string): string | null {
|
||||
const candidates = [
|
||||
path.join(os.homedir(), ".local", "share", "applications", desktopId),
|
||||
path.join("/usr/local/share/applications", desktopId),
|
||||
path.join("/usr/share/applications", desktopId),
|
||||
path.join("/var/lib/snapd/desktop/applications", desktopId),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (exists(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDesktopExecLine(desktopPath: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(desktopPath, "utf8");
|
||||
const lines = raw.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("Exec=")) {
|
||||
return line.slice("Exec=".length).trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractExecutableFromExecLine(execLine: string): string | null {
|
||||
const tokens = splitExecLine(execLine);
|
||||
for (const token of tokens) {
|
||||
if (!token) continue;
|
||||
if (token === "env") continue;
|
||||
if (token.includes("=") && !token.startsWith("/") && !token.includes("\\")) continue;
|
||||
return token.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitExecLine(line: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
let quoteChar = "";
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const ch = line[i];
|
||||
if ((ch === "\"" || ch === "'") && (!inQuotes || ch === quoteChar)) {
|
||||
if (inQuotes) {
|
||||
inQuotes = false;
|
||||
quoteChar = "";
|
||||
} else {
|
||||
inQuotes = true;
|
||||
quoteChar = ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!inQuotes && /\s/.test(ch)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function resolveLinuxExecutablePath(command: string): string | null {
|
||||
const cleaned = command.trim().replace(/%[a-zA-Z]/g, "");
|
||||
if (!cleaned) return null;
|
||||
if (cleaned.startsWith("/")) return cleaned;
|
||||
const resolved = execText("which", [cleaned], 800);
|
||||
return resolved ? resolved.trim() : null;
|
||||
}
|
||||
|
||||
function readWindowsProgId(): string | null {
|
||||
const output = execText("reg", [
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
|
||||
"/v",
|
||||
"ProgId",
|
||||
]);
|
||||
if (!output) return null;
|
||||
const match = output.match(/ProgId\s+REG_\w+\s+(.+)$/im);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function readWindowsCommandForProgId(progId: string): string | null {
|
||||
const key =
|
||||
progId === "http"
|
||||
? "HKCR\\http\\shell\\open\\command"
|
||||
: `HKCR\\${progId}\\shell\\open\\command`;
|
||||
const output = execText("reg", ["query", key, "/ve"]);
|
||||
if (!output) return null;
|
||||
const match = output.match(/REG_\w+\s+(.+)$/im);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function expandWindowsEnvVars(value: string): string {
|
||||
return value.replace(/%([^%]+)%/g, (_match, name) => {
|
||||
const key = String(name ?? "").trim();
|
||||
return key ? process.env[key] ?? `%${key}%` : _match;
|
||||
});
|
||||
}
|
||||
|
||||
function extractWindowsExecutablePath(command: string): string | null {
|
||||
const quoted = command.match(/"([^"]+\\.exe)"/i);
|
||||
if (quoted?.[1]) return quoted[1];
|
||||
const unquoted = command.match(/([^\\s]+\\.exe)/i);
|
||||
if (unquoted?.[1]) return unquoted[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFirstExecutable(candidates: Array<BrowserExecutable>): BrowserExecutable | null {
|
||||
for (const candidate of candidates) {
|
||||
if (exists(candidate.path)) return candidate;
|
||||
@@ -474,9 +179,6 @@ export function resolveBrowserExecutableForPlatform(
|
||||
return { kind: "custom", path: resolved.executablePath };
|
||||
}
|
||||
|
||||
const detected = detectDefaultChromiumExecutable(platform);
|
||||
if (detected) return detected;
|
||||
|
||||
if (platform === "darwin") return findChromeExecutableMac();
|
||||
if (platform === "linux") return findChromeExecutableLinux();
|
||||
if (platform === "win32") return findChromeExecutableWindows();
|
||||
|
||||
@@ -209,28 +209,6 @@ describe("daemon-cli coverage", () => {
|
||||
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("installs the daemon with json output", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
serviceIsLoaded.mockResolvedValueOnce(false);
|
||||
serviceInstall.mockClear();
|
||||
|
||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerDaemonCli(program);
|
||||
|
||||
await program.parseAsync(["daemon", "install", "--port", "18789", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||
const parsed = JSON.parse(jsonLine ?? "{}") as { ok?: boolean; action?: string; result?: string };
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.action).toBe("install");
|
||||
expect(parsed.result).toBe("installed");
|
||||
});
|
||||
|
||||
it("starts and stops the daemon via service helpers", async () => {
|
||||
serviceRestart.mockClear();
|
||||
serviceStop.mockClear();
|
||||
@@ -247,25 +225,4 @@ describe("daemon-cli coverage", () => {
|
||||
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
||||
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits json for daemon start/stop", async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
serviceRestart.mockClear();
|
||||
serviceStop.mockClear();
|
||||
serviceIsLoaded.mockResolvedValue(true);
|
||||
|
||||
const { registerDaemonCli } = await import("./daemon-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerDaemonCli(program);
|
||||
|
||||
await program.parseAsync(["daemon", "start", "--json"], { from: "user" });
|
||||
await program.parseAsync(["daemon", "stop", "--json"], { from: "user" });
|
||||
|
||||
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
|
||||
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
|
||||
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
|
||||
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -15,59 +15,33 @@ import {
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../../daemon/service-env.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
||||
import { parsePort } from "./shared.js";
|
||||
import type { DaemonInstallOptions } from "./types.js";
|
||||
|
||||
export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
const json = Boolean(opts.json);
|
||||
const warnings: string[] = [];
|
||||
const stdout = json ? createNullWriter() : process.stdout;
|
||||
const emit = (payload: {
|
||||
ok: boolean;
|
||||
result?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
service?: {
|
||||
label: string;
|
||||
loaded: boolean;
|
||||
loadedText: string;
|
||||
notLoadedText: string;
|
||||
};
|
||||
hints?: string[];
|
||||
warnings?: string[];
|
||||
}) => {
|
||||
if (!json) return;
|
||||
emitDaemonActionJson({ action: "install", ...payload });
|
||||
};
|
||||
const fail = (message: string) => {
|
||||
if (json) {
|
||||
emit({ ok: false, error: message, warnings: warnings.length ? warnings : undefined });
|
||||
} else {
|
||||
defaultRuntime.error(message);
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
};
|
||||
|
||||
if (resolveIsNixMode(process.env)) {
|
||||
fail("Nix mode detected; daemon install is disabled.");
|
||||
defaultRuntime.error("Nix mode detected; daemon install is disabled.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const portOverride = parsePort(opts.port);
|
||||
if (opts.port !== undefined && portOverride === null) {
|
||||
fail("Invalid port");
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const port = portOverride ?? resolveGatewayPort(cfg);
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
fail("Invalid port");
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const runtimeRaw = opts.runtime ? String(opts.runtime) : DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
||||
if (!isGatewayDaemonRuntime(runtimeRaw)) {
|
||||
fail('Invalid --runtime (use "node" or "bun")');
|
||||
defaultRuntime.error('Invalid --runtime (use "node" or "bun")');
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,22 +50,14 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch (err) {
|
||||
fail(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (loaded) {
|
||||
if (!opts.force) {
|
||||
emit({
|
||||
ok: true,
|
||||
result: "already-installed",
|
||||
message: `Gateway service already ${service.loadedText}.`,
|
||||
service: buildDaemonServiceSnapshot(service, loaded),
|
||||
warnings: warnings.length ? warnings : undefined,
|
||||
});
|
||||
if (!json) {
|
||||
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
|
||||
defaultRuntime.log("Reinstall with: clawdbot daemon install --force");
|
||||
}
|
||||
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
|
||||
defaultRuntime.log("Reinstall with: clawdbot daemon install --force");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -111,10 +77,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
if (runtimeRaw === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) {
|
||||
if (json) warnings.push(warning);
|
||||
else defaultRuntime.log(warning);
|
||||
}
|
||||
if (warning) defaultRuntime.log(warning);
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
@@ -129,26 +92,13 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
try {
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
} catch (err) {
|
||||
fail(`Gateway install failed: ${String(err)}`);
|
||||
return;
|
||||
defaultRuntime.error(`Gateway install failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
let installed = true;
|
||||
try {
|
||||
installed = await service.isLoaded({ env: process.env });
|
||||
} catch {
|
||||
installed = true;
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "installed",
|
||||
service: buildDaemonServiceSnapshot(service, installed),
|
||||
warnings: warnings.length ? warnings : undefined,
|
||||
});
|
||||
}
|
||||
+34
-199
@@ -1,193 +1,72 @@
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
||||
import { renderGatewayServiceStartHints } from "./shared.js";
|
||||
import type { DaemonLifecycleOptions } from "./types.js";
|
||||
|
||||
export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
|
||||
const json = Boolean(opts.json);
|
||||
const stdout = json ? createNullWriter() : process.stdout;
|
||||
const emit = (payload: {
|
||||
ok: boolean;
|
||||
result?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
service?: {
|
||||
label: string;
|
||||
loaded: boolean;
|
||||
loadedText: string;
|
||||
notLoadedText: string;
|
||||
};
|
||||
}) => {
|
||||
if (!json) return;
|
||||
emitDaemonActionJson({ action: "uninstall", ...payload });
|
||||
};
|
||||
const fail = (message: string) => {
|
||||
if (json) emit({ ok: false, error: message });
|
||||
else defaultRuntime.error(message);
|
||||
defaultRuntime.exit(1);
|
||||
};
|
||||
|
||||
export async function runDaemonUninstall() {
|
||||
if (resolveIsNixMode(process.env)) {
|
||||
fail("Nix mode detected; daemon uninstall is disabled.");
|
||||
defaultRuntime.error("Nix mode detected; daemon uninstall is disabled.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
try {
|
||||
await service.uninstall({ env: process.env, stdout });
|
||||
await service.uninstall({ env: process.env, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
fail(`Gateway uninstall failed: ${String(err)}`);
|
||||
return;
|
||||
defaultRuntime.error(`Gateway uninstall failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch {
|
||||
loaded = false;
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "uninstalled",
|
||||
service: buildDaemonServiceSnapshot(service, loaded),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) {
|
||||
const json = Boolean(opts.json);
|
||||
const stdout = json ? createNullWriter() : process.stdout;
|
||||
const emit = (payload: {
|
||||
ok: boolean;
|
||||
result?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
hints?: string[];
|
||||
service?: {
|
||||
label: string;
|
||||
loaded: boolean;
|
||||
loadedText: string;
|
||||
notLoadedText: string;
|
||||
};
|
||||
}) => {
|
||||
if (!json) return;
|
||||
emitDaemonActionJson({ action: "start", ...payload });
|
||||
};
|
||||
const fail = (message: string, hints?: string[]) => {
|
||||
if (json) emit({ ok: false, error: message, hints });
|
||||
else defaultRuntime.error(message);
|
||||
defaultRuntime.exit(1);
|
||||
};
|
||||
|
||||
export async function runDaemonStart() {
|
||||
const service = resolveGatewayService();
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch (err) {
|
||||
fail(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!loaded) {
|
||||
const hints = renderGatewayServiceStartHints();
|
||||
emit({
|
||||
ok: true,
|
||||
result: "not-loaded",
|
||||
message: `Gateway service ${service.notLoadedText}.`,
|
||||
hints,
|
||||
service: buildDaemonServiceSnapshot(service, loaded),
|
||||
});
|
||||
if (!json) {
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
for (const hint of hints) {
|
||||
defaultRuntime.log(`Start with: ${hint}`);
|
||||
}
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
for (const hint of renderGatewayServiceStartHints()) {
|
||||
defaultRuntime.log(`Start with: ${hint}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await service.restart({ env: process.env, stdout });
|
||||
await service.restart({ env: process.env, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
const hints = renderGatewayServiceStartHints();
|
||||
fail(`Gateway start failed: ${String(err)}`, hints);
|
||||
return;
|
||||
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
|
||||
for (const hint of renderGatewayServiceStartHints()) {
|
||||
defaultRuntime.error(`Start with: ${hint}`);
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
let started = true;
|
||||
try {
|
||||
started = await service.isLoaded({ env: process.env });
|
||||
} catch {
|
||||
started = true;
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "started",
|
||||
service: buildDaemonServiceSnapshot(service, started),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
|
||||
const json = Boolean(opts.json);
|
||||
const stdout = json ? createNullWriter() : process.stdout;
|
||||
const emit = (payload: {
|
||||
ok: boolean;
|
||||
result?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
service?: {
|
||||
label: string;
|
||||
loaded: boolean;
|
||||
loadedText: string;
|
||||
notLoadedText: string;
|
||||
};
|
||||
}) => {
|
||||
if (!json) return;
|
||||
emitDaemonActionJson({ action: "stop", ...payload });
|
||||
};
|
||||
const fail = (message: string) => {
|
||||
if (json) emit({ ok: false, error: message });
|
||||
else defaultRuntime.error(message);
|
||||
defaultRuntime.exit(1);
|
||||
};
|
||||
|
||||
export async function runDaemonStop() {
|
||||
const service = resolveGatewayService();
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch (err) {
|
||||
fail(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!loaded) {
|
||||
emit({
|
||||
ok: true,
|
||||
result: "not-loaded",
|
||||
message: `Gateway service ${service.notLoadedText}.`,
|
||||
service: buildDaemonServiceSnapshot(service, loaded),
|
||||
});
|
||||
if (!json) {
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
}
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await service.stop({ env: process.env, stdout });
|
||||
await service.stop({ env: process.env, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
fail(`Gateway stop failed: ${String(err)}`);
|
||||
return;
|
||||
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
let stopped = false;
|
||||
try {
|
||||
stopped = await service.isLoaded({ env: process.env });
|
||||
} catch {
|
||||
stopped = false;
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "stopped",
|
||||
service: buildDaemonServiceSnapshot(service, stopped),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,73 +74,29 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
|
||||
* @returns `true` if restart succeeded, `false` if the service was not loaded.
|
||||
* Throws/exits on check or restart failures.
|
||||
*/
|
||||
export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promise<boolean> {
|
||||
const json = Boolean(opts.json);
|
||||
const stdout = json ? createNullWriter() : process.stdout;
|
||||
const emit = (payload: {
|
||||
ok: boolean;
|
||||
result?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
hints?: string[];
|
||||
service?: {
|
||||
label: string;
|
||||
loaded: boolean;
|
||||
loadedText: string;
|
||||
notLoadedText: string;
|
||||
};
|
||||
}) => {
|
||||
if (!json) return;
|
||||
emitDaemonActionJson({ action: "restart", ...payload });
|
||||
};
|
||||
const fail = (message: string, hints?: string[]) => {
|
||||
if (json) emit({ ok: false, error: message, hints });
|
||||
else defaultRuntime.error(message);
|
||||
defaultRuntime.exit(1);
|
||||
};
|
||||
|
||||
export async function runDaemonRestart(): Promise<boolean> {
|
||||
const service = resolveGatewayService();
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch (err) {
|
||||
fail(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return false;
|
||||
}
|
||||
if (!loaded) {
|
||||
const hints = renderGatewayServiceStartHints();
|
||||
emit({
|
||||
ok: true,
|
||||
result: "not-loaded",
|
||||
message: `Gateway service ${service.notLoadedText}.`,
|
||||
hints,
|
||||
service: buildDaemonServiceSnapshot(service, loaded),
|
||||
});
|
||||
if (!json) {
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
for (const hint of hints) {
|
||||
defaultRuntime.log(`Start with: ${hint}`);
|
||||
}
|
||||
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
for (const hint of renderGatewayServiceStartHints()) {
|
||||
defaultRuntime.log(`Start with: ${hint}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await service.restart({ env: process.env, stdout });
|
||||
let restarted = true;
|
||||
try {
|
||||
restarted = await service.isLoaded({ env: process.env });
|
||||
} catch {
|
||||
restarted = true;
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "restarted",
|
||||
service: buildDaemonServiceSnapshot(service, restarted),
|
||||
});
|
||||
await service.restart({ env: process.env, stdout: process.stdout });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const hints = renderGatewayServiceStartHints();
|
||||
fail(`Gateway restart failed: ${String(err)}`, hints);
|
||||
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
|
||||
defaultRuntime.exit(1);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,6 @@ export function registerDaemonCli(program: Command) {
|
||||
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
|
||||
.option("--token <token>", "Gateway token (token auth)")
|
||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonInstall(opts);
|
||||
});
|
||||
@@ -55,33 +54,29 @@ export function registerDaemonCli(program: Command) {
|
||||
daemon
|
||||
.command("uninstall")
|
||||
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonUninstall(opts);
|
||||
.action(async () => {
|
||||
await runDaemonUninstall();
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("start")
|
||||
.description("Start the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonStart(opts);
|
||||
.action(async () => {
|
||||
await runDaemonStart();
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("stop")
|
||||
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonStop(opts);
|
||||
.action(async () => {
|
||||
await runDaemonStop();
|
||||
});
|
||||
|
||||
daemon
|
||||
.command("restart")
|
||||
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runDaemonRestart(opts);
|
||||
.action(async () => {
|
||||
await runDaemonRestart();
|
||||
});
|
||||
|
||||
// Build default deps (parity with other commands).
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Writable } from "node:stream";
|
||||
|
||||
import type { GatewayService } from "../../daemon/service.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
export type DaemonAction = "install" | "uninstall" | "start" | "stop" | "restart";
|
||||
|
||||
export type DaemonActionResponse = {
|
||||
ok: boolean;
|
||||
action: DaemonAction;
|
||||
result?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
hints?: string[];
|
||||
warnings?: string[];
|
||||
service?: {
|
||||
label: string;
|
||||
loaded: boolean;
|
||||
loadedText: string;
|
||||
notLoadedText: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function emitDaemonActionJson(payload: DaemonActionResponse) {
|
||||
defaultRuntime.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
export function buildDaemonServiceSnapshot(service: GatewayService, loaded: boolean) {
|
||||
return {
|
||||
label: service.label,
|
||||
loaded,
|
||||
loadedText: service.loadedText,
|
||||
notLoadedText: service.notLoadedText,
|
||||
};
|
||||
}
|
||||
|
||||
export function createNullWriter(): Writable {
|
||||
return new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -19,9 +19,4 @@ export type DaemonInstallOptions = {
|
||||
runtime?: string;
|
||||
token?: string;
|
||||
force?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type DaemonLifecycleOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -11,20 +11,6 @@ export type BridgeConfig = {
|
||||
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway)
|
||||
*/
|
||||
bind?: BridgeBindMode;
|
||||
tls?: BridgeTlsConfig;
|
||||
};
|
||||
|
||||
export type BridgeTlsConfig = {
|
||||
/** Enable TLS for the node bridge server. */
|
||||
enabled?: boolean;
|
||||
/** Auto-generate a self-signed cert if cert/key are missing (default: true). */
|
||||
autoGenerate?: boolean;
|
||||
/** PEM certificate path for the bridge server. */
|
||||
certPath?: string;
|
||||
/** PEM private key path for the bridge server. */
|
||||
keyPath?: string;
|
||||
/** Optional PEM CA bundle for TLS clients (mTLS or custom roots). */
|
||||
caPath?: string;
|
||||
};
|
||||
|
||||
export type WideAreaDiscoveryConfig = {
|
||||
|
||||
@@ -171,15 +171,6 @@ export const ClawdbotSchema = z
|
||||
bind: z
|
||||
.union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")])
|
||||
.optional(),
|
||||
tls: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
autoGenerate: z.boolean().optional(),
|
||||
certPath: z.string().optional(),
|
||||
keyPath: z.string().optional(),
|
||||
caPath: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
discovery: z
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSystemdExecStart } from "./systemd-unit.js";
|
||||
|
||||
describe("parseSystemdExecStart", () => {
|
||||
it("splits on whitespace outside quotes", () => {
|
||||
const execStart = "/usr/bin/clawdbot gateway start --foo bar";
|
||||
expect(parseSystemdExecStart(execStart)).toEqual([
|
||||
"/usr/bin/clawdbot",
|
||||
"gateway",
|
||||
"start",
|
||||
"--foo",
|
||||
"bar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves quoted arguments", () => {
|
||||
const execStart = "/usr/bin/clawdbot gateway start --name \"My Bot\"";
|
||||
expect(parseSystemdExecStart(execStart)).toEqual([
|
||||
"/usr/bin/clawdbot",
|
||||
"gateway",
|
||||
"start",
|
||||
"--name",
|
||||
"My Bot",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves backslashes in arguments", () => {
|
||||
const execStart = String.raw`/usr/bin/clawdbot gateway start --path \/tmp\/clawdbot`;
|
||||
expect(parseSystemdExecStart(execStart)).toEqual([
|
||||
"/usr/bin/clawdbot",
|
||||
"gateway",
|
||||
"start",
|
||||
"--path",
|
||||
"\\/tmp\\/clawdbot",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export function parseSystemdExecStart(value: string): string[] {
|
||||
inQuotes = !inQuotes;
|
||||
continue;
|
||||
}
|
||||
if (!inQuotes && /\s/.test(char)) {
|
||||
if (!inQuotes && /\\s/.test(char)) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = "";
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { HealthSummary } from "../commands/health.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
|
||||
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import { loadBridgeTlsRuntime } from "../infra/bridge/server/tls.js";
|
||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||
@@ -72,7 +71,7 @@ export async function startGatewayBridgeRuntime(params: {
|
||||
}): Promise<GatewayBridgeRuntime> {
|
||||
const wideAreaDiscoveryEnabled = params.cfg.discovery?.wideArea?.enabled === true;
|
||||
|
||||
let bridgeEnabled = (() => {
|
||||
const bridgeEnabled = (() => {
|
||||
if (params.cfg.bridge?.enabled !== undefined) return params.cfg.bridge.enabled === true;
|
||||
return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0";
|
||||
})();
|
||||
@@ -112,14 +111,6 @@ export async function startGatewayBridgeRuntime(params: {
|
||||
return "0.0.0.0";
|
||||
})();
|
||||
|
||||
const bridgeTls = bridgeEnabled
|
||||
? await loadBridgeTlsRuntime(params.cfg.bridge?.tls, params.logBridge)
|
||||
: { enabled: false, required: false };
|
||||
if (bridgeTls.required && !bridgeTls.enabled) {
|
||||
params.logBridge.warn(bridgeTls.error ?? "bridge tls: failed to enable; bridge disabled");
|
||||
bridgeEnabled = false;
|
||||
}
|
||||
|
||||
const canvasHostPort = (() => {
|
||||
if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) {
|
||||
const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10);
|
||||
@@ -206,7 +197,6 @@ export async function startGatewayBridgeRuntime(params: {
|
||||
bridgeEnabled,
|
||||
bridgePort,
|
||||
bridgeHost,
|
||||
bridgeTls: bridgeTls.enabled ? bridgeTls : undefined,
|
||||
machineDisplayName: params.machineDisplayName,
|
||||
canvasHostPort: canvasHostPortForBridge,
|
||||
canvasHostHost: canvasHostHostForBridge,
|
||||
@@ -222,9 +212,6 @@ export async function startGatewayBridgeRuntime(params: {
|
||||
machineDisplayName: params.machineDisplayName,
|
||||
port: params.port,
|
||||
bridgePort: bridge?.port,
|
||||
bridgeTls: bridgeTls.enabled
|
||||
? { enabled: true, fingerprintSha256: bridgeTls.fingerprintSha256 }
|
||||
: undefined,
|
||||
canvasPort: canvasHostPortForBridge,
|
||||
wideAreaDiscoveryEnabled,
|
||||
logDiscovery: params.logDiscovery,
|
||||
|
||||
@@ -11,7 +11,6 @@ export async function startGatewayDiscovery(params: {
|
||||
machineDisplayName: string;
|
||||
port: number;
|
||||
bridgePort?: number;
|
||||
bridgeTls?: { enabled: boolean; fingerprintSha256?: string };
|
||||
canvasPort?: number;
|
||||
wideAreaDiscoveryEnabled: boolean;
|
||||
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
@@ -28,8 +27,6 @@ export async function startGatewayDiscovery(params: {
|
||||
gatewayPort: params.port,
|
||||
bridgePort: params.bridgePort,
|
||||
canvasPort: params.canvasPort,
|
||||
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
|
||||
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
|
||||
sshPort,
|
||||
tailnetDns,
|
||||
cliPath: resolveBonjourCliPath(),
|
||||
@@ -54,8 +51,6 @@ export async function startGatewayDiscovery(params: {
|
||||
displayName: formatBonjourInstanceName(params.machineDisplayName),
|
||||
tailnetIPv4,
|
||||
tailnetIPv6: tailnetIPv6 ?? undefined,
|
||||
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
|
||||
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
|
||||
tailnetDns,
|
||||
sshPort,
|
||||
cliPath: resolveBonjourCliPath(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import { startNodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import type { BridgeTlsRuntime } from "../infra/bridge/server/tls.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../infra/skills-remote.js";
|
||||
@@ -24,7 +23,6 @@ export async function startGatewayNodeBridge(params: {
|
||||
bridgeEnabled: boolean;
|
||||
bridgePort: number;
|
||||
bridgeHost: string | null;
|
||||
bridgeTls?: BridgeTlsRuntime;
|
||||
machineDisplayName: string;
|
||||
canvasHostPort?: number;
|
||||
canvasHostHost?: string;
|
||||
@@ -113,7 +111,6 @@ export async function startGatewayNodeBridge(params: {
|
||||
const started = await startNodeBridgeServer({
|
||||
host: params.bridgeHost,
|
||||
port: params.bridgePort,
|
||||
tls: params.bridgeTls?.tlsOptions,
|
||||
serverName: params.machineDisplayName,
|
||||
canvasHostPort: params.canvasHostPort,
|
||||
canvasHostHost: params.canvasHostHost,
|
||||
@@ -161,8 +158,7 @@ export async function startGatewayNodeBridge(params: {
|
||||
},
|
||||
});
|
||||
if (started.port > 0) {
|
||||
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
|
||||
params.logBridge.info(`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`);
|
||||
params.logBridge.info(`listening on tcp://${params.bridgeHost}:${started.port} (node)`);
|
||||
return { bridge: started, nodePresenceTimers };
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,8 +12,6 @@ export type GatewayBonjourBeacon = {
|
||||
bridgePort?: number;
|
||||
gatewayPort?: number;
|
||||
sshPort?: number;
|
||||
bridgeTls?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
cliPath?: string;
|
||||
txt?: Record<string, string>;
|
||||
};
|
||||
@@ -208,11 +206,6 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour
|
||||
beacon.bridgePort = parseIntOrNull(txt.bridgePort);
|
||||
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
||||
beacon.sshPort = parseIntOrNull(txt.sshPort);
|
||||
if (txt.bridgeTls) {
|
||||
const raw = txt.bridgeTls.trim().toLowerCase();
|
||||
beacon.bridgeTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
}
|
||||
if (txt.bridgeTlsSha256) beacon.bridgeTlsFingerprintSha256 = txt.bridgeTlsSha256;
|
||||
|
||||
if (!beacon.displayName) beacon.displayName = decodedInstanceName;
|
||||
return beacon;
|
||||
|
||||
@@ -16,8 +16,6 @@ export type GatewayBonjourAdvertiseOpts = {
|
||||
sshPort?: number;
|
||||
bridgePort?: number;
|
||||
canvasPort?: number;
|
||||
bridgeTlsEnabled?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
tailnetDns?: string;
|
||||
cliPath?: string;
|
||||
};
|
||||
@@ -109,12 +107,6 @@ export async function startGatewayBonjourAdvertiser(
|
||||
if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) {
|
||||
txtBase.canvasPort = String(opts.canvasPort);
|
||||
}
|
||||
if (opts.bridgeTlsEnabled) {
|
||||
txtBase.bridgeTls = "1";
|
||||
if (opts.bridgeTlsFingerprintSha256) {
|
||||
txtBase.bridgeTlsSha256 = opts.bridgeTlsFingerprintSha256;
|
||||
}
|
||||
}
|
||||
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
|
||||
txtBase.tailnetDns = opts.tailnetDns.trim();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import tls from "node:tls";
|
||||
|
||||
import { resolveCanvasHostUrl } from "../../canvas-host-url.js";
|
||||
|
||||
@@ -48,8 +47,7 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise
|
||||
const loopbackHost = "127.0.0.1";
|
||||
|
||||
const listeners: Array<{ host: string; server: net.Server }> = [];
|
||||
const createServer = () => (opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection));
|
||||
const primary = createServer();
|
||||
const primary = net.createServer(onConnection);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
primary.once("error", onError);
|
||||
@@ -67,7 +65,7 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise
|
||||
const port = typeof address === "object" && address ? address.port : opts.port;
|
||||
|
||||
if (shouldAlsoListenOnLoopback(opts.host)) {
|
||||
const loopback = createServer();
|
||||
const loopback = net.createServer(onConnection);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import tls from "node:tls";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { BridgeTlsConfig } from "../../../config/types.gateway.js";
|
||||
import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../../utils.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type BridgeTlsRuntime = {
|
||||
enabled: boolean;
|
||||
required: boolean;
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
caPath?: string;
|
||||
fingerprintSha256?: string;
|
||||
tlsOptions?: tls.TlsOptions;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function normalizeFingerprint(input: string): string {
|
||||
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSelfSignedCert(params: {
|
||||
certPath: string;
|
||||
keyPath: string;
|
||||
log?: { info?: (msg: string) => void };
|
||||
}): Promise<void> {
|
||||
const certDir = path.dirname(params.certPath);
|
||||
const keyDir = path.dirname(params.keyPath);
|
||||
await ensureDir(certDir);
|
||||
if (keyDir !== certDir) {
|
||||
await ensureDir(keyDir);
|
||||
}
|
||||
await execFileAsync("openssl", [
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-sha256",
|
||||
"-days",
|
||||
"3650",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
params.keyPath,
|
||||
"-out",
|
||||
params.certPath,
|
||||
"-subj",
|
||||
"/CN=clawdbot-bridge",
|
||||
]);
|
||||
await fs.chmod(params.keyPath, 0o600).catch(() => {});
|
||||
await fs.chmod(params.certPath, 0o600).catch(() => {});
|
||||
params.log?.info?.(
|
||||
`bridge tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadBridgeTlsRuntime(
|
||||
cfg: BridgeTlsConfig | undefined,
|
||||
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
||||
): Promise<BridgeTlsRuntime> {
|
||||
if (!cfg || cfg.enabled !== true) return { enabled: false, required: false };
|
||||
|
||||
const autoGenerate = cfg.autoGenerate !== false;
|
||||
const baseDir = path.join(CONFIG_DIR, "bridge", "tls");
|
||||
const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "bridge-cert.pem"));
|
||||
const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "bridge-key.pem"));
|
||||
const caPath = cfg.caPath ? resolveUserPath(cfg.caPath) : undefined;
|
||||
|
||||
const hasCert = await fileExists(certPath);
|
||||
const hasKey = await fileExists(keyPath);
|
||||
|
||||
if (!hasCert && !hasKey && autoGenerate) {
|
||||
try {
|
||||
await generateSelfSignedCert({ certPath, keyPath, log });
|
||||
} catch (err) {
|
||||
return {
|
||||
enabled: false,
|
||||
required: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
error: `bridge tls: failed to generate cert (${String(err)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await fileExists(certPath)) || !(await fileExists(keyPath))) {
|
||||
return {
|
||||
enabled: false,
|
||||
required: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
error: "bridge tls: cert/key missing",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const cert = await fs.readFile(certPath, "utf8");
|
||||
const key = await fs.readFile(keyPath, "utf8");
|
||||
const ca = caPath ? await fs.readFile(caPath, "utf8") : undefined;
|
||||
const x509 = new X509Certificate(cert);
|
||||
const fingerprintSha256 = normalizeFingerprint(x509.fingerprint256 ?? "");
|
||||
|
||||
if (!fingerprintSha256) {
|
||||
return {
|
||||
enabled: false,
|
||||
required: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
caPath,
|
||||
error: "bridge tls: unable to compute certificate fingerprint",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
required: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
caPath,
|
||||
fingerprintSha256,
|
||||
tlsOptions: {
|
||||
cert,
|
||||
key,
|
||||
ca,
|
||||
minVersion: "TLSv1.2",
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
enabled: false,
|
||||
required: true,
|
||||
certPath,
|
||||
keyPath,
|
||||
caPath,
|
||||
error: `bridge tls: failed to load cert (${String(err)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { TlsOptions } from "node:tls";
|
||||
|
||||
import type { NodePairingPendingRequest } from "../../node-pairing.js";
|
||||
|
||||
export type BridgeHelloFrame = {
|
||||
@@ -124,7 +122,6 @@ export type NodeBridgeClientInfo = {
|
||||
export type NodeBridgeServerOpts = {
|
||||
host: string;
|
||||
port: number; // 0 = ephemeral
|
||||
tls?: TlsOptions;
|
||||
pairingBaseDir?: string;
|
||||
canvasHostPort?: number;
|
||||
canvasHostHost?: string;
|
||||
|
||||
@@ -73,17 +73,7 @@ async function writeJSONAtomic(filePath: string, value: unknown) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
|
||||
try {
|
||||
await fs.chmod(tmp, 0o600);
|
||||
} catch {
|
||||
// best-effort; ignore on platforms without chmod
|
||||
}
|
||||
await fs.rename(tmp, filePath);
|
||||
try {
|
||||
await fs.chmod(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort; ignore on platforms without chmod
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredPending(
|
||||
|
||||
@@ -71,8 +71,6 @@ export type WideAreaBridgeZoneOpts = {
|
||||
displayName: string;
|
||||
tailnetIPv4: string;
|
||||
tailnetIPv6?: string;
|
||||
bridgeTlsEnabled?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
instanceLabel?: string;
|
||||
hostLabel?: string;
|
||||
tailnetDns?: string;
|
||||
@@ -93,12 +91,6 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
|
||||
if (typeof opts.gatewayPort === "number" && opts.gatewayPort > 0) {
|
||||
txt.push(`gatewayPort=${opts.gatewayPort}`);
|
||||
}
|
||||
if (opts.bridgeTlsEnabled) {
|
||||
txt.push(`bridgeTls=1`);
|
||||
if (opts.bridgeTlsFingerprintSha256) {
|
||||
txt.push(`bridgeTlsSha256=${opts.bridgeTlsFingerprintSha256}`);
|
||||
}
|
||||
}
|
||||
if (opts.tailnetDns?.trim()) {
|
||||
txt.push(`tailnetDns=${opts.tailnetDns.trim()}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user