Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2de7aa108 | ||
|
|
61b52eb3c7 | ||
|
|
41d44021e7 | ||
|
|
1ab1e312b2 | ||
|
|
1656f491fd | ||
|
|
a057b3c9e8 | ||
|
|
c0c9742e44 |
No files matched your search
@@ -17,6 +17,7 @@
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||
- Agents: include recent context in reminder cron payloads. (#993) — thanks @cpojer.
|
||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
@@ -160,6 +161,9 @@ 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)
|
||||
@@ -488,12 +492,17 @@ 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())
|
||||
@@ -510,6 +519,7 @@ class NodeRuntime(context: Context) {
|
||||
session.connect(
|
||||
endpoint = endpoint,
|
||||
hello = buildSessionHello(token = authToken),
|
||||
tls = tls,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -556,6 +566,41 @@ 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,6 +147,16 @@ 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,6 +143,8 @@ 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(
|
||||
@@ -155,6 +157,8 @@ class BridgeDiscovery(
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
@@ -209,6 +213,11 @@ 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
|
||||
@@ -252,6 +261,8 @@ 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(
|
||||
@@ -264,6 +275,8 @@ class BridgeDiscovery(
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -474,6 +487,11 @@ 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,6 +10,8 @@ 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 =
|
||||
@@ -18,6 +20,8 @@ data class BridgeEndpoint(
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
tlsFingerprintSha256 = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
+109
-85
@@ -14,7 +14,6 @@ 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 }
|
||||
@@ -33,95 +32,120 @@ class BridgePairingClient {
|
||||
|
||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||
|
||||
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||
suspend fun pairAndHello(
|
||||
endpoint: BridgeEndpoint,
|
||||
hello: Hello,
|
||||
tls: BridgeTlsParams? = null,
|
||||
onTlsFingerprint: ((String) -> Unit)? = null,
|
||||
): PairResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
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()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
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@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")
|
||||
}
|
||||
|
||||
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@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")
|
||||
}
|
||||
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 {
|
||||
if (tls != null) {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
||||
} catch (e: Exception) {
|
||||
if (tls.required) throw e
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
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" }
|
||||
return PairResult(ok = false, token = null, error = message)
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
@@ -35,6 +35,7 @@ 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,
|
||||
@@ -66,11 +67,17 @@ class BridgeSession(
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
@Volatile private var mainSessionKey: String? = null
|
||||
|
||||
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||
private data class DesiredConnection(
|
||||
val endpoint: BridgeEndpoint,
|
||||
val hello: Hello,
|
||||
val tls: BridgeTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private var job: Job? = null
|
||||
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||
desired = endpoint to hello
|
||||
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
||||
desired = DesiredConnection(endpoint, hello, tls)
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
@@ -78,7 +85,7 @@ class BridgeSession(
|
||||
|
||||
suspend fun updateHello(hello: Hello) {
|
||||
val target = desired ?: return
|
||||
desired = target.first to hello
|
||||
desired = target.copy(hello = hello)
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
}
|
||||
@@ -165,10 +172,10 @@ class BridgeSession(
|
||||
continue
|
||||
}
|
||||
|
||||
val (endpoint, hello) = target
|
||||
val (endpoint, hello, tls) = target
|
||||
try {
|
||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||
connectOnce(endpoint, hello)
|
||||
connectOnce(endpoint, hello, tls)
|
||||
attempt = 0
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
@@ -192,50 +199,66 @@ class BridgeSession(
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
}
|
||||
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val socket = Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||
socket.soTimeout = 0
|
||||
|
||||
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
|
||||
|
||||
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"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
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")
|
||||
if (tls != null) {
|
||||
try {
|
||||
connectWithSocket(endpoint, hello, tls)
|
||||
return@withContext
|
||||
} catch (err: Throwable) {
|
||||
if (tls.required) throw err
|
||||
}
|
||||
}
|
||||
connectWithSocket(endpoint, hello, null)
|
||||
}
|
||||
|
||||
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 conn = Connection(socket, reader, writer, writeLock)
|
||||
currentConnection = conn
|
||||
|
||||
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"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
while (scope.isActive) {
|
||||
val line = reader.readLine() ?: break
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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,10 +10,36 @@ 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 connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||
@@ -142,6 +168,18 @@ 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,6 +10,7 @@ protocol BridgePairingClient: Sendable {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
}
|
||||
|
||||
@@ -115,9 +116,12 @@ 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: BridgeEndpointID.stableID(endpoint),
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
return
|
||||
@@ -135,10 +139,12 @@ 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)
|
||||
}
|
||||
@@ -182,6 +188,7 @@ final class BridgeConnectionController {
|
||||
private func startAutoConnect(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
token: String,
|
||||
instanceId: String)
|
||||
{
|
||||
@@ -193,6 +200,7 @@ 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
|
||||
@@ -208,6 +216,7 @@ final class BridgeConnectionController {
|
||||
appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: bridgeStableID,
|
||||
tls: tls,
|
||||
hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
@@ -217,6 +226,47 @@ 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,6 +23,8 @@ final class BridgeDiscoveryModel {
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var canvasPort: Int?
|
||||
var tlsEnabled: Bool
|
||||
var tlsFingerprintSha256: String?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
@@ -90,6 +92,8 @@ 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
|
||||
@@ -214,4 +218,9 @@ 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,15 +69,42 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
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 connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||
self.connection = connection
|
||||
@@ -255,6 +282,18 @@ 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: [
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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,6 +205,7 @@ final class NodeAppModel {
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
tls: BridgeTLSParams?,
|
||||
hello: BridgeHello)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
@@ -232,6 +233,7 @@ 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,9 +407,11 @@ 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
|
||||
@@ -426,6 +428,7 @@ struct SettingsTab: View {
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
bridgeStableID: bridge.stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -462,6 +465,8 @@ 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
|
||||
@@ -485,6 +490,7 @@ struct SettingsTab: View {
|
||||
let token = try await BridgeClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tlsParams,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
statusStore.text = status
|
||||
@@ -500,7 +506,8 @@ struct SettingsTab: View {
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
bridgeStableID: stableID,
|
||||
tls: tlsParams,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -517,6 +524,47 @@ 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,6 +27,7 @@ private actor MockBridgePairingClient: BridgePairingClient {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: BridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
{
|
||||
self.lastToken = hello.token
|
||||
@@ -244,6 +245,8 @@ 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"
|
||||
@@ -292,6 +295,8 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||
name: "Gateway B",
|
||||
@@ -303,6 +308,8 @@ private func withKeychainValues<T>(
|
||||
gatewayPort: 28789,
|
||||
bridgePort: 28790,
|
||||
canvasPort: 28793,
|
||||
tlsEnabled: false,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
|
||||
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||
|
||||
@@ -32,6 +32,7 @@ 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,10 +11,39 @@ 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 connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await AsyncTimeout.withTimeout(
|
||||
@@ -164,6 +193,18 @@ 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,6 +36,7 @@ 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)
|
||||
@@ -44,15 +45,35 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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 connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
||||
self.connection = connection
|
||||
@@ -262,6 +283,25 @@ 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)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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,6 +4,12 @@ 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()
|
||||
@@ -63,7 +69,7 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
guard let endpoint = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||
guard let target = 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
|
||||
@@ -73,10 +79,11 @@ final class MacNodeModeCoordinator {
|
||||
do {
|
||||
let hello = await self.makeHello()
|
||||
self.logger.info(
|
||||
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
|
||||
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
|
||||
try await self.session.connect(
|
||||
endpoint: endpoint,
|
||||
endpoint: target.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 {
|
||||
@@ -96,7 +103,7 @@ final class MacNodeModeCoordinator {
|
||||
return await self.runtime.handleInvoke(req)
|
||||
})
|
||||
} catch {
|
||||
if await self.tryPair(endpoint: endpoint, error: error) {
|
||||
if await self.tryPair(target: target, error: error) {
|
||||
continue
|
||||
}
|
||||
self.logger.error(
|
||||
@@ -173,7 +180,7 @@ final class MacNodeModeCoordinator {
|
||||
return commands
|
||||
}
|
||||
|
||||
private func tryPair(endpoint: NWEndpoint, error: Error) async -> Bool {
|
||||
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
|
||||
let text = error.localizedDescription.uppercased()
|
||||
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
||||
|
||||
@@ -183,9 +190,10 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
let hello = await self.makeHello()
|
||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
endpoint: target.endpoint,
|
||||
hello: hello,
|
||||
silent: shouldSilent,
|
||||
tls: target.tls,
|
||||
onStatus: { [weak self] status in
|
||||
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||
})
|
||||
@@ -203,7 +211,7 @@ final class MacNodeModeCoordinator {
|
||||
"mac-\(InstanceIdentity.instanceId)"
|
||||
}
|
||||
|
||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
guard let port = Self.loopbackBridgePort(),
|
||||
let endpointPort = NWEndpoint.Port(rawValue: port)
|
||||
else {
|
||||
@@ -211,7 +219,10 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
|
||||
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
|
||||
return reachable ? endpoint : nil
|
||||
guard reachable else { return nil }
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
|
||||
static func loopbackBridgePort() -> UInt16? {
|
||||
@@ -304,7 +315,7 @@ final class MacNodeModeCoordinator {
|
||||
})
|
||||
}
|
||||
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
||||
if mode == .remote {
|
||||
do {
|
||||
@@ -316,7 +327,10 @@ final class MacNodeModeCoordinator {
|
||||
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
||||
self.logger.info(
|
||||
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
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)
|
||||
}
|
||||
self.logger.error(
|
||||
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
||||
@@ -349,7 +363,10 @@ final class MacNodeModeCoordinator {
|
||||
"mac node bridge tunnel ready " +
|
||||
"localPort=\(localPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -360,8 +377,8 @@ final class MacNodeModeCoordinator {
|
||||
tunnel.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
if mode == .local, let endpoint = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||
return endpoint
|
||||
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||
return target
|
||||
}
|
||||
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
@@ -381,14 +398,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 -> NWEndpoint? {
|
||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
final class DiscoveryState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var resolved = false
|
||||
var browsers: [NWBrowser] = []
|
||||
var continuation: CheckedContinuation<NWEndpoint?, Never>?
|
||||
var continuation: CheckedContinuation<BridgeTarget?, Never>?
|
||||
|
||||
func finish(_ endpoint: NWEndpoint?) {
|
||||
func finish(_ target: BridgeTarget?) {
|
||||
self.lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if self.resolved { return }
|
||||
@@ -396,7 +413,7 @@ final class MacNodeModeCoordinator {
|
||||
for browser in self.browsers {
|
||||
browser.cancel()
|
||||
}
|
||||
self.continuation?.resume(returning: endpoint)
|
||||
self.continuation?.resume(returning: target)
|
||||
self.continuation = nil
|
||||
}
|
||||
}
|
||||
@@ -422,12 +439,12 @@ final class MacNodeModeCoordinator {
|
||||
return false
|
||||
})
|
||||
{
|
||||
state.finish(match.endpoint)
|
||||
state.finish(Self.targetFromResult(match))
|
||||
return
|
||||
}
|
||||
|
||||
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
||||
state.finish(result.endpoint)
|
||||
state.finish(Self.targetFromResult(result))
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { browserState in
|
||||
@@ -445,6 +462,72 @@ 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,14 +455,7 @@ actor MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
@@ -571,6 +564,35 @@ 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
|
||||
|
||||
@@ -28,8 +28,12 @@ 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: Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -2732,12 +2732,29 @@ 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"
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
- Allowlisted runs ignore `PATH` overrides; other env vars are merged with the app’s environment.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then 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 |
|
||||
| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) |
|
||||
| `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: Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
|
||||
## Use Brave (or another Chromium-based browser)
|
||||
|
||||
|
||||
@@ -75,7 +75,10 @@ export function createClawdbotTools(options?: {
|
||||
}),
|
||||
createCanvasTool(),
|
||||
createNodesTool(),
|
||||
createCronTool(),
|
||||
createCronTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
createMessageTool({
|
||||
agentAccountId: options?.agentAccountId,
|
||||
config: options?.config,
|
||||
|
||||
@@ -173,7 +173,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
browser: "Control web browser",
|
||||
canvas: "Present/eval/snapshot the Canvas",
|
||||
nodes: "List/describe/notify/camera/screen on paired nodes",
|
||||
cron: "Manage cron jobs and wake events (use for reminders)",
|
||||
cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
|
||||
message: "Send messages and channel actions",
|
||||
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
|
||||
agents_list: "List agent ids allowed for sessions_spawn",
|
||||
@@ -319,7 +319,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
"- browser: control clawd's dedicated browser",
|
||||
"- canvas: present/eval/snapshot the Canvas",
|
||||
"- nodes: list/describe/notify/camera/screen on paired nodes",
|
||||
"- cron: manage cron jobs and wake events (use for reminders)",
|
||||
"- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
|
||||
"- sessions_list: list sessions",
|
||||
"- sessions_history: fetch session history",
|
||||
"- sessions_send: send to another session",
|
||||
|
||||
@@ -84,4 +84,47 @@ describe("cron tool", () => {
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
it("adds recent context for systemEvent reminders when session key is available", async () => {
|
||||
callGatewayMock
|
||||
.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "Discussed Q2 budget" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "We agreed to review on Tuesday." }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Remind me about the thing at 2pm" }] },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const tool = createCronTool({ agentSessionKey: "main", config: {} });
|
||||
await tool.execute("call3", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { atMs: 123 },
|
||||
payload: { text: "Reminder: the thing." },
|
||||
},
|
||||
});
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(2);
|
||||
const historyCall = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
expect(historyCall.method).toBe("chat.history");
|
||||
|
||||
const cronCall = callGatewayMock.mock.calls[1]?.[0] as {
|
||||
method?: string;
|
||||
params?: { payload?: { text?: string } };
|
||||
};
|
||||
expect(cronCall.method).toBe("cron.add");
|
||||
const text = cronCall.params?.payload?.text ?? "";
|
||||
expect(text).toContain("Recent context:");
|
||||
expect(text).toContain("User: Discussed Q2 budget");
|
||||
expect(text).toContain("Assistant: We agreed to review on Tuesday.");
|
||||
expect(text).toContain("User: Remind me about the thing at 2pm");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
||||
import { loadConfig, type ClawdbotConfig } from "../../config/config.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||
|
||||
// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
|
||||
// instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas
|
||||
@@ -13,6 +16,11 @@ const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs"
|
||||
|
||||
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
|
||||
|
||||
const REMINDER_CONTEXT_MESSAGES = 3;
|
||||
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
|
||||
const REMINDER_CONTEXT_TOTAL_MAX = 700;
|
||||
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
|
||||
|
||||
// Flattened schema: runtime validates per-action requirements.
|
||||
const CronToolSchema = Type.Object({
|
||||
action: stringEnum(CRON_ACTIONS),
|
||||
@@ -28,7 +36,92 @@ const CronToolSchema = Type.Object({
|
||||
mode: optionalStringEnum(CRON_WAKE_MODES),
|
||||
});
|
||||
|
||||
export function createCronTool(): AnyAgentTool {
|
||||
type CronToolOptions = {
|
||||
agentSessionKey?: string;
|
||||
config?: ClawdbotConfig;
|
||||
};
|
||||
|
||||
type ChatMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
function stripExistingContext(text: string) {
|
||||
const index = text.indexOf(REMINDER_CONTEXT_MARKER);
|
||||
if (index === -1) return text;
|
||||
return text.slice(0, index).trim();
|
||||
}
|
||||
|
||||
function truncateText(input: string, maxLen: number) {
|
||||
if (input.length <= maxLen) return input;
|
||||
const truncated = truncateUtf16Safe(input, Math.max(0, maxLen - 3)).trimEnd();
|
||||
return `${truncated}...`;
|
||||
}
|
||||
|
||||
function normalizeContextText(raw: string) {
|
||||
return raw.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
if (role !== "user" && role !== "assistant") return null;
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
const normalized = normalizeContextText(content);
|
||||
return normalized ? { role, text: normalized } : null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
const chunks: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if ((block as { type?: unknown }).type !== "text") continue;
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
chunks.push(text);
|
||||
}
|
||||
}
|
||||
const joined = normalizeContextText(chunks.join(" "));
|
||||
return joined ? { role, text: joined } : null;
|
||||
}
|
||||
|
||||
async function buildReminderContextLines(params: {
|
||||
agentSessionKey?: string;
|
||||
gatewayOpts: GatewayCallOptions;
|
||||
config?: ClawdbotConfig;
|
||||
}) {
|
||||
const sessionKey = params.agentSessionKey?.trim();
|
||||
if (!sessionKey) return [];
|
||||
const cfg = params.config ?? loadConfig();
|
||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||
const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
|
||||
try {
|
||||
const res = (await callGatewayTool("chat.history", params.gatewayOpts, {
|
||||
sessionKey: resolvedKey,
|
||||
limit: 12,
|
||||
})) as { messages?: unknown[] };
|
||||
const messages = Array.isArray(res?.messages) ? res.messages : [];
|
||||
const parsed = messages
|
||||
.map((msg) => extractMessageText(msg as ChatMessage))
|
||||
.filter((msg): msg is { role: string; text: string } => Boolean(msg));
|
||||
const recent = parsed.slice(-REMINDER_CONTEXT_MESSAGES);
|
||||
if (recent.length === 0) return [];
|
||||
const lines: string[] = [];
|
||||
let total = 0;
|
||||
for (const entry of recent) {
|
||||
const label = entry.role === "user" ? "User" : "Assistant";
|
||||
const text = truncateText(entry.text, REMINDER_CONTEXT_PER_MESSAGE_MAX);
|
||||
const line = `- ${label}: ${text}`;
|
||||
total += line.length;
|
||||
if (total > REMINDER_CONTEXT_TOTAL_MAX) break;
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
|
||||
return {
|
||||
label: "Cron",
|
||||
name: "cron",
|
||||
@@ -58,6 +151,25 @@ export function createCronTool(): AnyAgentTool {
|
||||
throw new Error("job required");
|
||||
}
|
||||
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
||||
if (
|
||||
job &&
|
||||
typeof job === "object" &&
|
||||
"payload" in job &&
|
||||
(job as { payload?: { kind?: string; text?: string } }).payload?.kind === "systemEvent"
|
||||
) {
|
||||
const payload = (job as { payload: { kind: string; text: string } }).payload;
|
||||
if (typeof payload.text === "string" && payload.text.trim()) {
|
||||
const contextLines = await buildReminderContextLines({
|
||||
agentSessionKey: opts?.agentSessionKey,
|
||||
gatewayOpts,
|
||||
config: opts?.config,
|
||||
});
|
||||
if (contextLines.length > 0) {
|
||||
const baseText = stripExistingContext(payload.text);
|
||||
payload.text = `${baseText}${REMINDER_CONTEXT_MARKER}${contextLines.join("\n")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return jsonResult(await callGatewayTool("cron.add", gatewayOpts, job));
|
||||
}
|
||||
case "update": {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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,3 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -9,6 +10,83 @@ 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);
|
||||
@@ -17,6 +95,223 @@ 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;
|
||||
@@ -179,6 +474,9 @@ 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();
|
||||
|
||||
@@ -374,7 +374,7 @@ describe("doctor command", () => {
|
||||
|
||||
expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
it("skips gateway restarts in non-interactive mode", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
|
||||
@@ -372,7 +372,7 @@ describe("doctor command", () => {
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
it("warns when extra workspace directories exist", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
|
||||
@@ -343,7 +343,7 @@ describe("doctor command", () => {
|
||||
const stateNote = note.mock.calls.find((call) => call[1] === "State integrity");
|
||||
expect(stateNote).toBeTruthy();
|
||||
expect(String(stateNote?.[0])).toContain("CRITICAL");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
it("warns about opencode provider overrides", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
|
||||
@@ -42,5 +42,5 @@ describe("sandbox explain command", () => {
|
||||
expect(Array.isArray(parsed.fixIt)).toBe(true);
|
||||
expect(parsed.fixIt).toContain("agents.defaults.sandbox.mode=off");
|
||||
expect(parsed.fixIt).toContain("tools.sandbox.tools.deny");
|
||||
}, 15_000);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,20 @@ 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,6 +171,15 @@ 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
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -71,7 +72,7 @@ export async function startGatewayBridgeRuntime(params: {
|
||||
}): Promise<GatewayBridgeRuntime> {
|
||||
const wideAreaDiscoveryEnabled = params.cfg.discovery?.wideArea?.enabled === true;
|
||||
|
||||
const bridgeEnabled = (() => {
|
||||
let bridgeEnabled = (() => {
|
||||
if (params.cfg.bridge?.enabled !== undefined) return params.cfg.bridge.enabled === true;
|
||||
return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0";
|
||||
})();
|
||||
@@ -111,6 +112,14 @@ 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);
|
||||
@@ -197,6 +206,7 @@ export async function startGatewayBridgeRuntime(params: {
|
||||
bridgeEnabled,
|
||||
bridgePort,
|
||||
bridgeHost,
|
||||
bridgeTls: bridgeTls.enabled ? bridgeTls : undefined,
|
||||
machineDisplayName: params.machineDisplayName,
|
||||
canvasHostPort: canvasHostPortForBridge,
|
||||
canvasHostHost: canvasHostHostForBridge,
|
||||
@@ -212,6 +222,9 @@ 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,6 +11,7 @@ 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 };
|
||||
@@ -27,6 +28,8 @@ 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(),
|
||||
@@ -51,6 +54,8 @@ 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,5 +1,6 @@
|
||||
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";
|
||||
@@ -23,6 +24,7 @@ export async function startGatewayNodeBridge(params: {
|
||||
bridgeEnabled: boolean;
|
||||
bridgePort: number;
|
||||
bridgeHost: string | null;
|
||||
bridgeTls?: BridgeTlsRuntime;
|
||||
machineDisplayName: string;
|
||||
canvasHostPort?: number;
|
||||
canvasHostHost?: string;
|
||||
@@ -111,6 +113,7 @@ 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,
|
||||
@@ -158,7 +161,8 @@ export async function startGatewayNodeBridge(params: {
|
||||
},
|
||||
});
|
||||
if (started.port > 0) {
|
||||
params.logBridge.info(`listening on tcp://${params.bridgeHost}:${started.port} (node)`);
|
||||
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
|
||||
params.logBridge.info(`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`);
|
||||
return { bridge: started, nodePresenceTimers };
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,6 +12,8 @@ export type GatewayBonjourBeacon = {
|
||||
bridgePort?: number;
|
||||
gatewayPort?: number;
|
||||
sshPort?: number;
|
||||
bridgeTls?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
cliPath?: string;
|
||||
txt?: Record<string, string>;
|
||||
};
|
||||
@@ -206,6 +208,11 @@ 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,6 +16,8 @@ export type GatewayBonjourAdvertiseOpts = {
|
||||
sshPort?: number;
|
||||
bridgePort?: number;
|
||||
canvasPort?: number;
|
||||
bridgeTlsEnabled?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
tailnetDns?: string;
|
||||
cliPath?: string;
|
||||
};
|
||||
@@ -107,6 +109,12 @@ 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,6 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -47,7 +48,8 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise
|
||||
const loopbackHost = "127.0.0.1";
|
||||
|
||||
const listeners: Array<{ host: string; server: net.Server }> = [];
|
||||
const primary = net.createServer(onConnection);
|
||||
const createServer = () => (opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection));
|
||||
const primary = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
primary.once("error", onError);
|
||||
@@ -65,7 +67,7 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise
|
||||
const port = typeof address === "object" && address ? address.port : opts.port;
|
||||
|
||||
if (shouldAlsoListenOnLoopback(opts.host)) {
|
||||
const loopback = net.createServer(onConnection);
|
||||
const loopback = createServer();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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,3 +1,5 @@
|
||||
import type { TlsOptions } from "node:tls";
|
||||
|
||||
import type { NodePairingPendingRequest } from "../../node-pairing.js";
|
||||
|
||||
export type BridgeHelloFrame = {
|
||||
@@ -122,6 +124,7 @@ export type NodeBridgeClientInfo = {
|
||||
export type NodeBridgeServerOpts = {
|
||||
host: string;
|
||||
port: number; // 0 = ephemeral
|
||||
tls?: TlsOptions;
|
||||
pairingBaseDir?: string;
|
||||
canvasHostPort?: number;
|
||||
canvasHostHost?: string;
|
||||
|
||||
@@ -73,7 +73,17 @@ 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,6 +71,8 @@ export type WideAreaBridgeZoneOpts = {
|
||||
displayName: string;
|
||||
tailnetIPv4: string;
|
||||
tailnetIPv6?: string;
|
||||
bridgeTlsEnabled?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
instanceLabel?: string;
|
||||
hostLabel?: string;
|
||||
tailnetDns?: string;
|
||||
@@ -91,6 +93,12 @@ 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