Compare commits

..
Author SHA1 Message Date
Peter SteinbergerandWes f975ebf91b fix: keep mac gateway attached (#982) (thanks @wes-davis)
Co-authored-by: Wes <wesleydavis@me.com>
2026-01-16 07:36:27 +00:00
Peter Steinberger f2db894685 Merge pull request #992 from tyler6204/fix/tool-typing-race-condition
fix: send text between tool calls to channel immediately
2026-01-16 07:26:10 +00:00
Peter Steinberger 4f03283126 docs: add npm 1password publish steps 2026-01-16 07:08:07 +00:00
Peter Steinberger ed7dec0975 feat: use dropdowns for access controls 2026-01-16 07:00:05 +00:00
Peter Steinberger fbb3da506f docs: clarify onboarding + sessions + heartbeats 2026-01-16 06:57:54 +00:00
Peter Steinberger dfa6c5c2b3 fix(google): scrub tool schemas for gemini 2026-01-16 06:57:54 +00:00
Peter Steinberger 028eed5fe8 fix(browser): surface detection details and docs 2026-01-16 06:57:54 +00:00
Peter Steinberger 2b16a87f04 feat: add config get/set/unset helpers 2026-01-16 06:57:54 +00:00
Peter Steinberger 731049936d Merge pull request #870 from JDIVE/fix/discord-actions-schema
fix(config): allow discord action flags in schema
2026-01-16 06:50:50 +00:00
Peter Steinberger 5a5b058ba0 fix: update discord action defaults (#870) (thanks @JDIVE) 2026-01-16 06:50:27 +00:00
Jamie OpenshawandClaude 72f28be648 fix(config): allow discord action flags in schema
Ensure discord action flags survive config validation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-16 06:48:25 +00:00
Peter Steinberger ff645524d8 docs: move troubleshooting items from faq 2026-01-16 06:35:13 +00:00
Peter Steinberger c534390bc0 chore: update a2ui bundle 2026-01-16 06:35:05 +00:00
Peter Steinberger c5003e5441 fix: clear lint blockers 2026-01-16 06:35:05 +00:00
Peter Steinberger 0b3ebb0c63 test: stabilize slow and flaky tests 2026-01-16 06:24:58 +00:00
Peter Steinberger 23981496f9 fix: resolve bridge warnings 2026-01-16 06:15:45 +00:00
Peter Steinberger f2e425dc2b Merge pull request #995 from roshanasingh4/fix/systemd-execstart-whitespace
Fix systemd ExecStart whitespace parsing
2026-01-16 06:03:35 +00:00
Peter Steinberger e48d68bbc7 Merge pull request #993 from cpojer/reminder-improvement
Improve reminder text generation.
2026-01-16 06:03:04 +00:00
Peter Steinberger 842fc8d08b fix: repair launchd status parsing 2026-01-16 06:01:28 +00:00
Peter Steinberger 54ec14262b feat: add plugin update tracking 2026-01-16 05:55:05 +00:00
Peter Steinberger d0c70178e0 docs: fix FAQ reset anchor 2026-01-16 05:54:56 +00:00
Peter Steinberger 3bc9c330eb fix: unblock mac node bridge TLS 2026-01-16 05:50:55 +00:00
Peter Steinberger b7fcc8584f style: apply swiftformat fixes 2026-01-16 05:42:18 +00:00
Peter Steinberger 2b8ce3f06b feat: add json output for daemon lifecycle 2026-01-16 05:40:47 +00:00
Peter Steinberger 41d44021e7 feat(browser): prefer default chromium browser 2026-01-16 05:37:53 +00:00
Peter Steinberger 1ab1e312b2 feat: add TLS for node bridge 2026-01-16 05:28:40 +00:00
Roshan Singh fa9aafce83 Fix systemd ExecStart parsing whitespace 2026-01-16 05:25:13 +00:00
Tyler Yust 0d5dec4c66 fix: handle async tool start handler rejections
Add .catch() to handleToolExecutionStart call to prevent unhandled
promise rejections when onAgentEvent or typing signaling fails.
2026-01-15 21:10:52 -08:00
cpojer b2d5889f6e Improve reminder text generation. 2026-01-16 14:03:17 +09:00
Tyler Yust 2ee71e4154 fix: send text between tool calls to channel immediately
Previously, when block streaming was disabled (the default), text generated
between tool calls would only appear after all tools completed. This was
because onBlockReply wasn't passed to the subscription when block streaming
was off, so flushBlockReplyBuffer() before tool execution did nothing.

Now onBlockReply is always passed, and when block streaming is disabled,
block replies are sent directly during tool flush. Directly sent payloads
are tracked to avoid duplicates in final payloads.

Also fixes a race condition where tool summaries could be emitted before
the typing indicator started by awaiting onAgentEvent in tool handlers.
2026-01-15 20:55:52 -08:00
Peter Steinberger 1656f491fd fix: normalize pairing aliases and webhook guard (#991) (thanks @longmaba) 2026-01-16 04:55:16 +00:00
Peter Steinberger a057b3c9e8 Merge pull request #991 from longmaba/fix/zalo-pairing-and-webhook
fix(zalo): fix pairing channel detection and webhook payload format
2026-01-16 04:54:08 +00:00
LongandAmp c0c9742e44 fix(zalo): fix pairing channel detection and webhook payload format
Amp-Thread-ID: https://ampcode.com/threads/T-019bc4e0-fcb1-77be-b0b5-0d498f0c7197
Co-authored-by: Amp <amp@ampcode.com>
2026-01-16 10:43:14 +07:00
122 changed files with 18930 additions and 18382 deletions

No files matched your search

+8
View File
@@ -116,6 +116,14 @@
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
+3
View File
@@ -26,6 +26,8 @@
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- Fix: macOS gateway stays attached across restarts (no port flapping) and status probes pick the right auth. (#982) — thanks @wes-davis.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
@@ -35,6 +37,7 @@
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
+1 -1
View File
@@ -119,7 +119,7 @@ dependencies {
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
testImplementation("org.robolectric:robolectric:4.16")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
}
tasks.withType<Test>().configureEach {
@@ -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,
)
}
}
@@ -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,60 +199,76 @@ 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)
}
while (scope.isActive) {
val line = reader.readLine() ?: break
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) {
"event" -> {
val event = frame["event"].asStringOrNull() ?: return@withContext
val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload)
private suspend 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
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
when (frame["type"].asStringOrNull()) {
"event" -> {
val event = frame["event"].asStringOrNull() ?: continue
val payload = frame["payloadJSON"].asStringOrNull()
onEvent(event, payload)
}
"ping" -> {
val id = frame["id"].asStringOrNull() ?: ""
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
@@ -291,20 +314,20 @@ class BridgeSession(
},
)
}
"invoke-res" -> {
// gateway->node only (ignore)
}
"invoke-res" -> {
// gateway->node only (ignore)
}
}
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
}
} finally {
currentConnection = null
for ((_, waiter) in pending) {
waiter.cancel()
}
pending.clear()
conn.closeQuietly()
}
}
private fun buildHelloJson(hello: Hello): JsonObject =
buildJsonObject {
@@ -0,0 +1,81 @@
package com.clawdbot.android.bridge
import android.annotation.SuppressLint
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()
@SuppressLint("CustomX509TrustManager")
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' }
}
+39 -1
View File
@@ -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"
}
}
+41 -2
View File
@@ -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: [
+66
View File
@@ -0,0 +1,66 @@
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
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
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(trustRef, 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 }
}
+20 -9
View File
@@ -190,14 +190,7 @@ actor CameraController {
}
func listDevices() -> [CameraDeviceInfo] {
let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices.map { device in
return Self.discoverVideoDevices().map { device in
CameraDeviceInfo(
id: device.uniqueID,
name: device.localizedName,
@@ -232,7 +225,7 @@ actor CameraController {
deviceId: String?) -> AVCaptureDevice?
{
if let deviceId, !deviceId.isEmpty {
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
return match
}
}
@@ -252,6 +245,24 @@ actor CameraController {
}
}
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
let types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInUltraWideCamera,
.builtInTelephotoCamera,
.builtInDualCamera,
.builtInDualWideCamera,
.builtInTripleCamera,
.builtInTrueDepthCamera,
.builtInLiDARDepthCamera,
]
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices
}
nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9
return min(1.0, max(0.05, q))
@@ -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 {
@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
{
{ sample, type, error in
let sampleBox = UncheckedSendableBox(value: sample)
// ReplayKit can call the capture handler on a background queue.
// Serialize writes to avoid queue asserts.
recordQueue.async {
let sample = sampleBox.value
if let error {
state.withLock { state in
if state.handlerError == nil { state.handlerError = error }
+49 -1
View File
@@ -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")
@@ -178,7 +178,7 @@ enum GatewayEnvironment {
let port = self.gatewayPort()
if let gatewayBin {
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -186,7 +186,7 @@ enum GatewayEnvironment {
case let .success(resolvedRuntime) = runtime
{
let bind = self.preferredGatewayBind() ?? "loopback"
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind]
return GatewayCommandResolution(status: status, command: cmd)
}
@@ -2,77 +2,16 @@ import Foundation
enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private enum GatewayProgramArgumentsError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case let .message(message):
message
}
}
}
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
}
private static var legacyPlistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
}
private static func gatewayProgramArguments(
port: Int,
bind: String) -> Result<[String], GatewayProgramArgumentsError>
{
let projectRoot = CommandResolver.projectRoot()
#if DEBUG
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
switch CommandResolver.runtimeResolution() {
case let .success(runtime):
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
case .failure:
break
}
}
#endif
let searchPaths = CommandResolver.preferredPaths()
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
}
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
{
let cmd = CommandResolver.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: "gateway-daemon",
extraArgs: ["--port", "\(port)", "--bind", bind])
return .success(cmd)
}
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
}
static func isLoaded() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0
guard let loaded = await self.readDaemonLoaded() else { return false }
return loaded
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
@@ -81,255 +20,44 @@ enum GatewayLaunchAgentManager {
self.logger.info("launchd enable skipped (disable marker set)")
return nil
}
if enabled {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let desiredBind = self.preferredGatewayBind() ?? "loopback"
let desiredToken = self.preferredGatewayToken()
let desiredPassword = self.preferredGatewayPassword()
let desiredConfig = DesiredConfig(
port: port,
bind: desiredBind,
token: desiredToken,
password: desiredPassword)
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
guard case let .success(programArguments) = programArgumentsResult else {
if case let .failure(error) = programArgumentsResult {
let message = error.localizedDescription
self.logger.error("launchd enable failed: \(message)")
return message
}
return "Failed to resolve gateway command."
}
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
let loaded = await self.isLoaded()
if loaded {
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
self.logger.info("launchd job already loaded with desired config; skipping bootout")
await self.ensureEnabled()
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
}
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(programArguments: programArguments)
await self.ensureEnabled()
if loaded {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)")
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
await self.ensureEnabled()
return nil
self.logger.info("launchd enable requested via CLI port=\(port)")
return await self.runDaemonCommand([
"install",
"--force",
"--port",
"\(port)",
"--runtime",
"node",
])
}
self.logger.info("launchd disable requested")
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL)
return nil
self.logger.info("launchd disable requested via CLI")
return await self.runDaemonCommand(["uninstall"])
}
static func kickstart() async {
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
private static func writePlist(programArguments: [String]) {
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
let token = self.preferredGatewayToken()
let password = self.preferredGatewayPassword()
var envEntries = """
<key>PATH</key>
<string>\(preferredPath)</string>
"""
if let token {
let escapedToken = self.escapePlistValue(token)
envEntries += """
<key>CLAWDBOT_GATEWAY_TOKEN</key>
<string>\(escapedToken)</string>
"""
}
if let password {
let escapedPassword = self.escapePlistValue(password)
envEntries += """
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
<string>\(escapedPassword)</string>
"""
}
let argsXml = programArguments
.map { "<string>\(self.escapePlistValue($0))</string>" }
.joined(separator: "\n ")
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>\(gatewayLaunchdLabel)</string>
<key>ProgramArguments</key>
<array>
\(argsXml)
</array>
<key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
\(envEntries)
</dict>
<key>StandardOutPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
<key>StandardErrorPath</key>
<string>\(LogLocator.launchdGatewayLogPath)</string>
</dict>
</plist>
"""
do {
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
} catch {
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
}
}
private static func preferredGatewayBind() -> String? {
if CommandResolver.connectionModeIsRemote() {
return nil
}
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
return nil
}
private static func preferredGatewayToken() -> String? {
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
private static func preferredGatewayPassword() -> String? {
// First check environment variable
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
// Then check config file (gateway.auth.password)
let root = ClawdbotConfigFile.loadDict()
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let password = auth["password"] as? String
{
return password.trimmingCharacters(in: .whitespacesAndNewlines)
}
return nil
}
private static func escapePlistValue(_ raw: String) -> String {
raw
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
private struct DesiredConfig: Equatable {
let port: Int
let bind: String
let token: String?
let password: String?
}
private struct InstalledConfig: Equatable {
let port: Int?
let bind: String?
let token: String?
let password: String?
func matches(_ desired: DesiredConfig) -> Bool {
guard self.port == desired.port else { return false }
guard (self.bind ?? "loopback") == desired.bind else { return false }
guard self.token == desired.token else { return false }
guard self.password == desired.password else { return false }
return true
}
}
private static func readPlistConfig() -> InstalledConfig? {
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
return InstalledConfig(
port: snapshot.port,
bind: snapshot.bind,
token: snapshot.token,
password: snapshot.password)
_ = await self.runDaemonCommand(["restart"], timeout: 20)
}
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL)
}
private static func ensureEnabled() async {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
self.logger.warning("launchd enable failed")
} else {
self.logger.warning("launchd enable failed: \(msg)")
static func launchdGatewayLogPath() -> String {
let snapshot = self.launchdConfigSnapshot()
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!stdout.isEmpty
{
return stdout
}
}
private static func ensureDisabled() async {
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
self.logger.warning("launchd disable failed")
} else {
self.logger.warning("launchd disable failed: \(msg)")
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!stderr.isEmpty
{
return stderr
}
return LogLocator.launchdGatewayLogPath
}
}
@@ -339,20 +67,102 @@ extension GatewayLaunchAgentManager {
.appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path)
}
}
#if DEBUG
extension GatewayLaunchAgentManager {
static func _testPreferredGatewayBind() -> String? {
self.preferredGatewayBind()
private static func readDaemonLoaded() async -> Bool? {
let result = await self.runDaemonCommandResult(
["status", "--json", "--no-probe"],
timeout: 15,
quiet: true)
guard result.success, let payload = result.payload else { return nil }
guard
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
let service = json["service"] as? [String: Any],
let loaded = service["loaded"] as? Bool
else {
return nil
}
return loaded
}
static func _testPreferredGatewayToken() -> String? {
self.preferredGatewayToken()
private struct CommandResult {
let success: Bool
let payload: Data?
let message: String?
}
static func _testEscapePlistValue(_ raw: String) -> String {
self.escapePlistValue(raw)
private struct ParsedDaemonJson {
let text: String
let object: [String: Any]
}
private static func runDaemonCommand(
_ args: [String],
timeout: Double = 15,
quiet: Bool = false) async -> String?
{
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
if result.success { return nil }
return result.message ?? "Gateway daemon command failed"
}
private static func runDaemonCommandResult(
_ args: [String],
timeout: Double,
quiet: Bool) async -> CommandResult
{
let command = CommandResolver.clawdbotCommand(
subcommand: "daemon",
extraArgs: self.withJsonFlag(args))
var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
let ok = parsed?.object["ok"] as? Bool
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
let payload = parsed?.text.data(using: .utf8)
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
let success = ok ?? response.success
if success {
return CommandResult(success: true, payload: payload, message: nil)
}
if quiet {
return CommandResult(success: false, payload: payload, message: message)
}
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
?? "Gateway daemon command failed (\(exit))"
self.logger.error("\(fullMessage, privacy: .public)")
return CommandResult(success: false, payload: payload, message: detail)
}
private static func withJsonFlag(_ args: [String]) -> [String] {
if args.contains("--json") { return args }
return args + ["--json"]
}
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = trimmed.firstIndex(of: "{"),
let end = trimmed.lastIndex(of: "}")
else {
return nil
}
let jsonText = String(trimmed[start...end])
guard let data = jsonText.data(using: .utf8) else { return nil }
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
return ParsedDaemonJson(text: jsonText, object: object)
}
private static func summarize(_ text: String) -> String? {
let lines = text
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
}
}
#endif
@@ -87,6 +87,14 @@ final class GatewayProcessManager {
self.status = .stopped
return
}
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
switch self.status {
case .starting, .running, .attachedExisting:
return
case .stopped, .failed:
break
}
self.status = .starting
self.logger.debug("gateway start requested")
@@ -140,7 +148,7 @@ final class GatewayProcessManager {
func refreshLog() {
guard self.logRefreshTask == nil else { return }
let path = LogLocator.launchdGatewayLogPath
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
let limit = self.logLimit
self.logRefreshTask = Task { [weak self] in
let log = await Task.detached(priority: .utility) {
@@ -354,7 +362,7 @@ final class GatewayProcessManager {
func clearLog() {
self.log = ""
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
self.logger.debug("gateway log cleared")
}
@@ -92,10 +92,13 @@ struct GeneralSettings: View {
Text(policy.title).tag(policy)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.pickerStyle(.menu)
Text("""
Controls remote command execution on this Mac when it is paired as a node. "Always Ask" prompts on each command; "Always Allow" runs without prompts; "Never" disables `system.run`.
Controls remote command execution on this Mac when it is paired as a node. \
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
"Never" disables `system.run`.
""")
.font(.footnote)
.foregroundStyle(.tertiary)
@@ -111,7 +114,8 @@ struct GeneralSettings: View {
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
@@ -31,6 +31,8 @@ enum Launchctl {
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
let programArguments: [String]
let environment: [String: String]
let stdoutPath: String?
let stderrPath: String?
let port: Int?
let bind: String?
@@ -53,6 +55,10 @@ enum LaunchAgentPlist {
guard let root = rootAny as? [String: Any] else { return nil }
let programArguments = root["ProgramArguments"] as? [String] ?? []
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
let stdoutPath = (root["StandardOutPath"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let stderrPath = (root["StandardErrorPath"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let port = Self.extractFlagInt(programArguments, flag: "--port")
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
@@ -60,6 +66,8 @@ enum LaunchAgentPlist {
return LaunchAgentPlistSnapshot(
programArguments: programArguments,
environment: env,
stdoutPath: stdoutPath,
stderrPath: stderrPath,
port: port,
bind: bind,
token: token,
@@ -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)
@@ -420,7 +460,7 @@ actor MacNodeBridgeSession {
do {
try await self.send(response)
} catch {
await self.logInvokeSendFailure(error)
self.logInvokeSendFailure(error)
}
}
@@ -0,0 +1,74 @@
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 = self.keyPrefix + stableID
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
return raw?.isEmpty == false ? raw : nil
}
static func saveFingerprint(_ value: String, stableID: String) {
let key = self.keyPrefix + stableID
self.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
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
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(trustRef, 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(\.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 nonisolated 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 nonisolated 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 nonisolated 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 nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? nil : raw
}
private nonisolated 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 self.blockedEnvKeys.contains(upper) { continue }
if self.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
@@ -351,10 +351,14 @@ actor PortGuardian {
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
return false
case .local:
if !cmd.contains("clawdbot") { return false }
if full.contains("gateway-daemon") { return true }
// The gateway may listen as `clawdbot` or via its runtime (e.g. node).
let hasGatewayArgs =
full.contains(" gateway ") &&
full.contains(" --port \(port)")
if hasGatewayArgs { return true }
// If args are unavailable, treat a clawdbot listener as expected.
return full == cmd
if cmd.contains("clawdbot"), full == cmd { return true }
return false
case .unconfigured:
return false
}
@@ -10,11 +10,11 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
var title: String {
switch self {
case .never:
return "Never"
"Never"
case .ask:
return "Always Ask"
"Always Ask"
case .always:
return "Always Allow"
"Always Allow"
}
}
@@ -75,13 +75,13 @@ enum SystemRunAllowlist {
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
let key = key(for: argv)
return load(from: defaults).contains(key)
return self.load(from: defaults).contains(key)
}
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
let key = key(for: argv)
guard !key.isEmpty else { return }
var allowlist = load(from: defaults)
var allowlist = self.load(from: defaults)
if allowlist.insert(key).inserted {
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
}
@@ -7,6 +7,8 @@ import Testing
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: "launchd-token",
@@ -31,6 +33,8 @@ import Testing
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: "launchd-token",
@@ -48,6 +52,8 @@ import Testing
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
stdoutPath: nil,
stderrPath: nil,
port: nil,
bind: nil,
token: nil,
@@ -7,7 +7,7 @@ import Testing
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789", "--bind", "loopback"],
"EnvironmentVariables": [
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
@@ -28,7 +28,7 @@ import Testing
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789"],
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic])
@@ -112,20 +112,6 @@ struct LowCoverageHelperTests {
_ = PresenceReporter._testPrimaryIPv4Address()
}
@Test func gatewayLaunchAgentHelpers() async throws {
await TestIsolation.withEnvValues(
[
"CLAWDBOT_GATEWAY_BIND": "Lan",
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
]) {
#expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
#expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
#expect(
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
"a&amp;b&lt;c&gt;&quot;&apos;")
}
}
@Test func portGuardianParsesListenersAndBuildsReports() {
let output = """
p123
+8 -1
View File
@@ -227,6 +227,8 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
actions: {
reactions: true,
stickers: true,
emojiUploads: true,
stickerUploads: true,
polls: true,
permissions: true,
messages: true,
@@ -237,6 +239,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
roleInfo: true,
roles: false,
channelInfo: true,
channels: true,
voiceStatus: true,
events: true,
moderation: false
@@ -304,8 +307,9 @@ ack reaction after the bot replies.
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
- `reactions` (covers react + read reactions)
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
- `channels` (create/edit/delete channels + categories + permissions)
- `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
@@ -321,6 +325,8 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
| --- | --- | --- |
| reactions | enabled | React + list reactions + emojiList |
| stickers | enabled | Send stickers |
| emojiUploads | enabled | Upload emojis |
| stickerUploads | enabled | Upload stickers |
| polls | enabled | Create polls |
| permissions | enabled | Channel permission snapshot |
| messages | enabled | Read/send/edit/delete |
@@ -330,6 +336,7 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
| memberInfo | enabled | Member info |
| roleInfo | enabled | Role list |
| channelInfo | enabled | Channel info + list |
| channels | enabled | Channel/category management |
| voiceStatus | enabled | Voice state lookup |
| events | enabled | List/create scheduled events |
| roles | disabled | Role add/remove |
+41
View File
@@ -0,0 +1,41 @@
---
summary: "CLI reference for `clawdbot config` (get/set/unset config values)"
read_when:
- You want to read or edit config non-interactively
---
# `clawdbot config`
Config helpers: get/set/unset values by path. Run without a subcommand to open
the configure wizard (same as `clawdbot configure`).
## Examples
```bash
clawdbot config get browser.executablePath
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
clawdbot config set agents.defaults.heartbeat.every "2h"
clawdbot config unset tools.web.search.apiKey
```
## Paths
Paths use dot or bracket notation:
```bash
clawdbot config get agents.defaults.workspace
clawdbot config get agents.list[0].id
```
## Values
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
Use `--json` to require JSON5 parsing.
```bash
clawdbot config set agents.defaults.heartbeat.every "0m"
clawdbot config set gateway.port 19001 --json
clawdbot config set channels.whatsapp.groups '["*"]' --json
```
Restart the gateway after edits.
+6 -2
View File
@@ -1,15 +1,19 @@
---
summary: "CLI reference for `clawdbot configure` / `clawdbot config` (interactive configuration prompts)"
summary: "CLI reference for `clawdbot configure` (interactive configuration prompts)"
read_when:
- You want to tweak credentials, devices, or agent defaults interactively
---
# `clawdbot configure` (alias: `config`)
# `clawdbot configure`
Interactive prompt to set up credentials, devices, and agent defaults.
Tip: `clawdbot config` without a subcommand opens the same wizard. Use
`clawdbot config get|set|unset` for non-interactive edits.
Related:
- Gateway configuration reference: [Configuration](/gateway/configuration)
- Config CLI: [Config](/cli/config)
Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
+3
View File
@@ -15,3 +15,6 @@ Related:
Tip: run `clawdbot daemon --help` for platform-specific flags.
Notes:
- `daemon status` supports `--json` for scripting.
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
+19 -4
View File
@@ -13,7 +13,8 @@ This page describes the current CLI behavior. If commands change, update this do
- [`setup`](/cli/setup)
- [`onboard`](/cli/onboard)
- [`configure`](/cli/configure) (alias: `config`)
- [`configure`](/cli/configure)
- [`config`](/cli/config)
- [`doctor`](/cli/doctor)
- [`dashboard`](/cli/dashboard)
- [`reset`](/cli/reset)
@@ -83,7 +84,11 @@ Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”).
clawdbot [--dev] [--profile <name>] <command>
setup
onboard
configure (alias: config)
configure
config
get
set
unset
doctor
security
audit
@@ -310,9 +315,18 @@ Options:
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
- `--json`
### `configure` / `config`
### `configure`
Interactive configuration wizard (models, channels, skills, gateway).
### `config`
Non-interactive config helpers (get/set/unset). Running `clawdbot config` with no
subcommand launches the wizard.
Subcommands:
- `config get <path>`: print a config value (dot/bracket path).
- `config set <path> <value>`: set a value (JSON5 or raw string).
- `config unset <path>`: remove a value.
### `doctor`
Health checks + quick fixes (config + gateway + legacy services).
@@ -594,8 +608,9 @@ Notes:
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
- `daemon install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly).
- `daemon install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs).
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`, `--json`.
### `logs`
Tail Gateway file logs via RPC.
+11
View File
@@ -21,6 +21,8 @@ clawdbot plugins info <id>
clawdbot plugins enable <id>
clawdbot plugins disable <id>
clawdbot plugins doctor
clawdbot plugins update <id>
clawdbot plugins update --all
```
### Install
@@ -31,3 +33,12 @@ clawdbot plugins install <npm-spec>
Security note: treat plugin installs like running code. Prefer pinned versions.
### Update
```bash
clawdbot plugins update <id>
clawdbot plugins update --all
clawdbot plugins update <id> --dry-run
```
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
+4
View File
@@ -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).
+19 -2
View File
@@ -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"
}
}
}
```
+147
View File
@@ -169,6 +169,20 @@ The agent was interrupted mid-response.
**Fix:** Just send another message. The session continues.
### "Agent failed before reply: Unknown model: anthropic/claude-haiku-3-5"
Clawdbot intentionally rejects **older/insecure models** (especially those more
vulnerable to prompt injection). If you see this error, the model name is no
longer supported.
**Fix:**
- Pick a **latest** model for the provider and update your config or model alias.
- If you’re unsure which models are available, run `clawdbot models list` or
`clawdbot models scan` and choose a supported one.
- Check gateway logs for the detailed failure reason.
See also: [Models CLI](/cli/models) and [Model providers](/concepts/model-providers).
### Messages Not Triggering
**Check 1:** Is the sender allowlisted?
@@ -306,6 +320,139 @@ Clawdbot keeps conversation history in memory.
}
```
## Common troubleshooting
### “All models failed” — what should I check first?
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
- **Model status**: use `/model status` (chat) or `clawdbot models status` (CLI).
### I’m running on my personal WhatsApp number — why is self-chat weird?
Enable self-chat mode and allowlist your own number:
```json5
{
channels: {
whatsapp: {
selfChatMode: true,
dmPolicy: "allowlist",
allowFrom: ["+15555550123"]
}
}
}
```
See [WhatsApp setup](/channels/whatsapp).
### WhatsApp logged me out. How do I re‑auth?
Run the login command again and scan the QR code:
```bash
clawdbot channels login
```
### Build errors on `main` — what’s the standard fix path?
1) `git pull origin main && pnpm install`
2) `pnpm clawdbot doctor`
3) Check GitHub issues or Discord
4) Temporary workaround: check out an older commit
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
Typical recovery:
```bash
git status # ensure you’re in the repo root
pnpm install
pnpm build
pnpm clawdbot doctor
clawdbot daemon restart
```
Why: pnpm is the configured package manager for this repo, and the dependency
patching workflow relies on it.
### How do I switch between git installs and npm installs?
Use the **website installer** and select the install method with a flag. It
upgrades in place and rewrites the gateway service to point at the new install.
Switch **to git install**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
```
Switch **to npm global**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
Notes:
- The git flow only rebases if the repo is clean. Commit or stash changes first.
- After switching, run:
```bash
clawdbot doctor
clawdbot daemon restart
```
### Telegram block streaming isn’t splitting text between tool calls. Why?
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
- `agents.defaults.blockStreamingDefault` is still `"off"`.
- `channels.telegram.blockStreaming` is set to `false`.
- `channels.telegram.streamMode` is `partial` or `block` **and draft streaming is active**
(private chat + topics). Draft streaming disables block streaming in that case.
- Your `minChars` / coalesce settings are too high, so chunks get merged.
- The model emits one large text block (no mid‑reply flush points).
Fix checklist:
1) Put block streaming settings under `agents.defaults`, not the root.
2) Set `channels.telegram.streamMode: "off"` if you want real multi‑message block replies.
3) Use smaller chunk/coalesce thresholds while debugging.
See [Streaming](/concepts/streaming).
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
Fix checklist:
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
Top‑level `channels.discord.requireMention` is not a supported key.
4) Ensure the bot has **Message Content Intent** and channel permissions.
5) Run `clawdbot channels status --probe` for audit hints.
Docs: [Discord](/channels/discord), [Channels troubleshooting](/channels/troubleshooting).
### Cloud Code Assist API error: invalid tool schema (400). What now?
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
schemas in current `main`, but the fix is not in the last release yet (as of
January 13, 2026).
Fix checklist:
1) **Update Clawdbot**:
- If you can run from source, pull `main` and restart the gateway.
- Otherwise, wait for the next release that includes the schema scrubber.
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
`properties` and simple enums.
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
## macOS Specific Issues
### App Crashes when Granting Permissions (Speech/Mic)
+1 -1
View File
@@ -187,7 +187,7 @@ services:
[
"node",
"dist/index.js",
"gateway-daemon",
"gateway",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",
+1 -1
View File
@@ -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
+4
View File
@@ -160,11 +160,15 @@ clawdbot plugins install <path> # add a local file/dir to plugins.l
clawdbot plugins install ./extensions/voice-call # relative path ok
clawdbot plugins install ./plugin.tgz # install from a local tarball
clawdbot plugins install @clawdbot/voice-call # install from npm
clawdbot plugins update <id>
clawdbot plugins update --all
clawdbot plugins enable <id>
clawdbot plugins disable <id>
clawdbot plugins doctor
```
`plugins update` only works for npm installs tracked under `plugins.installs`.
Plugins may also register their own top‑level commands (example: `clawdbot voicecall`).
## Plugin API (overview)
+78 -146
View File
@@ -70,11 +70,14 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
- [Sessions & multiple chats](#sessions-multiple-chats)
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
- [Do sessions reset automatically if I never send `/new`?](#do-sessions-reset-automatically-if-i-never-send-new)
- [How do I completely reset Clawdbot but keep it installed?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
- [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes)
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
- [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create)
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
- [What is the “default model”?](#what-is-the-default-model)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
@@ -107,25 +110,17 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Logging and debugging](#logging-and-debugging)
- [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
- [ELI5: `clawdbot daemon restart` vs `clawdbot gateway`](#eli5-clawdbot-daemon-restart-vs-clawdbot-gateway)
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
- [Media & attachments](#media-attachments)
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [Security and access control](#security-and-access-control)
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
- [Common troubleshooting](#common-troubleshooting)
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
- [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
- [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
- [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
- [npm install fails (allow-build-scripts / missing tar or yargs). What now?](#npm-install-fails-allow-build-scripts--missing-tar-or-yargs-what-now)
- [How do I switch between git installs and npm installs?](#how-do-i-switch-between-git-installs-and-npm-installs)
- [Telegram block streaming isn’t splitting text between tool calls. Why?](#telegram-block-streaming-isnt-splitting-text-between-tool-calls-why)
- [Discord doesn’t reply in my server even with `requireMention: false`. Why?](#discord-doesnt-reply-in-my-server-even-with-requiremention-false-why)
- [Cloud Code Assist API error: invalid tool schema (400). What now?](#cloud-code-assist-api-error-invalid-tool-schema-400-what-now)
## First 60 seconds if something's broken
@@ -858,7 +853,21 @@ See [/concepts/model-providers](/concepts/model-providers) and [/environment](/e
Send `/new` or `/reset` as a standalone message. See [Session management](/concepts/session).
### How do I completely reset Clawdbot (but keep it installed)?
### Do sessions reset automatically if I never send `/new`?
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
message starts a fresh session id for that chat key. This does not delete
transcripts — it just starts a new session.
```json5
{
session: {
idleMinutes: 240
}
}
```
### How do I completely reset Clawdbot but keep it installed?
Use the reset command:
@@ -905,6 +914,24 @@ If it keeps happening:
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
### Why am I getting heartbeat messages every 30 minutes?
Heartbeats run every **30m** by default. Tune or disable them:
```json5
{
agents: {
defaults: {
heartbeat: {
every: "2h" // or "0m" to disable
}
}
}
}
```
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
### Do I need to add a “bot account” to a WhatsApp group?
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
@@ -935,6 +962,19 @@ See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
Direct chats collapse to the main session by default. Groups/channels have their own session keys, and Telegram topics / Discord threads are separate sessions. See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
### How many workspaces and agents can I create?
No hard limits. Dozens (even hundreds) are fine, but watch for:
- **Disk growth:** sessions + transcripts live under `~/.clawdbot/agents/<agentId>/sessions/`.
- **Token cost:** more agents means more concurrent model usage.
- **Ops overhead:** per-agent auth profiles, workspaces, and channel routing.
Tips:
- Keep one **active** workspace per agent (`agents.defaults.workspace`).
- Prune old sessions (delete JSONL or store entries) if disk grows.
- Use `clawdbot doctor` to spot stray workspaces and profile mismatches.
## Models: defaults, selection, aliases, switching
### What is the “default model”?
@@ -1407,6 +1447,14 @@ clawdbot daemon restart
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
### ELI5: `clawdbot daemon restart` vs `clawdbot gateway`
- `clawdbot daemon restart`: restarts the **background service** (launchd/systemd).
- `clawdbot gateway`: runs the gateway **in the foreground** for this terminal session.
If you installed the daemon, use the daemon commands. Use `clawdbot gateway` when
you want a one-off, foreground run.
### What’s the fastest way to get more details when something fails?
Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors.
@@ -1423,7 +1471,11 @@ CLI sending:
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
```
Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images).
Also check:
- The target channel supports outbound media and isn’t blocked by allowlists.
- The file is within the provider’s size limits (images are resized to max 2048px).
See [Images](/nodes/images).
## Security and access control
@@ -1439,6 +1491,19 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
Run `clawdbot doctor` to surface risky DM policies.
### I ran `/start` in Telegram but didn’t get a pairing code
Pairing codes are sent **only** when an unknown sender messages the bot and
`dmPolicy: "pairing"` is enabled. `/start` by itself doesn’t generate a code.
Check pending requests:
```bash
clawdbot pairing list telegram
```
If you want immediate access, allowlist your sender id or set `dmPolicy: "open"`
for that account.
### WhatsApp: will it message my contacts? How does pairing work?
No. Default WhatsApp DM policy is **pairing**. Unknown senders only get a pairing code and their message is **not processed**. Clawdbot only replies to chats it receives or to explicit sends you trigger.
@@ -1496,139 +1561,6 @@ Queue mode controls how new messages interact with an in‑flight run. Use `/que
You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
## Common troubleshooting
### “All models failed” — what should I check first?
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
- **`/model status`** to see current configured models + shorthands.
### I’m running on my personal WhatsApp number — why is self-chat weird?
Enable self-chat mode and allowlist your own number:
```json5
{
channels: {
whatsapp: {
selfChatMode: true,
dmPolicy: "allowlist",
allowFrom: ["+15555550123"]
}
}
}
```
See [WhatsApp setup](/channels/whatsapp).
### WhatsApp logged me out. How do I re‑auth?
Run the login command again and scan the QR code:
```bash
clawdbot channels login
```
### Build errors on `main` — what’s the standard fix path?
1) `git pull origin main && pnpm install`
2) `pnpm clawdbot doctor`
3) Check GitHub issues or Discord
4) Temporary workaround: check out an older commit
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
Typical recovery:
```bash
git status # ensure you’re in the repo root
pnpm install
pnpm build
pnpm clawdbot doctor
clawdbot daemon restart
```
Why: pnpm is the configured package manager for this repo, and the dependency
patching workflow relies on it.
### How do I switch between git installs and npm installs?
Use the **website installer** and select the install method with a flag. It
upgrades in place and rewrites the gateway service to point at the new install.
Switch **to git install**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
```
Switch **to npm global**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
Notes:
- The git flow only rebases if the repo is clean. Commit or stash changes first.
- After switching, run:
```bash
clawdbot doctor
clawdbot daemon restart
```
### Telegram block streaming isn’t splitting text between tool calls. Why?
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
- `agents.defaults.blockStreamingDefault` is still `"off"`.
- `channels.telegram.blockStreaming` is set to `false`.
- `channels.telegram.streamMode` is `partial` or `block` **and draft streaming is active**
(private chat + topics). Draft streaming disables block streaming in that case.
- Your `minChars` / coalesce settings are too high, so chunks get merged.
- The model emits one large text block (no mid‑reply flush points).
Fix checklist:
1) Put block streaming settings under `agents.defaults`, not the root.
2) Set `channels.telegram.streamMode: "off"` if you want real multi‑message block replies.
3) Use smaller chunk/coalesce thresholds while debugging.
See [Streaming](/concepts/streaming).
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
Fix checklist:
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
Top‑level `channels.discord.requireMention` is not a supported key.
4) Ensure the bot has **Message Content Intent** and channel permissions.
5) Run `clawdbot channels status --probe` for audit hints.
Docs: [Discord](/channels/discord), [Channels troubleshooting](/channels/troubleshooting).
### Cloud Code Assist API error: invalid tool schema (400). What now?
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
schemas in current `main`, but the fix is not in the last release yet (as of
January 13, 2026).
Fix checklist:
1) **Update Clawdbot**:
- If you can run from source, pull `main` and restart the gateway.
- Otherwise, wait for the next release that includes the schema scrubber.
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
`properties` and simple enums.
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
## Answer the exact question from the screenshot/chat log
**Q: “What’s the default model for Anthropic with an API key?”**
+2
View File
@@ -68,6 +68,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
1) **Existing config detection**
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
- Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset**
(or pass `--reset`).
- If the config is invalid or contains legacy keys, the wizard stops and asks
you to run `clawdbot doctor` before continuing.
- Reset uses `trash` (never `rm`) and offers scopes:
+16 -1
View File
@@ -107,8 +107,23 @@ 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` |
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
### Problem: "Chrome extension relay is running, but no tab is connected"
You’re using the `chrome` profile (extension relay). It expects the Clawdbot
browser extension to be attached to a live tab.
Fix options:
1. **Use the managed browser:** `clawdbot browser start --browser-profile clawd`
(or set `browser.defaultProfile: "clawd"`).
2. **Use the extension relay:** install the extension, open a tab, and click the
Clawdbot extension icon to attach it.
Notes:
- The `chrome` profile uses your **system default Chromium browser** when possible.
- Local `clawd` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP.
+27 -3
View File
@@ -14,8 +14,10 @@ control server.
Beginner view:
- Think of it as a **separate, agent-only browser**.
- It does **not** touch your personal browser profile.
- The `clawd` profile does **not** touch your personal browser profile.
- The agent can **open tabs, read pages, click, and type** in a safe lane.
- The default `chrome` profile uses the **system default Chromium browser** via the
extension relay; switch to `clawd` for the isolated managed browser.
## What you get
@@ -39,6 +41,14 @@ clawdbot browser --browser-profile clawd snapshot
If you get “Browser disabled”, enable it in config (see below) and restart the
Gateway.
## Profiles: `clawd` vs `chrome`
- `clawd`: managed, isolated browser (no extension required).
- `chrome`: extension relay to your **system browser** (requires the Clawdbot
extension to be attached to a tab).
Set `browser.defaultProfile: "clawd"` if you want managed mode by default.
## Configuration
Browser settings live in `~/.clawdbot/clawdbot.json`.
@@ -71,11 +81,21 @@ 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.
- Default profile is `chrome` (extension relay). Use `defaultProfile: "clawd"` for the managed browser.
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
- Local `clawd` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
## Use Brave (or another Chromium-based browser)
Set `browser.executablePath` to override auto-detection:
If your **system default** browser is Chromium-based (Chrome/Brave/Edge/etc),
Clawdbot uses it automatically. Set `browser.executablePath` to override
auto-detection:
CLI example:
```bash
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
```
```json5
// macOS
@@ -339,6 +359,10 @@ Playwright. If Playwright isn’t installed, those endpoints return a clear 501
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
If you see `Playwright is not available in this gateway build`, install the full
Playwright package (not `playwright-core`) and restart the gateway, or reinstall
Clawdbot with browser support.
## How it works (internal)
High-level flow:
+25 -7
View File
@@ -206,6 +206,31 @@ if [[ "$NO_SIGN" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}"
fi
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (before the app launches).
# This reduces noisy "could not connect" errors during app startup.
if [ "$NO_SIGN" -eq 1 ]; then
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
fi
GATEWAY_PORT="$(
node -e '
const fs = require("node:fs");
const path = require("node:path");
try {
const raw = fs.readFileSync(path.join(process.env.HOME, ".clawdbot", "clawdbot.json"), "utf8");
const cfg = JSON.parse(raw);
const port = cfg && cfg.gateway && typeof cfg.gateway.port === "number" ? cfg.gateway.port : 18789;
process.stdout.write(String(port));
} catch {
process.stdout.write("18789");
}
'
)"
run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" bash -lc "lsof -iTCP:${GATEWAY_PORT} -sTCP:LISTEN | head -n 5 || true"
fi
# 4) Launch the installed app in the foreground so the menu bar extra appears.
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
@@ -226,13 +251,6 @@ else
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
fi
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (after the app launches).
if [ "$NO_SIGN" -eq 1 ]; then
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
fi
run_step "verify gateway port 18789 (unsigned)" bash -lc "lsof -iTCP:18789 -sTCP:LISTEN | head -n 5 || true"
run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true"
fi
+3 -1
View File
@@ -75,7 +75,9 @@ export function createClawdbotTools(options?: {
}),
createCanvasTool(),
createNodesTool(),
createCronTool(),
createCronTool({
agentSessionKey: options?.agentSessionKey,
}),
createMessageTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
+7 -2
View File
@@ -44,7 +44,11 @@ import {
} from "../skills.js";
import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../workspace.js";
import { buildEmbeddedExtensionPaths } from "./extensions.js";
import { logToolSchemasForGoogle, sanitizeSessionHistory } from "./google.js";
import {
logToolSchemasForGoogle,
sanitizeSessionHistory,
sanitizeToolsForGoogle,
} from "./google.js";
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
@@ -183,7 +187,7 @@ export async function compactEmbeddedPiSession(params: {
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
});
const runAbortController = new AbortController();
const tools = createClawdbotCodingTools({
const toolsRaw = createClawdbotCodingTools({
exec: {
...resolveExecToolDefaults(params.config),
elevated: params.bashElevated,
@@ -200,6 +204,7 @@ export async function compactEmbeddedPiSession(params: {
modelId,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { sanitizeToolsForGoogle } from "./google.js";
describe("sanitizeToolsForGoogle", () => {
it("strips unsupported schema keywords for Google providers", () => {
const tool = {
name: "test",
description: "test",
parameters: {
type: "object",
additionalProperties: false,
properties: {
foo: {
type: "string",
format: "uuid",
},
},
},
execute: async () => ({ ok: true, content: [] }),
} as unknown as AgentTool;
const [sanitized] = sanitizeToolsForGoogle({
tools: [tool],
provider: "google-gemini-cli",
});
const params = sanitized.parameters as {
additionalProperties?: unknown;
properties?: Record<string, { format?: unknown }>;
};
expect(params.additionalProperties).toBeUndefined();
expect(params.properties?.foo?.format).toBeUndefined();
});
});
+23 -2
View File
@@ -1,4 +1,5 @@
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
@@ -14,6 +15,7 @@ import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
import { log } from "./logger.js";
import { describeUnknownError } from "./utils.js";
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
@@ -80,17 +82,36 @@ function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[]
return violations;
}
export function sanitizeToolsForGoogle<TSchemaType extends TSchema = TSchema, TResult = unknown>(
params: {
tools: AgentTool<TSchemaType, TResult>[];
provider: string;
},
): AgentTool<TSchemaType, TResult>[] {
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
return params.tools;
}
return params.tools.map((tool) => {
if (!tool.parameters || typeof tool.parameters !== "object") return tool;
return {
...tool,
parameters: cleanToolSchemaForGemini(tool.parameters as Record<string, unknown>) as TSchemaType,
};
});
}
export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider: string }) {
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
return;
}
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
const tools = sanitizeToolsForGoogle(params);
log.info("google tool schema snapshot", {
provider: params.provider,
toolCount: params.tools.length,
toolCount: tools.length,
tools: toolNames,
});
for (const [index, tool] of params.tools.entries()) {
for (const [index, tool] of tools.entries()) {
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
if (violations.length > 0) {
log.warn("google tool schema has unsupported keywords", {
+7 -2
View File
@@ -45,7 +45,11 @@ import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../
import { isAbortError } from "../abort.js";
import { buildEmbeddedExtensionPaths } from "../extensions.js";
import { applyExtraParamsToAgent } from "../extra-params.js";
import { logToolSchemasForGoogle, sanitizeSessionHistory } from "../google.js";
import {
logToolSchemasForGoogle,
sanitizeSessionHistory,
sanitizeToolsForGoogle,
} from "../google.js";
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
import { log } from "../logger.js";
import { buildModelAliasLines } from "../model.js";
@@ -127,7 +131,7 @@ export async function runEmbeddedAttempt(
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
const tools = createClawdbotCodingTools({
const toolsRaw = createClawdbotCodingTools({
exec: {
...resolveExecToolDefaults(params.config),
elevated: params.bashElevated,
@@ -148,6 +152,7 @@ export async function runEmbeddedAttempt(
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider });
logToolSchemasForGoogle({ tools, provider: params.provider });
const machineName = await getMachineDisplayName();
@@ -14,7 +14,7 @@ export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
startedAt: Date.now(),
},
});
ctx.params.onAgentEvent?.({
void ctx.params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "start" },
});
@@ -24,7 +24,7 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) {
ctx.state.compactionInFlight = true;
ctx.ensureCompactionPromise();
ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`);
ctx.params.onAgentEvent?.({
void ctx.params.onAgentEvent?.({
stream: "compaction",
data: { phase: "start" },
});
@@ -43,7 +43,7 @@ export function handleAutoCompactionEnd(
} else {
ctx.maybeResolveCompactionWait();
}
ctx.params.onAgentEvent?.({
void ctx.params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry },
});
@@ -59,7 +59,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
endedAt: Date.now(),
},
});
ctx.params.onAgentEvent?.({
void ctx.params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "end" },
});
@@ -118,7 +118,7 @@ export function handleMessageUpdate(
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
ctx.params.onAgentEvent?.({
void ctx.params.onAgentEvent?.({
stream: "assistant",
data: {
text: cleanedText,
@@ -11,7 +11,7 @@ import {
} from "./pi-embedded-subscribe.tools.js";
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
export function handleToolExecutionStart(
export async function handleToolExecutionStart(
ctx: EmbeddedPiSubscribeContext,
evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
) {
@@ -53,7 +53,8 @@ export function handleToolExecutionStart(
args: args as Record<string, unknown>,
},
});
ctx.params.onAgentEvent?.({
// Await onAgentEvent to ensure typing indicator starts before tool summaries are emitted.
await ctx.params.onAgentEvent?.({
stream: "tool",
data: { phase: "start", name: toolName, toolCallId },
});
@@ -108,7 +109,7 @@ export function handleToolExecutionUpdate(
partialResult: sanitized,
},
});
ctx.params.onAgentEvent?.({
void ctx.params.onAgentEvent?.({
stream: "tool",
data: {
phase: "update",
@@ -170,7 +171,7 @@ export function handleToolExecutionEnd(
result: sanitizedResult,
},
});
ctx.params.onAgentEvent?.({
void ctx.params.onAgentEvent?.({
stream: "tool",
data: {
phase: "result",
+5 -1
View File
@@ -32,7 +32,11 @@ export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeCont
handleMessageEnd(ctx, evt as never);
return;
case "tool_execution_start":
handleToolExecutionStart(ctx, evt as never);
// Async handler - awaits typing indicator before emitting tool summaries.
// Catch rejections to avoid unhandled promise rejection crashes.
handleToolExecutionStart(ctx, evt as never).catch((err) => {
ctx.log.debug(`tool_execution_start handler failed: ${String(err)}`);
});
return;
case "tool_execution_update":
handleToolExecutionUpdate(ctx, evt as never);
@@ -13,7 +13,7 @@ describe("subscribeEmbeddedPiSession", () => {
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
] as const;
it("includes canvas action metadata in tool summaries", () => {
it("includes canvas action metadata in tool summaries", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
@@ -38,6 +38,9 @@ describe("subscribeEmbeddedPiSession", () => {
args: { action: "a2ui_push", jsonlPath: "/tmp/a2ui.jsonl" },
});
// Wait for async handler to complete
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("🖼️");
@@ -72,7 +75,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).not.toHaveBeenCalled();
});
it("emits tool summaries when shouldEmitToolResult overrides verbose", () => {
it("emits tool summaries when shouldEmitToolResult overrides verbose", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
@@ -98,6 +101,9 @@ describe("subscribeEmbeddedPiSession", () => {
args: { path: "/tmp/c.txt" },
});
// Wait for async handler to complete
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(1);
});
});
@@ -14,7 +14,7 @@ describe("subscribeEmbeddedPiSession", () => {
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
] as const;
it("suppresses message_end block replies when the message tool already sent", () => {
it("suppresses message_end block replies when the message tool already sent", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
@@ -41,6 +41,9 @@ describe("subscribeEmbeddedPiSession", () => {
args: { action: "send", to: "+1555", message: messageText },
});
// Wait for async handler to complete
await Promise.resolve();
handler?.({
type: "tool_execution_end",
toolName: "message",
@@ -58,7 +61,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onBlockReply).not.toHaveBeenCalled();
});
it("does not suppress message_end replies when message tool reports error", () => {
it("does not suppress message_end replies when message tool reports error", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
@@ -85,6 +88,9 @@ describe("subscribeEmbeddedPiSession", () => {
args: { action: "send", to: "+1555", message: messageText },
});
// Wait for async handler to complete
await Promise.resolve();
handler?.({
type: "tool_execution_end",
toolName: "message",
@@ -54,7 +54,7 @@ describe("subscribeEmbeddedPiSession", () => {
await waitPromise;
expect(resolved).toBe(true);
});
it("emits tool summaries at tool start when verbose is on", () => {
it("emits tool summaries at tool start when verbose is on", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
@@ -79,6 +79,9 @@ describe("subscribeEmbeddedPiSession", () => {
args: { path: "/tmp/a.txt" },
});
// Wait for async handler to complete
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("/tmp/a.txt");
@@ -93,7 +96,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).toHaveBeenCalledTimes(1);
});
it("includes browser action metadata in tool summaries", () => {
it("includes browser action metadata in tool summaries", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
@@ -118,6 +121,9 @@ describe("subscribeEmbeddedPiSession", () => {
args: { action: "snapshot", targetUrl: "https://example.com" },
});
// Wait for async handler to complete
await Promise.resolve();
expect(onToolResult).toHaveBeenCalledTimes(1);
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("🌐");
+4 -1
View File
@@ -22,7 +22,10 @@ export type SubscribeEmbeddedPiSessionParams = {
blockReplyChunking?: BlockReplyChunking;
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
onAgentEvent?: (evt: {
stream: string;
data: Record<string, unknown>;
}) => void | Promise<void>;
enforceFinalTag?: boolean;
};
+2 -2
View File
@@ -102,7 +102,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
if (!watchEnabled) {
if (existing) {
watchers.delete(workspaceDir);
existing.timer && clearTimeout(existing.timer);
if (existing.timer) clearTimeout(existing.timer);
void existing.watcher.close().catch(() => {});
}
return;
@@ -115,7 +115,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
}
if (existing) {
watchers.delete(workspaceDir);
existing.timer && clearTimeout(existing.timer);
if (existing.timer) clearTimeout(existing.timer);
void existing.watcher.close().catch(() => {});
}
+2 -2
View File
@@ -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",
+43
View File
@@ -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" });
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");
});
});
+110 -1
View File
@@ -1,8 +1,11 @@
import { Type } from "@sinclair/typebox";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { loadConfig } 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,90 @@ const CronToolSchema = Type.Object({
mode: optionalStringEnum(CRON_WAKE_MODES),
});
export function createCronTool(): AnyAgentTool {
type CronToolOptions = {
agentSessionKey?: string;
};
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;
}) {
const sessionKey = params.agentSessionKey?.trim();
if (!sessionKey) return [];
const cfg = 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 +149,24 @@ 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,
});
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": {
+9 -9
View File
@@ -219,7 +219,7 @@ export async function handleDiscordGuildAction(
return jsonResult({ ok: true, event });
}
case "channelCreate": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
@@ -241,7 +241,7 @@ export async function handleDiscordGuildAction(
return jsonResult({ ok: true, channel });
}
case "channelEdit": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
@@ -267,7 +267,7 @@ export async function handleDiscordGuildAction(
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
@@ -277,7 +277,7 @@ export async function handleDiscordGuildAction(
return jsonResult(result);
}
case "channelMove": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
@@ -295,7 +295,7 @@ export async function handleDiscordGuildAction(
return jsonResult({ ok: true });
}
case "categoryCreate": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
@@ -310,7 +310,7 @@ export async function handleDiscordGuildAction(
return jsonResult({ ok: true, category: channel });
}
case "categoryEdit": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
@@ -326,7 +326,7 @@ export async function handleDiscordGuildAction(
return jsonResult({ ok: true, category: channel });
}
case "categoryDelete": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
@@ -336,7 +336,7 @@ export async function handleDiscordGuildAction(
return jsonResult(result);
}
case "channelPermissionSet": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
@@ -359,7 +359,7 @@ export async function handleDiscordGuildAction(
return jsonResult({ ok: true });
}
case "channelPermissionRemove": {
if (!isActionEnabled("channels", false)) {
if (!isActionEnabled("channels")) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
+67 -51
View File
@@ -26,7 +26,7 @@ import type { VerboseLevel } from "../thinking.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
import type { FollowupRun } from "./queue.js";
import { parseReplyDirectives } from "./reply-directives.js";
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
@@ -40,6 +40,8 @@ export type AgentRunLoopResult =
fallbackModel?: string;
didLogHeartbeatStrip: boolean;
autoCompactionCompleted: boolean;
/** Payload keys sent directly (not via pipeline) during tool flush. */
directlySentBlockKeys?: Set<string>;
}
| { kind: "final"; payload: ReplyPayload };
@@ -70,6 +72,8 @@ export async function runAgentTurnWithFallback(params: {
}): Promise<AgentRunLoopResult> {
let didLogHeartbeatStrip = false;
let autoCompactionCompleted = false;
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
const directlySentBlockKeys = new Set<string>();
const runId = crypto.randomUUID();
if (params.sessionKey) {
@@ -244,12 +248,13 @@ export async function runAgentTurnWithFallback(params: {
});
}
: undefined,
onAgentEvent: (evt) => {
// Trigger typing when tools start executing
onAgentEvent: async (evt) => {
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "start" || phase === "update") {
void params.typingSignals.signalToolStart();
await params.typingSignals.signalToolStart();
}
}
// Track auto-compaction completion
@@ -261,57 +266,67 @@ export async function runAgentTurnWithFallback(params: {
}
}
},
onBlockReply:
params.blockStreamingEnabled && params.opts?.onBlockReply
? async (payload) => {
const { text, skip } = normalizeStreamingText(payload);
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (skip && !hasPayloadMedia) return;
const taggedPayload = applyReplyTagsToPayload(
{
text,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
},
params.sessionCtx.MessageSid,
);
// Let through payloads with audioAsVoice flag even if empty (need to track it)
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
currentMessageId: params.sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
});
const cleaned = parsed.text || undefined;
const hasRenderableMedia =
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
if (
!cleaned &&
!hasRenderableMedia &&
!payload.audioAsVoice &&
!parsed.audioAsVoice
)
return;
if (parsed.isSilent && !hasRenderableMedia) return;
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
// even when regular block streaming is disabled. The handler sends directly
// via opts.onBlockReply when the pipeline isn't available.
onBlockReply: params.opts?.onBlockReply
? async (payload) => {
const { text, skip } = normalizeStreamingText(payload);
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (skip && !hasPayloadMedia) return;
const taggedPayload = applyReplyTagsToPayload(
{
text,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
},
params.sessionCtx.MessageSid,
);
// Let through payloads with audioAsVoice flag even if empty (need to track it)
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
currentMessageId: params.sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
});
const cleaned = parsed.text || undefined;
const hasRenderableMedia =
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
if (
!cleaned &&
!hasRenderableMedia &&
!payload.audioAsVoice &&
!parsed.audioAsVoice
)
return;
if (parsed.isSilent && !hasRenderableMedia) return;
const blockPayload: ReplyPayload = params.applyReplyToMode({
...taggedPayload,
text: cleaned,
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
const blockPayload: ReplyPayload = params.applyReplyToMode({
...taggedPayload,
text: cleaned,
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
});
void params.typingSignals
.signalTextDelta(cleaned ?? taggedPayload.text)
.catch((err) => {
logVerbose(`block reply typing signal failed: ${String(err)}`);
});
void params.typingSignals
.signalTextDelta(cleaned ?? taggedPayload.text)
.catch((err) => {
logVerbose(`block reply typing signal failed: ${String(err)}`);
});
params.blockReplyPipeline?.enqueue(blockPayload);
// Use pipeline if available (block streaming enabled), otherwise send directly
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload);
} else {
// Send directly when flushing before tool execution (no streaming).
// Track sent key to avoid duplicate in final payloads.
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
await params.opts?.onBlockReply?.(blockPayload);
}
: undefined,
}
: undefined,
onBlockReplyFlush:
params.blockStreamingEnabled && blockReplyPipeline
? async () => {
@@ -447,5 +462,6 @@ export async function runAgentTurnWithFallback(params: {
fallbackModel,
didLogHeartbeatStrip,
autoCompactionCompleted,
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
};
}
@@ -5,7 +5,7 @@ import type { OriginatingChannelType } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
import { parseReplyDirectives } from "./reply-directives.js";
import {
applyReplyThreading,
@@ -20,6 +20,8 @@ export function buildReplyPayloads(params: {
didLogHeartbeatStrip: boolean;
blockStreamingEnabled: boolean;
blockReplyPipeline: BlockReplyPipeline | null;
/** Payload keys sent directly (not via pipeline) during tool flush. */
directlySentBlockKeys?: Set<string>;
replyToMode: ReplyToMode;
replyToChannel?: OriginatingChannelType;
currentMessageId?: string;
@@ -98,11 +100,16 @@ export function buildReplyPayloads(params: {
payloads: replyTaggedPayloads,
sentTexts: messagingToolSentTexts,
});
// Filter out payloads already sent via pipeline or directly during tool flush.
const filteredPayloads = shouldDropFinalPayloads
? []
: params.blockStreamingEnabled
? dedupedPayloads.filter((payload) => !params.blockReplyPipeline?.hasSentPayload(payload))
: dedupedPayloads;
: params.directlySentBlockKeys?.size
? dedupedPayloads.filter(
(payload) => !params.directlySentBlockKeys!.has(createBlockReplyPayloadKey(payload)),
)
: dedupedPayloads;
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
return {
+2 -1
View File
@@ -272,7 +272,7 @@ export async function runReplyAgent(params: {
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
}
const { runResult, fallbackProvider, fallbackModel } = runOutcome;
const { runResult, fallbackProvider, fallbackModel, directlySentBlockKeys } = runOutcome;
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
if (
@@ -314,6 +314,7 @@ export async function runReplyAgent(params: {
didLogHeartbeatStrip,
blockStreamingEnabled,
blockReplyPipeline,
directlySentBlockKeys,
replyToMode,
replyToChannel,
currentMessageId: sessionCtx.MessageSid,
@@ -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");
});
});
+298
View File
@@ -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();
+3
View File
@@ -13,6 +13,9 @@ export type BrowserStatus = {
cdpPort: number;
cdpUrl?: string;
chosenBrowser: string | null;
detectedBrowser?: string | null;
detectedExecutablePath?: string | null;
detectError?: string | null;
userDataDir: string | null;
color: string;
headless: boolean;
+5 -1
View File
@@ -63,7 +63,11 @@ export async function requirePwAi(
jsonError(
res,
501,
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
[
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
"Install the full Playwright package (not playwright-core) and restart the gateway, or reinstall with browser support.",
"Docs: /tools/browser#playwright-requirement",
].join("\n"),
);
return null;
}
+17
View File
@@ -1,5 +1,6 @@
import type express from "express";
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext } from "../server-context.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
@@ -36,6 +37,19 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
]);
const profileState = current.profiles.get(profileCtx.profile.name);
let detectedBrowser: string | null = null;
let detectedExecutablePath: string | null = null;
let detectError: string | null = null;
try {
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
if (detected) {
detectedBrowser = detected.kind;
detectedExecutablePath = detected.path;
}
} catch (err) {
detectError = String(err);
}
res.json({
enabled: current.resolved.enabled,
@@ -48,6 +62,9 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
cdpPort: profileCtx.profile.cdpPort,
cdpUrl: profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
detectedBrowser,
detectedExecutablePath,
detectError,
userDataDir: profileState?.running?.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
+1 -1
View File
@@ -1 +1 @@
7a41e2cbe78e007eeaa03a65e18b8edf3fe17263de427426eaf58e946728eaf6
e8286176aaf2c0daf5f4b30dd221c3d7aa1aec8329d6865cac3d9636fd7f08f6
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { discordMessageActions } from "./discord.js";
describe("discord message actions", () => {
it("lists channel and upload actions by default", () => {
const cfg = { channels: { discord: { token: "d0" } } } as ClawdbotConfig;
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("emoji-upload");
expect(actions).toContain("sticker-upload");
expect(actions).toContain("channel-create");
});
it("respects disabled channel actions", () => {
const cfg = {
channels: { discord: { token: "d0", actions: { channels: false } } },
} as ClawdbotConfig;
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("channel-create");
});
});
+1 -1
View File
@@ -47,7 +47,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
actions.add("channel-info");
actions.add("channel-list");
}
if (gate("channels", false)) {
if (gate("channels")) {
actions.add("channel-create");
actions.add("channel-edit");
actions.add("channel-delete");
+3
View File
@@ -47,7 +47,10 @@ export function registerBrowserManageCommands(
`cdpPort: ${status.cdpPort}`,
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
`browser: ${status.chosenBrowser ?? "unknown"}`,
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
`detectedPath: ${status.detectedExecutablePath ?? status.executablePath ?? "auto"}`,
`profileColor: ${status.color}`,
...(status.detectError ? [`detectError: ${status.detectError}`] : []),
].join("\n"),
);
} catch (err) {
+297
View File
@@ -0,0 +1,297 @@
import JSON5 from "json5";
import type { Command } from "commander";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
type PathSegment = string;
function isIndexSegment(raw: string): boolean {
return /^[0-9]+$/.test(raw);
}
function parsePath(raw: string): PathSegment[] {
const trimmed = raw.trim();
if (!trimmed) return [];
const parts: string[] = [];
let current = "";
let i = 0;
while (i < trimmed.length) {
const ch = trimmed[i];
if (ch === "\\") {
const next = trimmed[i + 1];
if (next) current += next;
i += 2;
continue;
}
if (ch === ".") {
if (current) parts.push(current);
current = "";
i += 1;
continue;
}
if (ch === "[") {
if (current) parts.push(current);
current = "";
const close = trimmed.indexOf("]", i);
if (close === -1) throw new Error(`Invalid path (missing "]"): ${raw}`);
const inside = trimmed.slice(i + 1, close).trim();
if (!inside) throw new Error(`Invalid path (empty "[]"): ${raw}`);
parts.push(inside);
i = close + 1;
continue;
}
current += ch;
i += 1;
}
if (current) parts.push(current);
return parts.map((part) => part.trim()).filter(Boolean);
}
function parseValue(raw: string, opts: { json?: boolean }): unknown {
const trimmed = raw.trim();
if (opts.json) {
try {
return JSON5.parse(trimmed);
} catch (err) {
throw new Error(`Failed to parse JSON5 value: ${String(err)}`);
}
}
try {
return JSON5.parse(trimmed);
} catch {
return raw;
}
}
function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } {
let current: unknown = root;
for (const segment of path) {
if (!current || typeof current !== "object") return { found: false };
if (Array.isArray(current)) {
if (!isIndexSegment(segment)) return { found: false };
const index = Number.parseInt(segment, 10);
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
return { found: false };
}
current = current[index];
continue;
}
const record = current as Record<string, unknown>;
if (!(segment in record)) return { found: false };
current = record[segment];
}
return { found: true, value: current };
}
function setAtPath(root: Record<string, unknown>, path: PathSegment[], value: unknown): void {
let current: unknown = root;
for (let i = 0; i < path.length - 1; i += 1) {
const segment = path[i];
const next = path[i + 1];
const nextIsIndex = Boolean(next && isIndexSegment(next));
if (Array.isArray(current)) {
if (!isIndexSegment(segment)) {
throw new Error(`Expected numeric index for array segment "${segment}"`);
}
const index = Number.parseInt(segment, 10);
const existing = current[index];
if (!existing || typeof existing !== "object") {
current[index] = nextIsIndex ? [] : {};
}
current = current[index];
continue;
}
if (!current || typeof current !== "object") {
throw new Error(`Cannot traverse into "${segment}" (not an object)`);
}
const record = current as Record<string, unknown>;
const existing = record[segment];
if (!existing || typeof existing !== "object") {
record[segment] = nextIsIndex ? [] : {};
}
current = record[segment];
}
const last = path[path.length - 1];
if (Array.isArray(current)) {
if (!isIndexSegment(last)) {
throw new Error(`Expected numeric index for array segment "${last}"`);
}
const index = Number.parseInt(last, 10);
current[index] = value;
return;
}
if (!current || typeof current !== "object") {
throw new Error(`Cannot set "${last}" (parent is not an object)`);
}
(current as Record<string, unknown>)[last] = value;
}
function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolean {
let current: unknown = root;
for (let i = 0; i < path.length - 1; i += 1) {
const segment = path[i];
if (!current || typeof current !== "object") return false;
if (Array.isArray(current)) {
if (!isIndexSegment(segment)) return false;
const index = Number.parseInt(segment, 10);
if (!Number.isFinite(index) || index < 0 || index >= current.length) return false;
current = current[index];
continue;
}
const record = current as Record<string, unknown>;
if (!(segment in record)) return false;
current = record[segment];
}
const last = path[path.length - 1];
if (Array.isArray(current)) {
if (!isIndexSegment(last)) return false;
const index = Number.parseInt(last, 10);
if (!Number.isFinite(index) || index < 0 || index >= current.length) return false;
current.splice(index, 1);
return true;
}
if (!current || typeof current !== "object") return false;
const record = current as Record<string, unknown>;
if (!(last in record)) return false;
delete record[last];
return true;
}
async function loadValidConfig() {
const snapshot = await readConfigFileSnapshot();
if (snapshot.valid) return snapshot;
defaultRuntime.error(`Config invalid at ${snapshot.path}.`);
for (const issue of snapshot.issues) {
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
}
defaultRuntime.error("Run `clawdbot doctor` to repair, then retry.");
defaultRuntime.exit(1);
return snapshot;
}
export function registerConfigCli(program: Command) {
const cmd = program
.command("config")
.description("Config helpers (get/set/unset). Run without subcommand for the wizard.")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/config", "docs.clawd.bot/cli/config")}\n`,
)
.option(
"--section <section>",
"Configure wizard sections (repeatable). Use with no subcommand.",
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.action(async (opts) => {
const { CONFIGURE_WIZARD_SECTIONS, configureCommand, configureCommandWithSections } =
await import("../commands/configure.js");
const sections: string[] = Array.isArray(opts.section)
? opts.section
.map((value: unknown) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
if (sections.length === 0) {
await configureCommand(defaultRuntime);
return;
}
const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never));
if (invalid.length > 0) {
defaultRuntime.error(
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`,
);
defaultRuntime.exit(1);
return;
}
await configureCommandWithSections(sections as never, defaultRuntime);
});
cmd
.command("get")
.description("Get a config value by dot path")
.argument("<path>", "Config path (dot or bracket notation)")
.option("--json", "Output JSON", false)
.action(async (path: string, opts) => {
try {
const parsedPath = parsePath(path);
if (parsedPath.length === 0) {
throw new Error("Path is empty.");
}
const snapshot = await loadValidConfig();
const res = getAtPath(snapshot.config, parsedPath);
if (!res.found) {
defaultRuntime.error(danger(`Config path not found: ${path}`));
defaultRuntime.exit(1);
return;
}
if (opts.json) {
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
return;
}
if (typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean") {
defaultRuntime.log(String(res.value));
return;
}
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
cmd
.command("set")
.description("Set a config value by dot path")
.argument("<path>", "Config path (dot or bracket notation)")
.argument("<value>", "Value (JSON5 or raw string)")
.option("--json", "Parse value as JSON5 (required)", false)
.action(async (path: string, value: string, opts) => {
try {
const parsedPath = parsePath(path);
if (parsedPath.length === 0) throw new Error("Path is empty.");
const parsedValue = parseValue(value, opts);
const snapshot = await loadValidConfig();
const next = snapshot.config as Record<string, unknown>;
setAtPath(next, parsedPath, parsedValue);
await writeConfigFile(next);
defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
cmd
.command("unset")
.description("Remove a config value by dot path")
.argument("<path>", "Config path (dot or bracket notation)")
.action(async (path: string) => {
try {
const parsedPath = parsePath(path);
if (parsedPath.length === 0) throw new Error("Path is empty.");
const snapshot = await loadValidConfig();
const next = snapshot.config as Record<string, unknown>;
const removed = unsetAtPath(next, parsedPath);
if (!removed) {
defaultRuntime.error(danger(`Config path not found: ${path}`));
defaultRuntime.exit(1);
return;
}
await writeConfigFile(next);
defaultRuntime.log(info(`Removed ${path}. Restart the gateway to apply.`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}
+43
View File
@@ -209,6 +209,28 @@ describe("daemon-cli coverage", () => {
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("installs the daemon with json output", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
serviceIsLoaded.mockResolvedValueOnce(false);
serviceInstall.mockClear();
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "install", "--port", "18789", "--json"], {
from: "user",
});
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const parsed = JSON.parse(jsonLine ?? "{}") as { ok?: boolean; action?: string; result?: string };
expect(parsed.ok).toBe(true);
expect(parsed.action).toBe("install");
expect(parsed.result).toBe("installed");
});
it("starts and stops the daemon via service helpers", async () => {
serviceRestart.mockClear();
serviceStop.mockClear();
@@ -225,4 +247,25 @@ describe("daemon-cli coverage", () => {
expect(serviceRestart).toHaveBeenCalledTimes(1);
expect(serviceStop).toHaveBeenCalledTimes(1);
});
it("emits json for daemon start/stop", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
serviceRestart.mockClear();
serviceStop.mockClear();
serviceIsLoaded.mockResolvedValue(true);
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "start", "--json"], { from: "user" });
await program.parseAsync(["daemon", "stop", "--json"], { from: "user" });
const jsonLines = runtimeLogs.filter((line) => line.trim().startsWith("{"));
const parsed = jsonLines.map((line) => JSON.parse(line) as { action?: string; ok?: boolean });
expect(parsed.some((entry) => entry.action === "start" && entry.ok === true)).toBe(true);
expect(parsed.some((entry) => entry.action === "stop" && entry.ok === true)).toBe(true);
});
});
+66 -16
View File
@@ -15,33 +15,59 @@ import {
import { resolveGatewayService } from "../../daemon/service.js";
import { buildServiceEnvironment } from "../../daemon/service-env.js";
import { defaultRuntime } from "../../runtime.js";
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
import { parsePort } from "./shared.js";
import type { DaemonInstallOptions } from "./types.js";
export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (resolveIsNixMode(process.env)) {
defaultRuntime.error("Nix mode detected; daemon install is disabled.");
const json = Boolean(opts.json);
const warnings: string[] = [];
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
hints?: string[];
warnings?: string[];
}) => {
if (!json) return;
emitDaemonActionJson({ action: "install", ...payload });
};
const fail = (message: string) => {
if (json) {
emit({ ok: false, error: message, warnings: warnings.length ? warnings : undefined });
} else {
defaultRuntime.error(message);
}
defaultRuntime.exit(1);
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon install is disabled.");
return;
}
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
fail("Invalid port");
return;
}
const port = portOverride ?? resolveGatewayPort(cfg);
if (!Number.isFinite(port) || port <= 0) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
fail("Invalid port");
return;
}
const runtimeRaw = opts.runtime ? String(opts.runtime) : DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!isGatewayDaemonRuntime(runtimeRaw)) {
defaultRuntime.error('Invalid --runtime (use "node" or "bun")');
defaultRuntime.exit(1);
fail('Invalid --runtime (use "node" or "bun")');
return;
}
@@ -50,14 +76,22 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
fail(`Gateway service check failed: ${String(err)}`);
return;
}
if (loaded) {
if (!opts.force) {
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot daemon install --force");
emit({
ok: true,
result: "already-installed",
message: `Gateway service already ${service.loadedText}.`,
service: buildDaemonServiceSnapshot(service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot daemon install --force");
}
return;
}
}
@@ -77,7 +111,10 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (runtimeRaw === "node") {
const systemNode = await resolveSystemNodeInfo({ env: process.env });
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
if (warning) defaultRuntime.log(warning);
if (warning) {
if (json) warnings.push(warning);
else defaultRuntime.log(warning);
}
}
const environment = buildServiceEnvironment({
env: process.env,
@@ -92,13 +129,26 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
try {
await service.install({
env: process.env,
stdout: process.stdout,
stdout,
programArguments,
workingDirectory,
environment,
});
} catch (err) {
defaultRuntime.error(`Gateway install failed: ${String(err)}`);
defaultRuntime.exit(1);
fail(`Gateway install failed: ${String(err)}`);
return;
}
let installed = true;
try {
installed = await service.isLoaded({ env: process.env });
} catch {
installed = true;
}
emit({
ok: true,
result: "installed",
service: buildDaemonServiceSnapshot(service, installed),
warnings: warnings.length ? warnings : undefined,
});
}
+199 -34
View File
@@ -1,72 +1,193 @@
import { resolveIsNixMode } from "../../config/paths.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { defaultRuntime } from "../../runtime.js";
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
import { renderGatewayServiceStartHints } from "./shared.js";
import type { DaemonLifecycleOptions } from "./types.js";
export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "uninstall", ...payload });
};
const fail = (message: string) => {
if (json) emit({ ok: false, error: message });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
export async function runDaemonUninstall() {
if (resolveIsNixMode(process.env)) {
defaultRuntime.error("Nix mode detected; daemon uninstall is disabled.");
defaultRuntime.exit(1);
fail("Nix mode detected; daemon uninstall is disabled.");
return;
}
const service = resolveGatewayService();
try {
await service.uninstall({ env: process.env, stdout: process.stdout });
await service.uninstall({ env: process.env, stdout });
} catch (err) {
defaultRuntime.error(`Gateway uninstall failed: ${String(err)}`);
defaultRuntime.exit(1);
fail(`Gateway uninstall failed: ${String(err)}`);
return;
}
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
}
emit({
ok: true,
result: "uninstalled",
service: buildDaemonServiceSnapshot(service, loaded),
});
}
export async function runDaemonStart() {
export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
hints?: string[];
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "start", ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (json) emit({ ok: false, error: message, hints });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
fail(`Gateway service check failed: ${String(err)}`);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.log(`Start with: ${hint}`);
const hints = renderGatewayServiceStartHints();
emit({
ok: true,
result: "not-loaded",
message: `Gateway service ${service.notLoadedText}.`,
hints,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of hints) {
defaultRuntime.log(`Start with: ${hint}`);
}
}
return;
}
try {
await service.restart({ env: process.env, stdout: process.stdout });
await service.restart({ env: process.env, stdout });
} catch (err) {
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.error(`Start with: ${hint}`);
}
defaultRuntime.exit(1);
const hints = renderGatewayServiceStartHints();
fail(`Gateway start failed: ${String(err)}`, hints);
return;
}
let started = true;
try {
started = await service.isLoaded({ env: process.env });
} catch {
started = true;
}
emit({
ok: true,
result: "started",
service: buildDaemonServiceSnapshot(service, started),
});
}
export async function runDaemonStop() {
export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "stop", ...payload });
};
const fail = (message: string) => {
if (json) emit({ ok: false, error: message });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
fail(`Gateway service check failed: ${String(err)}`);
return;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
emit({
ok: true,
result: "not-loaded",
message: `Gateway service ${service.notLoadedText}.`,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
}
return;
}
try {
await service.stop({ env: process.env, stdout: process.stdout });
await service.stop({ env: process.env, stdout });
} catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1);
fail(`Gateway stop failed: ${String(err)}`);
return;
}
let stopped = false;
try {
stopped = await service.isLoaded({ env: process.env });
} catch {
stopped = false;
}
emit({
ok: true,
result: "stopped",
service: buildDaemonServiceSnapshot(service, stopped),
});
}
/**
@@ -74,29 +195,73 @@ export async function runDaemonStop() {
* @returns `true` if restart succeeded, `false` if the service was not loaded.
* Throws/exits on check or restart failures.
*/
export async function runDaemonRestart(): Promise<boolean> {
export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promise<boolean> {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
hints?: string[];
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "restart", ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (json) emit({ ok: false, error: message, hints });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
fail(`Gateway service check failed: ${String(err)}`);
return false;
}
if (!loaded) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of renderGatewayServiceStartHints()) {
defaultRuntime.log(`Start with: ${hint}`);
const hints = renderGatewayServiceStartHints();
emit({
ok: true,
result: "not-loaded",
message: `Gateway service ${service.notLoadedText}.`,
hints,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
for (const hint of hints) {
defaultRuntime.log(`Start with: ${hint}`);
}
}
return false;
}
try {
await service.restart({ env: process.env, stdout: process.stdout });
await service.restart({ env: process.env, stdout });
let restarted = true;
try {
restarted = await service.isLoaded({ env: process.env });
} catch {
restarted = true;
}
emit({
ok: true,
result: "restarted",
service: buildDaemonServiceSnapshot(service, restarted),
});
return true;
} catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
defaultRuntime.exit(1);
const hints = renderGatewayServiceStartHints();
fail(`Gateway restart failed: ${String(err)}`, hints);
return false;
}
}
+13 -8
View File
@@ -47,6 +47,7 @@ export function registerDaemonCli(program: Command) {
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonInstall(opts);
});
@@ -54,29 +55,33 @@ export function registerDaemonCli(program: Command) {
daemon
.command("uninstall")
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonUninstall();
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonUninstall(opts);
});
daemon
.command("start")
.description("Start the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonStart();
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStart(opts);
});
daemon
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonStop();
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStop(opts);
});
daemon
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.action(async () => {
await runDaemonRestart();
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonRestart(opts);
});
// Build default deps (parity with other commands).
+43
View File
@@ -0,0 +1,43 @@
import { Writable } from "node:stream";
import type { GatewayService } from "../../daemon/service.js";
import { defaultRuntime } from "../../runtime.js";
export type DaemonAction = "install" | "uninstall" | "start" | "stop" | "restart";
export type DaemonActionResponse = {
ok: boolean;
action: DaemonAction;
result?: string;
message?: string;
error?: string;
hints?: string[];
warnings?: string[];
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
};
export function emitDaemonActionJson(payload: DaemonActionResponse) {
defaultRuntime.log(JSON.stringify(payload, null, 2));
}
export function buildDaemonServiceSnapshot(service: GatewayService, loaded: boolean) {
return {
label: service.label,
loaded,
loadedText: service.loadedText,
notLoadedText: service.notLoadedText,
};
}
export function createNullWriter(): Writable {
return new Writable({
write(_chunk, _encoding, callback) {
callback();
},
});
}
+5
View File
@@ -19,4 +19,9 @@ export type DaemonInstallOptions = {
runtime?: string;
token?: string;
force?: boolean;
json?: boolean;
};
export type DaemonLifecycleOptions = {
json?: boolean;
};
-7
View File
@@ -53,13 +53,6 @@ export function registerGatewayCli(program: Command) {
),
);
// Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias.
addGatewayRunCommand(
program
.command("gateway-daemon", { hidden: true })
.description("Run the WebSocket Gateway as a long-lived daemon"),
);
gatewayCallOpts(
gateway
.command("call")
+156 -3
View File
@@ -1,10 +1,17 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { installPluginFromArchive, installPluginFromNpmSpec } from "../plugins/install.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
installPluginFromArchive,
installPluginFromNpmSpec,
resolvePluginInstallDir,
} from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
import type { PluginRecord } from "../plugins/registry.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
@@ -22,6 +29,11 @@ export type PluginInfoOptions = {
json?: boolean;
};
export type PluginUpdateOptions = {
all?: boolean;
dryRun?: boolean;
};
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const status =
plugin.status === "loaded"
@@ -56,6 +68,16 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
return parts.join("\n");
}
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
try {
const raw = await fsp.readFile(path.join(dir, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { version?: unknown };
return typeof parsed.version === "string" ? parsed.version : undefined;
} catch {
return undefined;
}
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -118,6 +140,8 @@ export function registerPluginsCli(program: Command) {
defaultRuntime.error(`Plugin not found: ${id}`);
process.exit(1);
}
const cfg = loadConfig();
const install = cfg.plugins?.installs?.[plugin.id];
if (opts.json) {
defaultRuntime.log(JSON.stringify(plugin, null, 2));
@@ -151,6 +175,15 @@ export function registerPluginsCli(program: Command) {
lines.push(`Services: ${plugin.services.join(", ")}`);
}
if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`));
if (install) {
lines.push("");
lines.push(`Install: ${install.source}`);
if (install.spec) lines.push(`Spec: ${install.spec}`);
if (install.sourcePath) lines.push(`Source path: ${install.sourcePath}`);
if (install.installPath) lines.push(`Install path: ${install.installPath}`);
if (install.version) lines.push(`Recorded version: ${install.version}`);
if (install.installedAt) lines.push(`Installed at: ${install.installedAt}`);
}
defaultRuntime.log(lines.join("\n"));
});
@@ -223,7 +256,7 @@ export function registerPluginsCli(program: Command) {
process.exit(1);
}
const next = {
let next: ClawdbotConfig = {
...cfg,
plugins: {
...cfg.plugins,
@@ -236,6 +269,13 @@ export function registerPluginsCli(program: Command) {
},
},
};
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "archive",
sourcePath: resolved,
installPath: result.targetDir,
version: result.version,
});
await writeConfigFile(next);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
@@ -287,7 +327,7 @@ export function registerPluginsCli(program: Command) {
process.exit(1);
}
const next = {
let next: ClawdbotConfig = {
...cfg,
plugins: {
...cfg.plugins,
@@ -300,11 +340,124 @@ export function registerPluginsCli(program: Command) {
},
},
};
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "npm",
spec: raw,
installPath: result.targetDir,
version: result.version,
});
await writeConfigFile(next);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
});
plugins
.command("update")
.description("Update installed plugins (npm installs only)")
.argument("[id]", "Plugin id (omit with --all)")
.option("--all", "Update all tracked plugins", false)
.option("--dry-run", "Show what would change without writing", false)
.action(async (id: string | undefined, opts: PluginUpdateOptions) => {
const cfg = loadConfig();
const installs = cfg.plugins?.installs ?? {};
const targets = opts.all ? Object.keys(installs) : id ? [id] : [];
if (targets.length === 0) {
defaultRuntime.error("Provide a plugin id or use --all.");
process.exit(1);
}
let nextCfg = cfg;
let updatedCount = 0;
for (const pluginId of targets) {
const record = installs[pluginId];
if (!record) {
defaultRuntime.log(chalk.yellow(`No install record for "${pluginId}".`));
continue;
}
if (record.source !== "npm") {
defaultRuntime.log(
chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`),
);
continue;
}
if (!record.spec) {
defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (missing npm spec).`));
continue;
}
const installPath = record.installPath ?? resolvePluginInstallDir(pluginId);
const currentVersion = await readInstalledPackageVersion(installPath);
if (opts.dryRun) {
const probe = await installPluginFromNpmSpec({
spec: record.spec,
mode: "update",
dryRun: true,
expectedPluginId: pluginId,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
},
});
if (!probe.ok) {
defaultRuntime.log(chalk.red(`Failed to check ${pluginId}: ${probe.error}`));
continue;
}
const nextVersion = probe.version ?? "unknown";
const currentLabel = currentVersion ?? "unknown";
if (currentVersion && probe.version && currentVersion === probe.version) {
defaultRuntime.log(`${pluginId} is up to date (${currentLabel}).`);
} else {
defaultRuntime.log(
`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`,
);
}
continue;
}
const result = await installPluginFromNpmSpec({
spec: record.spec,
mode: "update",
expectedPluginId: pluginId,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
},
});
if (!result.ok) {
defaultRuntime.log(chalk.red(`Failed to update ${pluginId}: ${result.error}`));
continue;
}
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
nextCfg = recordPluginInstall(nextCfg, {
pluginId,
source: "npm",
spec: record.spec,
installPath: result.targetDir,
version: nextVersion,
});
updatedCount += 1;
const currentLabel = currentVersion ?? "unknown";
const nextLabel = nextVersion ?? "unknown";
if (currentVersion && nextVersion && currentVersion === nextVersion) {
defaultRuntime.log(`${pluginId} already at ${currentLabel}.`);
} else {
defaultRuntime.log(`Updated ${pluginId}: ${currentLabel} → ${nextLabel}.`);
}
}
if (updatedCount > 0) {
await writeConfigFile(nextCfg);
defaultRuntime.log("Restart the gateway to load plugins.");
}
});
plugins
.command("doctor")
.description("Report plugin load issues")
+2
View File
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { registerBrowserCli } from "../browser-cli.js";
import { registerConfigCli } from "../config-cli.js";
import { createProgramContext } from "./context.js";
import { configureProgramHelp } from "./help.js";
import { registerPreActionHooks } from "./preaction.js";
@@ -22,6 +23,7 @@ export function buildProgram() {
registerSetupCommand(program);
registerOnboardCommand(program);
registerConfigureCommand(program);
registerConfigCli(program);
registerMaintenanceCommands(program);
registerMessageCommands(program, ctx);
registerAgentCommands(program, {
+39 -48
View File
@@ -9,54 +9,45 @@ import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
export function registerConfigureCommand(program: Command) {
const register = (name: "configure" | "config") => {
program
.command(name)
.description(
name === "config"
? "Alias for `clawdbot configure`"
: "Interactive prompt to set up credentials, devices, and agent defaults",
)
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/configure", "docs.clawd.bot/cli/configure")}\n`,
)
.option(
"--section <section>",
`Configuration sections (repeatable). Options: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}`,
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.action(async (opts) => {
try {
const sections: string[] = Array.isArray(opts.section)
? opts.section
.map((value: unknown) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
if (sections.length === 0) {
await configureCommand(defaultRuntime);
return;
}
const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never));
if (invalid.length > 0) {
defaultRuntime.error(
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`,
);
defaultRuntime.exit(1);
return;
}
await configureCommandWithSections(sections as never, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
program
.command("configure")
.description("Interactive prompt to set up credentials, devices, and agent defaults")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/configure", "docs.clawd.bot/cli/configure")}\n`,
)
.option(
"--section <section>",
`Configuration sections (repeatable). Options: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}`,
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.action(async (opts) => {
try {
const sections: string[] = Array.isArray(opts.section)
? opts.section
.map((value: unknown) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
if (sections.length === 0) {
await configureCommand(defaultRuntime);
return;
}
});
};
register("configure");
register("config");
const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never));
if (invalid.length > 0) {
defaultRuntime.error(
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`,
);
defaultRuntime.exit(1);
return;
}
await configureCommandWithSections(sections as never, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}
@@ -372,7 +372,7 @@ describe("doctor command", () => {
);
}),
).toBe(true);
}, 10_000);
}, 20_000);
it("warns when extra workspace directories exist", async () => {
readConfigFileSnapshot.mockResolvedValue({
@@ -68,6 +68,9 @@ describe("ensureOnboardingPluginInstalled", () => {
expect(result.installed).toBe(true);
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
expect(result.cfg.plugins?.allow).toContain("zalo");
expect(result.cfg.plugins?.installs?.zalo?.source).toBe("npm");
expect(result.cfg.plugins?.installs?.zalo?.spec).toBe("@clawdbot/zalo");
expect(result.cfg.plugins?.installs?.zalo?.installPath).toBe("/tmp/zalo");
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({ spec: "@clawdbot/zalo" }),
);
@@ -4,6 +4,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { recordPluginInstall } from "../../plugins/installs.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import { installPluginFromNpmSpec } from "../../plugins/install.js";
import type { RuntimeEnv } from "../../runtime.js";
@@ -158,6 +159,13 @@ export async function ensureOnboardingPluginInstalled(params: {
if (result.ok) {
next = ensurePluginEnabled(next, result.pluginId);
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "npm",
spec: entry.install.npmSpec,
installPath: result.targetDir,
version: result.version,
});
return { cfg: next, installed: true };
}
+3 -2
View File
@@ -119,10 +119,11 @@ export async function statusAllCommand(
const localFallbackAuth = resolveProbeAuth("local");
const remoteAuth = resolveProbeAuth("remote");
const probeAuth = isRemoteMode && !remoteUrlMissing ? remoteAuth : localFallbackAuth;
const gatewayProbe = await probeGateway({
url: connection.url,
auth: remoteUrlMissing ? localFallbackAuth : remoteAuth,
auth: probeAuth,
timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000),
}).catch(() => null);
const gatewayReachable = gatewayProbe?.ok === true;
@@ -291,7 +292,7 @@ export async function statusAllCommand(
? `unreachable (${gatewayProbe.error})`
: "unreachable";
const gatewayAuth = gatewayReachable
? ` · auth ${formatGatewayAuthUsed(remoteUrlMissing ? localFallbackAuth : remoteAuth)}`
? ` · auth ${formatGatewayAuthUsed(probeAuth)}`
: "";
const gatewaySelfLine =
gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform
+15 -7
View File
@@ -28,13 +28,18 @@ describe("config discord", () => {
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["clawd-dm"],
},
guilds: {
"123": {
slug: "friends-of-clawd",
requireMention: false,
groupEnabled: true,
groupChannels: ["clawd-dm"],
},
actions: {
emojiUploads: true,
stickerUploads: false,
channels: true,
},
guilds: {
"123": {
slug: "friends-of-clawd",
requireMention: false,
users: ["steipete"],
channels: {
general: { allow: true },
@@ -57,6 +62,9 @@ describe("config discord", () => {
expect(cfg.channels?.discord?.enabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupEnabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
expect(cfg.channels?.discord?.actions?.emojiUploads).toBe(true);
expect(cfg.channels?.discord?.actions?.stickerUploads).toBe(false);
expect(cfg.channels?.discord?.actions?.channels).toBe(true);
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
});
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more