feat(apps): review durable approvals on mobile (#104913)

* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e561ceadc9da799e7f7803f9905844f)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a606197227b0221d5f21f0fb92bdce5bf57eeec)
(cherry picked from commit 6f0c3865677f5988f4d1bccce8e46a0949c18ea2)
(cherry picked from commit 784a5857b7ade84b42866b8b7789d315ff04eadd)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
This commit is contained in:
Peter Steinberger
2026-07-11 19:59:07 -07:00
committed by GitHub
parent 9c33a5acfb
commit ac89350327
63 changed files with 13613 additions and 2083 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@
## Unreleased
Routes exec approval review through the Gateway's durable approval records, including first-answer-wins results from other authorized surfaces, fail-closed reconciliation after ambiguous writes, and compatibility with older Gateway v4 peers.
Shows the localized app version, Git commit, and build date together on the About screen, with real provenance in repository-backed debug builds.
Recovers Android permission prompts after timeouts or cancellation without exhausting future requests. Thanks @NianJiuZst.
@@ -1,17 +1,23 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.concurrent.atomic.AtomicLong
data class GatewayExecApprovalSummary(
val id: String,
val commandText: String,
val commandPreview: String?,
val warningText: String?,
val allowedDecisions: List<String>,
val host: String?,
val nodeId: String?,
@@ -22,6 +28,192 @@ data class GatewayExecApprovalSummary(
val errorText: String? = null,
)
internal enum class GatewayApprovalTerminalStatus {
Allowed,
Denied,
Expired,
Cancelled,
}
internal sealed interface GatewayExecApprovalSnapshot {
val id: String
data class Pending(
val summary: GatewayExecApprovalSummary,
) : GatewayExecApprovalSnapshot {
override val id: String = summary.id
}
data class Terminal(
override val id: String,
val status: GatewayApprovalTerminalStatus,
val decision: String?,
) : GatewayExecApprovalSnapshot
}
internal data class GatewayExecApprovalResolution(
val applied: Boolean,
val approval: GatewayExecApprovalSnapshot.Terminal,
val attribution: GatewayExecApprovalResolutionAttribution =
if (applied) GatewayExecApprovalResolutionAttribution.AppliedHere else GatewayExecApprovalResolutionAttribution.PriorResponse,
)
internal enum class GatewayExecApprovalResolutionAttribution {
AppliedHere,
PriorResponse,
Unknown,
}
private val execApprovalNoticePublications = AtomicLong()
data class GatewayExecApprovalNotice(
val approvalId: String,
val message: String,
val warning: Boolean,
// Distinct per constructed notice: a re-requested approval can lose again with an
// identical id/message, and the dismiss compareAndSet must not treat the stale
// banner as equal to its replacement.
val publication: Long = execApprovalNoticePublications.incrementAndGet(),
)
internal fun gatewayExecApprovalResolutionNotice(
resolution: GatewayExecApprovalResolution,
): GatewayExecApprovalNotice =
when (resolution.approval.status) {
GatewayApprovalTerminalStatus.Allowed -> {
val saved = resolution.approval.decision == "allow-always"
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalAllowedMessage(attribution = resolution.attribution, saved = saved),
warning = false,
)
}
GatewayApprovalTerminalStatus.Denied ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalDeniedMessage(resolution.attribution),
warning = true,
)
GatewayApprovalTerminalStatus.Expired ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalTerminalMessage(resolution.approval.status),
warning = true,
)
GatewayApprovalTerminalStatus.Cancelled ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalTerminalMessage(resolution.approval.status),
warning = true,
)
}
private fun gatewayExecApprovalAllowedMessage(
attribution: GatewayExecApprovalResolutionAttribution,
saved: Boolean,
): String {
if (attribution == GatewayExecApprovalResolutionAttribution.AppliedHere) {
if (saved) return "Approval allowed and saved."
return "Approval allowed once."
}
if (attribution == GatewayExecApprovalResolutionAttribution.PriorResponse) {
if (saved) return "A prior response already allowed this command and saved the choice."
return "A prior response already allowed this command once."
}
if (saved) return "Gateway recorded approval and saved the choice."
return "Gateway recorded approval once."
}
private fun gatewayExecApprovalDeniedMessage(attribution: GatewayExecApprovalResolutionAttribution): String =
when (attribution) {
GatewayExecApprovalResolutionAttribution.AppliedHere -> "Approval denied."
GatewayExecApprovalResolutionAttribution.PriorResponse -> "A prior response already denied this approval."
GatewayExecApprovalResolutionAttribution.Unknown -> "Gateway recorded a denial."
}
private fun gatewayExecApprovalTerminalMessage(status: GatewayApprovalTerminalStatus): String =
when (status) {
GatewayApprovalTerminalStatus.Expired -> "This approval expired before it could be resolved."
GatewayApprovalTerminalStatus.Cancelled -> "This approval was cancelled before it could be resolved."
else -> error("approval is not expired or cancelled")
}
internal fun gatewayExecApprovalRemoteTerminalNotice(
approval: GatewayExecApprovalSnapshot.Terminal,
): GatewayExecApprovalNotice =
gatewayExecApprovalResolutionNotice(
GatewayExecApprovalResolution(applied = false, approval = approval),
)
internal fun gatewayExecApprovalPriorResolutionNotice(id: String): GatewayExecApprovalNotice =
GatewayExecApprovalNotice(
approvalId = id,
message = gatewayExecApprovalPriorResolutionMessage(),
warning = true,
)
private fun gatewayExecApprovalPriorResolutionMessage(): String = "A prior response already resolved this approval."
internal fun normalizeGatewayExecApprovalDecision(value: String): String? =
when (value) {
"allow-once" -> "allow-once"
"allow-always" -> "allow-always"
"deny" -> "deny"
else -> null
}
/** Parses the terminal winner from an authenticated Gateway resolution event. */
internal fun parseGatewayExecApprovalResolvedEventTerminal(
payloadJson: String,
json: Json,
): GatewayExecApprovalSnapshot.Terminal? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
val id = root.strictApprovalId("id") ?: return null
val decision = root.strictString("decision")?.let(::normalizeGatewayExecApprovalDecision) ?: return null
legacyGatewayExecApprovalTerminal(id, decision)
} catch (_: Throwable) {
null
}
internal enum class GatewayApprovalRpcFamily {
Canonical,
Legacy,
Unavailable,
}
/**
* Selects one read/write family for the lifetime of a Gateway hello catalog.
* Legacy exec.approval.* serves shipped Gateway v4 peers until the minimum supported
* Gateway advertises approval.get/approval.resolve.
*/
internal fun selectGatewayApprovalRpcFamily(methods: Set<String>): GatewayApprovalRpcFamily {
val hasCanonicalGet = "approval.get" in methods
val hasCanonicalResolve = "approval.resolve" in methods
if (hasCanonicalGet && hasCanonicalResolve) return GatewayApprovalRpcFamily.Canonical
if (
!hasCanonicalGet &&
!hasCanonicalResolve &&
"exec.approval.get" in methods &&
"exec.approval.resolve" in methods
) {
return GatewayApprovalRpcFamily.Legacy
}
return GatewayApprovalRpcFamily.Unavailable
}
internal fun buildGatewayExecApprovalGetParams(id: String): JsonObject = buildJsonObject { put("id", id) }
internal fun buildGatewayExecApprovalResolveParams(
id: String,
decision: String,
): JsonObject =
buildJsonObject {
put("id", id)
put("kind", "exec")
put("decision", decision)
}
internal fun parseGatewayExecApprovalListPayload(
payloadJson: String,
json: Json,
@@ -37,127 +229,323 @@ internal fun parseGatewayExecApprovalListPayload(
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
val request = obj["request"].asObjectOrNull()
val commandText = gatewayExecApprovalListCommandText(obj, request)
val id = obj.strictApprovalId("id") ?: return null
val createdAtMs = obj.strictNonNegativeLong("createdAtMs") ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
// The legacy list is discovery-only. Its embedded request can contain runtime-only
// details, so rendering waits for the reviewer-safe unified approval projection.
return GatewayExecApprovalSummary(
id = id,
commandText = gatewayExecApprovalCommandRequestText(),
commandPreview = null,
warningText = null,
allowedDecisions = emptyList(),
host = null,
nodeId = null,
agentId = null,
createdAtMs = createdAtMs,
expiresAtMs = expiresAtMs,
)
}
private fun gatewayExecApprovalCommandRequestText(): String = "Command request"
internal fun parseGatewayExecApprovalGetPayload(
payloadJson: String,
json: Json,
expectedId: String,
): GatewayExecApprovalSnapshot? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
if (!root.hasExactKeys(APPROVAL_GET_RESULT_KEYS)) return null
parseGatewayExecApprovalSnapshot(root["approval"].asObjectOrNull() ?: return null)
?.takeIf { it.id == expectedId }
} catch (_: Throwable) {
null
}
internal fun parseGatewayExecApprovalResolvePayload(
payloadJson: String,
json: Json,
expectedId: String,
expectedDecision: String,
): GatewayExecApprovalResolution? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
if (!root.hasExactKeys(APPROVAL_RESOLVE_RESULT_KEYS)) return null
val applied = root.strictBoolean("applied") ?: return null
val approval =
parseGatewayExecApprovalSnapshot(root["approval"].asObjectOrNull() ?: return null)
as? GatewayExecApprovalSnapshot.Terminal
?: return null
if (approval.id != expectedId) return null
// `applied=true` claims this write won. A different returned decision is an
// ambiguous write outcome, never evidence that the attempted approval applied.
if (applied && approval.decision != expectedDecision) return null
GatewayExecApprovalResolution(applied = applied, approval = approval)
} catch (_: Throwable) {
null
}
/** Parses the shipped pre-unified exec reviewer projection for old Gateway v4 peers. */
internal fun parseLegacyGatewayExecApprovalGetPayload(
payloadJson: String,
json: Json,
expectedId: String,
createdAtMs: Long?,
): GatewayExecApprovalSnapshot.Pending? =
try {
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
val id = obj.strictApprovalId("id") ?: return null
if (id != expectedId) return null
val normalizedCreatedAtMs = createdAtMs?.takeIf { it >= 0 } ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
val commandText = obj.strictNonEmptyString("commandText") ?: return null
val commandPreview = obj.optionalString("commandPreview") ?: return null
val host = obj.optionalString("host") ?: return null
val nodeId = obj.optionalString("nodeId", requireNonEmpty = true) ?: return null
val agentId = obj.optionalString("agentId", requireNonEmpty = true) ?: return null
val allowedDecisions = parseAllowedDecisions(obj["allowedDecisions"] as? JsonArray) ?: return null
GatewayExecApprovalSnapshot.Pending(
GatewayExecApprovalSummary(
id = id,
commandText = commandText,
commandPreview = commandPreview.value?.takeIf { it != commandText },
warningText = null,
allowedDecisions = allowedDecisions,
host = host.value,
nodeId = nodeId.value,
agentId = agentId.value,
createdAtMs = normalizedCreatedAtMs,
expiresAtMs = expiresAtMs,
),
)
} catch (_: Throwable) {
null
}
internal fun parseLegacyGatewayExecApprovalResolvePayload(
payloadJson: String,
json: Json,
): Boolean =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return false
root.strictBoolean("ok") == true
} catch (_: Throwable) {
false
}
internal fun legacyGatewayExecApprovalTerminal(
id: String,
decision: String,
): GatewayExecApprovalSnapshot.Terminal? {
val status =
when (decision) {
"allow-once", "allow-always" -> GatewayApprovalTerminalStatus.Allowed
"deny" -> GatewayApprovalTerminalStatus.Denied
else -> return null
}
return GatewayExecApprovalSnapshot.Terminal(id, status, decision)
}
private fun parseGatewayExecApprovalSnapshot(obj: JsonObject): GatewayExecApprovalSnapshot? {
val status = obj.strictString("status") ?: return null
val expectedKeys = APPROVAL_SNAPSHOT_KEYS_BY_STATUS[status] ?: return null
if (!obj.hasExactKeys(expectedKeys)) return null
val id = obj.strictApprovalId("id") ?: return null
obj.strictNonEmptyString("urlPath") ?: return null
val createdAtMs = obj.strictNonNegativeLong("createdAtMs") ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
val presentation = obj["presentation"].asObjectOrNull() ?: return null
val summary = parseGatewayExecApprovalPresentation(id, createdAtMs, expiresAtMs, presentation) ?: return null
return when (status) {
"pending" -> GatewayExecApprovalSnapshot.Pending(summary)
"allowed" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Allowed,
expectedDecision = setOf("allow-once", "allow-always"),
)?.takeIf { terminal ->
terminal.decision?.let(summary.allowedDecisions::contains) == true
}
"denied" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Denied,
expectedDecision = setOf("deny"),
)
"expired" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Expired,
expectedDecision = null,
)
"cancelled" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Cancelled,
expectedDecision = null,
)
else -> null
}
}
private fun parseGatewayExecApprovalPresentation(
id: String,
createdAtMs: Long,
expiresAtMs: Long,
presentation: JsonObject,
): GatewayExecApprovalSummary? {
if (!presentation.hasOnlyKeys(EXEC_APPROVAL_PRESENTATION_KEYS)) return null
if (!presentation.keys.containsAll(EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS)) return null
// A unified lookup can return other approval owners. Android's exec inbox must
// never reinterpret plugin copy or metadata as an executable command request.
if (presentation.strictString("kind") != "exec") return null
val commandText = presentation.strictNonEmptyString("commandText") ?: return null
val allowedDecisions = parseAllowedDecisions(presentation["allowedDecisions"] as? JsonArray) ?: return null
val commandPreview = presentation.optionalString("commandPreview") ?: return null
val warningText = presentation.optionalString("warningText") ?: return null
val host = presentation.optionalString("host") ?: return null
val nodeId = presentation.optionalString("nodeId", requireNonEmpty = true) ?: return null
val agentId = presentation.optionalString("agentId", requireNonEmpty = true) ?: return null
return GatewayExecApprovalSummary(
id = id,
commandText = commandText,
commandPreview = gatewayExecApprovalListCommandPreview(obj, request, commandText),
allowedDecisions = emptyList(),
host =
request
?.get("host")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
nodeId =
request
?.get("nodeId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
agentId =
request
?.get("agentId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
createdAtMs = obj.long("createdAtMs"),
expiresAtMs = obj.long("expiresAtMs"),
)
}
internal fun parseGatewayExecApprovalDetail(
obj: JsonObject,
createdAtMs: Long?,
): GatewayExecApprovalSummary? {
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
return GatewayExecApprovalSummary(
id = id,
commandText =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request",
commandPreview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
allowedDecisions = gatewayExecApprovalAllowedDecisions(obj),
host = obj["host"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
nodeId = obj["nodeId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
agentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
commandPreview = commandPreview.value?.takeIf { it != commandText },
warningText = warningText.value,
allowedDecisions = allowedDecisions,
host = host.value,
nodeId = nodeId.value,
agentId = agentId.value,
createdAtMs = createdAtMs,
expiresAtMs = obj.long("expiresAtMs"),
expiresAtMs = expiresAtMs,
)
}
private fun gatewayExecApprovalListCommandText(
private fun parseTerminalApproval(
obj: JsonObject,
request: JsonObject?,
): String =
obj["commandText"]
.asStringOrNull()
?.trim()
id: String,
status: GatewayApprovalTerminalStatus,
expectedDecision: Set<String>?,
): GatewayExecApprovalSnapshot.Terminal? {
obj.strictNonNegativeLong("resolvedAtMs") ?: return null
val reason = obj.strictString("reason") ?: return null
if (reason !in APPROVAL_TERMINAL_REASONS) return null
val decision = obj.strictString("decision")
if (expectedDecision == null) {
if (obj.containsKey("decision")) return null
} else if (decision !in expectedDecision) {
return null
}
return GatewayExecApprovalSnapshot.Terminal(id = id, status = status, decision = decision)
}
private fun parseAllowedDecisions(items: JsonArray?): List<String>? {
if (items == null || items.size !in 1..3) return null
val decisions = items.map { item -> item.strictString() ?: return null }
if (decisions.distinct().size != decisions.size || "deny" !in decisions) return null
return decisions.takeIf { values -> values.all { it in APPROVAL_DECISIONS } }
}
private data class OptionalString(
val value: String?,
)
private fun JsonObject.optionalString(
key: String,
requireNonEmpty: Boolean = false,
): OptionalString? {
val value = this[key]
if (value == null || value is JsonNull) return OptionalString(null)
val string = value.strictString() ?: return null
if (requireNonEmpty && string.isEmpty()) return null
return OptionalString(string)
}
private fun JsonObject.strictString(key: String): String? = this[key].strictString()
private fun JsonElement?.strictString(): String? =
(this as? JsonPrimitive)
?.takeIf { it.isString }
?.content
private fun JsonObject.strictNonEmptyString(key: String): String? =
strictString(key)
?.takeIf { it.isNotEmpty() }
?: request
?.get("command")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request"
private fun gatewayExecApprovalListCommandPreview(
obj: JsonObject,
request: JsonObject?,
commandText: String,
): String? {
val preview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("commandPreview")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
return preview?.takeIf { it != commandText }
}
private fun JsonObject.strictApprovalId(key: String): String? =
strictString(key)
?.takeIf(::isWellFormedGatewayApprovalId)
private fun gatewayExecApprovalAllowedDecisions(request: JsonObject?): List<String> {
val explicit = parseGatewayExecApprovalDecisions(request?.get("allowedDecisions") as? JsonArray)
if (explicit.isNotEmpty()) return explicit
val allowed =
if (request
?.get("ask")
.asStringOrNull()
?.trim()
?.lowercase() == "always"
) {
listOf("allow-once", "deny")
} else {
listOf("allow-once", "allow-always", "deny")
}
val unavailable = parseGatewayExecApprovalDecisions(request?.get("unavailableDecisions") as? JsonArray).toSet()
return allowed.filterNot { it == "allow-always" && it in unavailable }
}
private fun JsonObject.strictBoolean(key: String): Boolean? =
(this[key] as? JsonPrimitive)
?.takeUnless { it.isString }
?.booleanOrNull
private fun parseGatewayExecApprovalDecisions(items: JsonArray?): List<String> =
items
?.mapNotNull { item ->
when (item.asStringOrNull()?.trim()) {
"allow-once" -> "allow-once"
"allow-always" -> "allow-always"
"deny" -> "deny"
else -> null
private fun JsonObject.strictNonNegativeLong(key: String): Long? =
(this[key] as? JsonPrimitive)
?.takeUnless { it.isString }
?.longOrNull
?.takeIf { it >= 0 }
// Closed-schema contract: the gateway protocol declares approval results with
// additionalProperties:false, so additive protocol changes hard-fail old clients by design.
private fun JsonObject.hasExactKeys(expected: Set<String>): Boolean = keys == expected
private fun JsonObject.hasOnlyKeys(allowed: Set<String>): Boolean = keys.all(allowed::contains)
internal fun isWellFormedGatewayApprovalId(value: String): Boolean {
if (value.isEmpty() || value == "." || value == "..") return false
var index = 0
while (index < value.length) {
val current = value[index]
when {
Character.isHighSurrogate(current) -> {
if (index + 1 >= value.length || !Character.isLowSurrogate(value[index + 1])) return false
index += 2
}
}?.distinct()
.orEmpty()
Character.isLowSurrogate(current) -> return false
else -> index += 1
}
}
return true
}
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
private val APPROVAL_GET_RESULT_KEYS = setOf("approval")
private val APPROVAL_RESOLVE_RESULT_KEYS = setOf("applied", "approval")
private val APPROVAL_SNAPSHOT_COMMON_KEYS =
setOf("id", "urlPath", "status", "createdAtMs", "expiresAtMs", "presentation")
private val APPROVAL_SNAPSHOT_KEYS_BY_STATUS =
mapOf(
"pending" to APPROVAL_SNAPSHOT_COMMON_KEYS,
"allowed" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason", "decision"),
"denied" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason", "decision"),
"expired" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason"),
"cancelled" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason"),
)
private val EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS = setOf("kind", "commandText", "allowedDecisions")
private val EXEC_APPROVAL_PRESENTATION_KEYS =
EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS +
setOf("commandPreview", "warningText", "host", "nodeId", "agentId")
private val APPROVAL_DECISIONS = setOf("allow-once", "allow-always", "deny")
private val APPROVAL_TERMINAL_REASONS =
setOf(
"user",
"timeout",
"malformed-verdict",
"no-route",
"run-aborted",
"gateway-restart",
"storage-corrupt",
)
@@ -395,6 +395,7 @@ class MainViewModel(
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = runtimeState(initial = emptyList()) { it.execApprovals }
val execApprovalsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.execApprovalsRefreshing }
val execApprovalsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.execApprovalsErrorText }
val execApprovalsNotice: StateFlow<GatewayExecApprovalNotice?> = runtimeState(initial = null) { it.execApprovalsNotice }
val canvas: CanvasController
get() = ensureRuntime().canvas
@@ -966,6 +967,10 @@ class MainViewModel(
ensureRuntime().resolveExecApproval(id = id, decision = decision)
}
fun dismissExecApprovalsNotice(expected: GatewayExecApprovalNotice) {
ensureRuntime().dismissExecApprovalsNotice(expected)
}
fun refreshChannels() {
ensureRuntime().refreshChannels()
}
@@ -25,6 +25,9 @@ import ai.openclaw.app.gateway.GatewayDiscovery
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayRegistryEntry
import ai.openclaw.app.gateway.GatewayRegistryEntryKind
import ai.openclaw.app.gateway.GatewayRequestDefinitiveFailure
import ai.openclaw.app.gateway.GatewayRequestNotEnqueued
import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown
import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
@@ -121,6 +124,23 @@ private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L
private const val CRON_RUN_TRACKING_POLL_MS = 2_000L
private const val OperatorAdminScope = "operator.admin"
private fun execApprovalOutcomeUnknownMessage(): String = "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified."
private fun execApprovalStillPendingMessage(): String = "The Gateway still shows this approval as pending. Review it before trying again."
private fun execApprovalLoadDetailsFailureMessage(): String = "Could not load approval details. Refresh and try again."
private fun execApprovalLoadFailureMessage(): String = "Could not load approvals."
private fun execApprovalResolveFailureMessage(): String = "Could not resolve approval. Refresh and try again."
internal typealias GatewayDataRequestOverride =
suspend (stableId: String, method: String, paramsJson: String?) -> String
private class ExecApprovalWriteOutcomeUnknown : IllegalStateException("approval resolve response was not authoritative")
private class GatewayApprovalRpcUnavailable : IllegalStateException("Gateway approval RPC catalog is inconsistent")
private enum class SkillWorkshopGatewayAction(
val methodSuffix: String,
val expectedStatus: String,
@@ -378,6 +398,19 @@ class NodeRuntime private constructor(
val generation: Long,
)
private data class GatewayMethodsSnapshot(
val approvalRpcFamily: GatewayApprovalRpcFamily,
val epoch: Long,
)
private class PendingExecApprovalWrite(
val stableId: String,
val id: String,
val decision: String,
) {
@Volatile var requestInFlight: Boolean = true
}
private data class CronActionResult(
val message: String,
val kind: GatewayCronNoticeKind,
@@ -807,9 +840,20 @@ class NodeRuntime private constructor(
val execApprovalsRefreshing: StateFlow<Boolean> = _execApprovalsRefreshing.asStateFlow()
private val _execApprovalsErrorText = MutableStateFlow<String?>(null)
val execApprovalsErrorText: StateFlow<String?> = _execApprovalsErrorText.asStateFlow()
private val _execApprovalsNotice = MutableStateFlow<GatewayExecApprovalNotice?>(null)
val execApprovalsNotice: StateFlow<GatewayExecApprovalNotice?> = _execApprovalsNotice.asStateFlow()
private val execApprovalsRefreshSeq = AtomicLong(0)
private val execApprovalsStateLock = Any()
private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val pendingExecApprovalWrites = mutableMapOf<String, PendingExecApprovalWrite>()
// Each hello pins one approval RPC family. The epoch prevents an old socket's
// response from publishing into a replacement socket on the same stable endpoint.
private val gatewayMethodsLock = Any()
private var gatewayApprovalRpcFamily = GatewayApprovalRpcFamily.Unavailable
private var gatewayMethodsEpoch = 0L
@Volatile internal var gatewayDataRequestOverrideForTests: GatewayDataRequestOverride? = null
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
private val _channelsRefreshing = MutableStateFlow(false)
@@ -870,6 +914,7 @@ class NodeRuntime private constructor(
_remoteAddress.value = hello.remoteAddress
_gatewayVersion.value = hello.serverVersion
_gatewayUpdateAvailable.value = hello.updateAvailable
replaceGatewayMethods(hello.methods)
_operatorScopes.value = normalizeOperatorScopes(hello.authScopes)
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
@@ -919,6 +964,7 @@ class NodeRuntime private constructor(
_remoteAddress.value = null
_gatewayVersion.value = null
_gatewayUpdateAvailable.value = null
replaceGatewayMethods(emptySet())
_operatorScopes.value = emptyList()
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
_gatewayDefaultAgentId.value = null
@@ -967,9 +1013,13 @@ class NodeRuntime private constructor(
)
invalidateExecApprovalRefreshes()
resolvedExecApprovalIds.clear()
if (retirePendingCronRuns) {
synchronized(execApprovalsStateLock) { pendingExecApprovalWrites.clear() }
}
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
_execApprovalsErrorText.value = null
_execApprovalsNotice.value = null
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
_channelsRefreshing.value = false
_channelsErrorText.value = null
@@ -1740,14 +1790,21 @@ class NodeRuntime private constructor(
id: String,
decision: String,
) {
val normalizedId = id.trim()
val normalizedDecision = decision.trim()
if (normalizedId.isEmpty() || normalizedDecision.isEmpty()) return
val exactId = id.takeIf(::isWellFormedGatewayApprovalId)
val normalizedDecision = normalizeGatewayExecApprovalDecision(decision)
if (exactId == null || normalizedDecision == null) return
scope.launch {
resolveExecApprovalOnGateway(id = normalizedId, decision = normalizedDecision)
resolveExecApprovalOnGateway(id = exactId, decision = normalizedDecision)
}
}
fun dismissExecApprovalsNotice(expected: GatewayExecApprovalNotice) {
// Atomic conditional clear: not every notice publisher holds execApprovalsStateLock
// (refreshExecApprovalFromGateway's terminal branch), so a locked check-then-clear
// could still let a stale dismiss clobber a freshly published replacement.
_execApprovalsNotice.compareAndSet(expected, null)
}
fun refreshChannels() {
if (mode == NodeRuntimeMode.ScreenshotFixture) return
scope.launch {
@@ -3658,7 +3715,14 @@ class NodeRuntime private constructor(
when (event) {
"exec.approval.requested" -> {
val approvalId = parseExecApprovalEventId(payloadJson)
approvalId?.let(resolvedExecApprovalIds::remove)
approvalId?.let { id ->
resolvedExecApprovalIds.remove(id)
synchronized(execApprovalsStateLock) {
if (_execApprovalsNotice.value?.approvalId == id) {
_execApprovalsNotice.value = null
}
}
}
scope.launch {
if (approvalId == null) {
refreshExecApprovalsFromGateway()
@@ -3669,7 +3733,27 @@ class NodeRuntime private constructor(
}
"exec.approval.resolved" -> {
val approvalId = parseExecApprovalEventId(payloadJson) ?: return
markExecApprovalResolved(approvalId)
val methodsSnapshot = captureGatewayMethods()
when (methodsSnapshot.approvalRpcFamily) {
GatewayApprovalRpcFamily.Canonical -> {
// Resolve events can race the local request or come from another surface.
// Canonical readback preserves the durable winner across that race.
scope.launch { refreshExecApprovalFromGateway(approvalId) }
}
GatewayApprovalRpcFamily.Legacy,
GatewayApprovalRpcFamily.Unavailable,
-> {
val terminal = parseGatewayExecApprovalResolvedEventTerminal(payloadJson ?: return, json)
synchronized(execApprovalsStateLock) {
if (terminal != null && _execApprovals.value.any { it.id == approvalId }) {
_execApprovalsNotice.value = gatewayExecApprovalRemoteTerminalNotice(terminal)
}
// Noncanonical peers cannot prove terminal state by readback. The
// authenticated event is the fail-closed tombstone for this exact ID.
markExecApprovalResolved(approvalId)
}
}
}
}
}
}
@@ -3679,9 +3763,10 @@ class NodeRuntime private constructor(
payloadJson
?.let { json.parseToJsonElement(it).asObjectOrNull() }
?.get("id")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { it as? JsonPrimitive }
?.takeIf { it.isString }
?.content
?.takeIf(::isWellFormedGatewayApprovalId)
} catch (_: Throwable) {
null
}
@@ -3721,11 +3806,48 @@ class NodeRuntime private constructor(
method: String,
paramsJson: String?,
): String {
val response = operatorSession.requestForEndpoint(gatewayScope.stableId, method, paramsJson)
val response =
gatewayDataRequestOverrideForTests?.invoke(gatewayScope.stableId, method, paramsJson)
?: operatorSession.requestForEndpoint(gatewayScope.stableId, method, paramsJson)
if (!isGatewayDataScopeCurrent(gatewayScope)) throw CancellationException("gateway scope changed")
return response
}
private suspend fun requestGatewayApprovalData(
gatewayScope: GatewayDataScope,
methodsSnapshot: GatewayMethodsSnapshot,
method: String,
paramsJson: String?,
preserveWriteFailureAcrossEpoch: Boolean = false,
): String {
if (!isGatewayMethodsSnapshotCurrent(methodsSnapshot)) {
if (preserveWriteFailureAcrossEpoch) {
throw GatewayRequestNotEnqueued("gateway connection changed before request")
}
throw CancellationException("gateway connection changed")
}
return try {
val response = requestGatewayData(gatewayScope, method, paramsJson)
if (!isGatewayMethodsSnapshotCurrent(methodsSnapshot)) {
throw CancellationException("gateway connection changed")
}
response
} catch (err: Throwable) {
if (!isGatewayMethodsSnapshotCurrent(methodsSnapshot)) {
// A registered write owner makes definitive and ambiguous failures safe
// to classify after a same-endpoint reconnect; successes still read back.
if (
preserveWriteFailureAcrossEpoch &&
(err is GatewayRequestDefinitiveFailure || err is GatewayRequestOutcomeUnknown)
) {
throw err
}
throw CancellationException("gateway connection changed")
}
throw err
}
}
private fun isGatewayDataScopeCurrent(gatewayScope: GatewayDataScope): Boolean =
synchronized(gatewayDataScopeLock) {
gatewayScope.generation == gatewayDataGeneration && connectedEndpoint?.stableId == gatewayScope.stableId
@@ -3744,6 +3866,27 @@ class NodeRuntime private constructor(
}
}
/** Publishes approval state only while the response's operator socket still owns the method catalog. */
private inline fun publishGatewayApprovalData(
gatewayScope: GatewayDataScope,
methodsSnapshot: GatewayMethodsSnapshot,
publish: () -> Unit,
): Boolean {
var approvalPublished = false
val scopePublished =
publishGatewayData(gatewayScope) {
// Lock order stays gateway data -> method catalog -> approval state. The
// explicit disconnect path already takes the first two in this order.
synchronized(gatewayMethodsLock) {
if (methodsSnapshot.epoch == gatewayMethodsEpoch) {
publish()
approvalPublished = true
}
}
}
return scopePublished && approvalPublished
}
private inline fun publishCronRefresh(
gatewayScope: GatewayDataScope,
refreshGeneration: Long,
@@ -4664,10 +4807,16 @@ class NodeRuntime private constructor(
private suspend fun refreshExecApprovalsFromGateway() {
val gatewayScope = captureGatewayDataScope() ?: return
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
val refreshGeneration =
synchronized(execApprovalsStateLock) {
execApprovalsRefreshSeq.incrementAndGet()
}
publishGatewayData(gatewayScope) {
_execApprovalsRefreshing.value = true
_execApprovalsErrorText.value = null
// The terminal notice reports an outcome the reviewer has not acknowledged yet.
// Refresh must not wipe it; it clears on user dismissal, a replacement terminal
// notice, a re-requested approval with the same id, or gateway teardown.
}
if (!operatorConnected) {
publishGatewayData(gatewayScope) {
@@ -4679,37 +4828,64 @@ class NodeRuntime private constructor(
return
}
try {
// TODO(#103505): replace legacy full-request discovery with the sanitized
// session approval lifecycle projection before removing this list seam.
val res = requestGatewayData(gatewayScope, "exec.approval.list", "{}")
val existing = _execApprovals.value.associateBy { it.id }
val terminalApprovals = mutableListOf<GatewayExecApprovalSnapshot.Terminal>()
val rows =
parseGatewayExecApprovalListPayload(res, json)
.filterNot { it.id in resolvedExecApprovalIds }
.map { row ->
val hydrated =
.mapNotNull { row ->
val methodsSnapshot = captureGatewayMethods()
val lookup =
try {
fetchExecApprovalDetailFromGateway(
gatewayScope = gatewayScope,
methodsSnapshot = methodsSnapshot,
id = row.id,
createdAtMs = row.createdAtMs ?: System.currentTimeMillis(),
)
} catch (_: Throwable) {
null
} ?: row.copy(errorText = "Could not load approval details. Refresh and try again.")
}
if (lookup is GatewayExecApprovalSnapshot.Terminal) {
terminalApprovals.add(lookup)
return@mapNotNull null
}
val hydrated =
(lookup as? GatewayExecApprovalSnapshot.Pending)?.summary
?: row.copy(errorText = execApprovalLoadDetailsFailureMessage())
val current = existing[row.id]
val pendingWrite = pendingExecApprovalWrite(row.id, gatewayScope.stableId)
if (current == null) {
hydrated
hydrated.copy(
resolvingDecision = pendingWrite?.decision,
errorText = if (pendingWrite == null) hydrated.errorText else execApprovalOutcomeUnknownMessage(),
)
} else {
hydrated.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText ?: hydrated.errorText,
resolvingDecision = current.resolvingDecision ?: pendingWrite?.decision,
errorText =
current.errorText
?: if (pendingWrite?.requestInFlight == false) {
execApprovalOutcomeUnknownMessage()
} else {
hydrated.errorText
},
)
}
}
publishExecApprovalsIfCurrent(gatewayScope, refreshGeneration, rows)
publishExecApprovalsIfCurrent(
gatewayScope = gatewayScope,
refreshGeneration = refreshGeneration,
rows = rows,
terminalApprovals = terminalApprovals,
)
} catch (_: Throwable) {
publishGatewayData(gatewayScope) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsErrorText.value = "Could not load approvals."
_execApprovalsErrorText.value = execApprovalLoadFailureMessage()
}
}
} finally {
@@ -4719,6 +4895,7 @@ class NodeRuntime private constructor(
}
}
}
reconcilePendingExecApprovalWrites(gatewayScope)
}
private suspend fun refreshExecApprovalFromGateway(id: String) {
@@ -4727,17 +4904,39 @@ class NodeRuntime private constructor(
if (id in resolvedExecApprovalIds) return
try {
val current = _execApprovals.value.firstOrNull { it.id == id }
val row =
val methodsSnapshot = captureGatewayMethods()
val lookup =
fetchExecApprovalDetailFromGateway(
gatewayScope = gatewayScope,
methodsSnapshot = methodsSnapshot,
id = id,
createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(),
) ?: return
publishGatewayData(gatewayScope) {
if (id !in resolvedExecApprovalIds) {
invalidateExecApprovalRefreshes()
upsertExecApproval(row)
}
)
when (lookup) {
is GatewayExecApprovalSnapshot.Pending ->
publishGatewayApprovalData(gatewayScope, methodsSnapshot) {
if (id !in resolvedExecApprovalIds) {
invalidateExecApprovalRefreshes()
val pendingWrite = pendingExecApprovalWrite(id, gatewayScope.stableId)
upsertExecApproval(
lookup.summary.copy(
resolvingDecision = current?.resolvingDecision ?: pendingWrite?.decision,
errorText =
current?.errorText
?: pendingWrite
?.takeIf { current == null || !it.requestInFlight }
?.let { execApprovalOutcomeUnknownMessage() },
),
)
}
}
is GatewayExecApprovalSnapshot.Terminal ->
publishGatewayApprovalData(gatewayScope, methodsSnapshot) {
if (_execApprovals.value.any { it.id == id }) {
_execApprovalsNotice.value = gatewayExecApprovalRemoteTerminalNotice(lookup)
}
markExecApprovalResolved(id)
}
}
} catch (_: Throwable) {
if (isGatewayDataScopeCurrent(gatewayScope)) {
@@ -4748,61 +4947,350 @@ class NodeRuntime private constructor(
private suspend fun fetchExecApprovalDetailFromGateway(
gatewayScope: GatewayDataScope,
methodsSnapshot: GatewayMethodsSnapshot,
id: String,
createdAtMs: Long,
): GatewayExecApprovalSummary? {
val params = buildJsonObject { put("id", JsonPrimitive(id)) }.toString()
val res = requestGatewayData(gatewayScope, "exec.approval.get", params)
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
return parseGatewayExecApprovalDetail(root, createdAtMs = createdAtMs)
}
createdAtMs: Long?,
): GatewayExecApprovalSnapshot =
when (methodsSnapshot.approvalRpcFamily) {
GatewayApprovalRpcFamily.Canonical ->
fetchUnifiedExecApprovalDetail(
gatewayScope = gatewayScope,
methodsSnapshot = methodsSnapshot,
id = id,
)
GatewayApprovalRpcFamily.Legacy -> {
val params = buildGatewayExecApprovalGetParams(id).toString()
val response =
requestGatewayApprovalData(
gatewayScope = gatewayScope,
methodsSnapshot = methodsSnapshot,
method = "exec.approval.get",
paramsJson = params,
)
parseLegacyGatewayExecApprovalGetPayload(
payloadJson = response,
json = json,
expectedId = id,
createdAtMs = createdAtMs,
) ?: error("Malformed exec.approval.get response")
}
GatewayApprovalRpcFamily.Unavailable -> throw GatewayApprovalRpcUnavailable()
}
private suspend fun resolveExecApprovalOnGateway(
id: String,
decision: String,
) {
val gatewayScope = captureGatewayDataScope() ?: return
var markedResolving = false
val currentScope =
publishGatewayData(gatewayScope) {
val methodsSnapshot = captureGatewayMethods()
var registeredWrite: PendingExecApprovalWrite? = null
val scopeCurrent =
publishGatewayApprovalData(gatewayScope, methodsSnapshot) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return@synchronized
val currentRows = _execApprovals.value
if (currentRows.none { it.id == id }) return@synchronized
if (currentRows.none { it.id == id && it.resolvingDecision == null }) return@synchronized
if (pendingExecApprovalWrites.containsKey(id)) return@synchronized
val pendingWrite = PendingExecApprovalWrite(gatewayScope.stableId, id, decision)
pendingExecApprovalWrites[id] = pendingWrite
registeredWrite = pendingWrite
invalidateExecApprovalRefreshes()
_execApprovals.value =
currentRows.map { row ->
if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row
}
markedResolving = true
// Do not clear the notice here: it reports a different approval's terminal
// outcome (a same-id write cannot start after its terminal notice retired the
// row) and must stay visible until the user acknowledges it.
}
}
if (!currentScope || !markedResolving) return
val pendingWrite = registeredWrite
if (!scopeCurrent || pendingWrite == null) return
try {
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put("decision", JsonPrimitive(decision))
}.toString()
requestGatewayData(gatewayScope, "exec.approval.resolve", params)
publishGatewayData(gatewayScope) { markExecApprovalResolved(id) }
} catch (_: Throwable) {
publishGatewayData(gatewayScope) {
val resolution = submitExecApprovalResolution(gatewayScope, methodsSnapshot, id, decision)
markExecApprovalWriteRequestFinished(pendingWrite)
publishGatewayApprovalData(gatewayScope, methodsSnapshot) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return@synchronized
_execApprovals.value =
_execApprovals.value.map { row ->
if (row.id == id) {
row.copy(resolvingDecision = null, errorText = "Could not resolve approval. Refresh and try again.")
} else {
row
}
if (pendingExecApprovalWrites[id] !== pendingWrite || id in resolvedExecApprovalIds) return@synchronized
// `applied=false` carries the canonical winner from another surface.
_execApprovalsNotice.value = gatewayExecApprovalResolutionNotice(resolution)
markExecApprovalResolved(id)
}
}
if (pendingExecApprovalWrite(id, gatewayScope.stableId) === pendingWrite) {
reconcileExecApprovalWriteOutcome(gatewayScope, pendingWrite)
}
} catch (err: CancellationException) {
markExecApprovalWriteRequestFinished(pendingWrite)
reconcileExecApprovalWriteOutcome(gatewayScope, pendingWrite)
throw err
} catch (_: GatewayRequestNotEnqueued) {
handleExecApprovalResolveFailure(
gatewayScope = gatewayScope,
pendingWrite = pendingWrite,
outcomeUnknown = false,
)
} catch (err: GatewayRequestRejected) {
if (
methodsSnapshot.approvalRpcFamily == GatewayApprovalRpcFamily.Legacy &&
isGatewayExecApprovalAlreadyResolved(err.gatewayError)
) {
// Mirror the success path: the rejection settled the request, so mark it
// finished first. The epoch-guarded publish below can be skipped by a methods
// epoch bump, and a write left requestInFlight would never reconcile.
markExecApprovalWriteRequestFinished(pendingWrite)
handleLegacyExecApprovalAlreadyResolved(gatewayScope, methodsSnapshot, pendingWrite)
if (pendingExecApprovalWrite(id, gatewayScope.stableId) === pendingWrite) {
// A same-endpoint method-catalog replacement rejects stale publishes but does
// not invalidate the write owner. Read current canonical state so the card
// cannot remain frozen until a later manual refresh.
reconcileExecApprovalWriteOutcome(gatewayScope, pendingWrite)
}
} else {
handleExecApprovalResolveFailure(
gatewayScope = gatewayScope,
pendingWrite = pendingWrite,
outcomeUnknown = false,
)
}
} catch (_: GatewayApprovalRpcUnavailable) {
handleExecApprovalResolveFailure(
gatewayScope = gatewayScope,
pendingWrite = pendingWrite,
outcomeUnknown = false,
)
} catch (_: Throwable) {
handleExecApprovalResolveFailure(
gatewayScope = gatewayScope,
pendingWrite = pendingWrite,
outcomeUnknown = true,
)
reconcileExecApprovalWriteOutcome(gatewayScope, pendingWrite)
}
}
private suspend fun submitExecApprovalResolution(
gatewayScope: GatewayDataScope,
methodsSnapshot: GatewayMethodsSnapshot,
id: String,
decision: String,
): GatewayExecApprovalResolution =
when (methodsSnapshot.approvalRpcFamily) {
GatewayApprovalRpcFamily.Canonical -> {
val params = buildGatewayExecApprovalResolveParams(id, decision).toString()
val response =
requestGatewayApprovalData(
gatewayScope = gatewayScope,
methodsSnapshot = methodsSnapshot,
method = "approval.resolve",
paramsJson = params,
preserveWriteFailureAcrossEpoch = true,
)
parseGatewayExecApprovalResolvePayload(
payloadJson = response,
json = json,
expectedId = id,
expectedDecision = decision,
) ?: throw ExecApprovalWriteOutcomeUnknown()
}
GatewayApprovalRpcFamily.Legacy -> {
val legacyParams =
buildJsonObject {
put("id", JsonPrimitive(id))
put("decision", JsonPrimitive(decision))
}.toString()
val legacyResponse =
requestGatewayApprovalData(
gatewayScope = gatewayScope,
methodsSnapshot = methodsSnapshot,
method = "exec.approval.resolve",
paramsJson = legacyParams,
preserveWriteFailureAcrossEpoch = true,
)
if (!parseLegacyGatewayExecApprovalResolvePayload(legacyResponse, json)) {
throw ExecApprovalWriteOutcomeUnknown()
}
val terminal =
legacyGatewayExecApprovalTerminal(id, decision)
?: throw ExecApprovalWriteOutcomeUnknown()
GatewayExecApprovalResolution(
applied = false,
approval = terminal,
attribution = GatewayExecApprovalResolutionAttribution.Unknown,
)
}
GatewayApprovalRpcFamily.Unavailable -> throw GatewayApprovalRpcUnavailable()
}
private fun isGatewayExecApprovalAlreadyResolved(error: GatewaySession.ErrorShape): Boolean = error.code == "INVALID_REQUEST" && error.details?.reason == "APPROVAL_ALREADY_RESOLVED"
private fun handleLegacyExecApprovalAlreadyResolved(
gatewayScope: GatewayDataScope,
methodsSnapshot: GatewayMethodsSnapshot,
pendingWrite: PendingExecApprovalWrite,
) {
publishGatewayApprovalData(gatewayScope, methodsSnapshot) {
synchronized(execApprovalsStateLock) {
val id = pendingWrite.id
if (pendingExecApprovalWrites[id] !== pendingWrite) return@synchronized
if (_execApprovals.value.any { it.id == id }) {
_execApprovalsNotice.value = gatewayExecApprovalPriorResolutionNotice(id)
}
// The legacy rejection proves only that another verdict won. Retire the
// exact card without inventing that unavailable winner's decision.
markExecApprovalResolved(id)
}
}
}
private fun handleExecApprovalResolveFailure(
gatewayScope: GatewayDataScope,
pendingWrite: PendingExecApprovalWrite,
outcomeUnknown: Boolean,
) {
publishGatewayData(gatewayScope) {
synchronized(execApprovalsStateLock) {
val id = pendingWrite.id
if (pendingExecApprovalWrites[id] !== pendingWrite) return@synchronized
if (!outcomeUnknown) {
pendingExecApprovalWrites.remove(id)
} else {
pendingWrite.requestInFlight = false
}
invalidateExecApprovalRefreshes()
if (!operatorConnected || id in resolvedExecApprovalIds || _execApprovals.value.none { it.id == id }) {
return@synchronized
}
val error =
if (outcomeUnknown) execApprovalOutcomeUnknownMessage() else execApprovalResolveFailureMessage()
_execApprovals.value =
_execApprovals.value.map { row ->
if (row.id == id) {
row.copy(
resolvingDecision = pendingWrite.decision.takeIf { outcomeUnknown },
errorText = error,
)
} else {
row
}
}
}
}
}
private suspend fun reconcilePendingExecApprovalWrites(gatewayScope: GatewayDataScope) {
if (!operatorConnected) return
val pendingWrites =
synchronized(execApprovalsStateLock) {
pendingExecApprovalWrites.values
.filter { it.stableId == gatewayScope.stableId && !it.requestInFlight }
.toList()
}
pendingWrites.forEach { reconcileExecApprovalWriteOutcome(gatewayScope, it) }
}
private suspend fun reconcileExecApprovalWriteOutcome(
gatewayScope: GatewayDataScope,
pendingWrite: PendingExecApprovalWrite,
) {
val shouldReconcile =
synchronized(execApprovalsStateLock) {
operatorConnected &&
pendingExecApprovalWrites[pendingWrite.id] === pendingWrite &&
!pendingWrite.requestInFlight
}
if (!shouldReconcile) return
val methodsSnapshot = captureGatewayMethods()
val snapshot =
try {
fetchExecApprovalDetailFromGateway(
gatewayScope = gatewayScope,
methodsSnapshot = methodsSnapshot,
id = pendingWrite.id,
createdAtMs = _execApprovals.value.firstOrNull { it.id == pendingWrite.id }?.createdAtMs,
)
} catch (_: Throwable) {
return
}
publishGatewayApprovalData(gatewayScope, methodsSnapshot) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || pendingExecApprovalWrites[pendingWrite.id] !== pendingWrite) return@synchronized
when (snapshot) {
is GatewayExecApprovalSnapshot.Terminal -> {
_execApprovalsNotice.value = gatewayExecApprovalRemoteTerminalNotice(snapshot)
markExecApprovalResolved(pendingWrite.id)
}
is GatewayExecApprovalSnapshot.Pending -> {
invalidateExecApprovalRefreshes()
pendingExecApprovalWrites.remove(pendingWrite.id)
val row =
snapshot.summary.copy(
resolvingDecision = null,
errorText = execApprovalStillPendingMessage(),
)
val retained = _execApprovals.value.filterNot { it.id == pendingWrite.id }
val nextRows =
(retained + row)
.filterActiveExecApprovals()
.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
}
}
}
private fun markExecApprovalWriteRequestFinished(pendingWrite: PendingExecApprovalWrite) {
synchronized(execApprovalsStateLock) {
if (pendingExecApprovalWrites[pendingWrite.id] === pendingWrite) {
pendingWrite.requestInFlight = false
}
}
}
private suspend fun fetchUnifiedExecApprovalDetail(
gatewayScope: GatewayDataScope,
methodsSnapshot: GatewayMethodsSnapshot,
id: String,
): GatewayExecApprovalSnapshot {
val params = buildGatewayExecApprovalGetParams(id).toString()
val response =
requestGatewayApprovalData(
gatewayScope = gatewayScope,
methodsSnapshot = methodsSnapshot,
method = "approval.get",
paramsJson = params,
)
return parseGatewayExecApprovalGetPayload(response, json, expectedId = id)
?: error("Malformed approval.get response")
}
private fun replaceGatewayMethods(methods: Set<String>) {
synchronized(gatewayMethodsLock) {
gatewayApprovalRpcFamily = selectGatewayApprovalRpcFamily(methods)
gatewayMethodsEpoch += 1
}
}
private fun captureGatewayMethods(): GatewayMethodsSnapshot =
synchronized(gatewayMethodsLock) {
GatewayMethodsSnapshot(
approvalRpcFamily = gatewayApprovalRpcFamily,
epoch = gatewayMethodsEpoch,
)
}
private fun isGatewayMethodsSnapshotCurrent(snapshot: GatewayMethodsSnapshot): Boolean = synchronized(gatewayMethodsLock) { snapshot.epoch == gatewayMethodsEpoch }
private fun pendingExecApprovalWrite(
id: String,
stableId: String,
): PendingExecApprovalWrite? =
synchronized(execApprovalsStateLock) {
pendingExecApprovalWrites[id]?.takeIf { it.stableId == stableId }
}
private fun upsertExecApproval(row: GatewayExecApprovalSummary) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || row.id in resolvedExecApprovalIds) return
@@ -4815,8 +5303,8 @@ class NodeRuntime private constructor(
rows.map { current ->
if (current.id == row.id) {
row.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText,
resolvingDecision = current.resolvingDecision ?: row.resolvingDecision,
errorText = current.errorText ?: row.errorText,
)
} else {
current
@@ -4833,13 +5321,16 @@ class NodeRuntime private constructor(
}
private fun invalidateExecApprovalRefreshes() {
execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = false
synchronized(execApprovalsStateLock) {
execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = false
}
}
private fun markExecApprovalResolved(id: String) {
synchronized(execApprovalsStateLock) {
resolvedExecApprovalIds.add(id)
pendingExecApprovalWrites.remove(id)
invalidateExecApprovalRefreshes()
_execApprovals.value = _execApprovals.value.filterNot { it.id == id }
}
@@ -4849,10 +5340,22 @@ class NodeRuntime private constructor(
gatewayScope: GatewayDataScope,
refreshGeneration: Long,
rows: List<GatewayExecApprovalSummary>,
terminalApprovals: List<GatewayExecApprovalSnapshot.Terminal>,
) {
publishGatewayData(gatewayScope) {
synchronized(execApprovalsStateLock) {
if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) {
val visibleIds = _execApprovals.value.mapTo(mutableSetOf()) { it.id }
val pendingWriteIds =
pendingExecApprovalWrites.values
.filter { it.stableId == gatewayScope.stableId }
.mapTo(mutableSetOf()) { it.id }
terminalApprovals.lastOrNull { it.id in visibleIds || it.id in pendingWriteIds }?.let { terminal ->
_execApprovalsNotice.value = gatewayExecApprovalRemoteTerminalNotice(terminal)
}
val terminalIds = terminalApprovals.map { it.id }
resolvedExecApprovalIds.addAll(terminalIds)
terminalIds.forEach(pendingExecApprovalWrites::remove)
val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals()
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
@@ -110,6 +110,7 @@ data class GatewayHelloSummary(
val updateAvailable: GatewayUpdateAvailableSummary?,
val authRole: String? = null,
val authScopes: List<String> = emptyList(),
val methods: Set<String> = emptySet(),
)
data class GatewayUpdateAvailableSummary(
@@ -947,6 +948,14 @@ class GatewaySession(
val server = obj["server"].asObjectOrNull()
val serverName = server?.get("host").asStringOrNull()
val serverVersion = server?.get("version").asStringOrNull()
val methods =
obj["features"]
.asObjectOrNull()
?.get("methods")
.asArrayOrNull()
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { method -> method.isNotEmpty() } }
?.toSet()
.orEmpty()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
@@ -1004,6 +1013,7 @@ class GatewaySession(
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
authRole = authRole,
authScopes = authScopes,
methods = methods,
),
)
}
@@ -14,6 +14,7 @@ import ai.openclaw.app.GatewayCronJobDetailState
import ai.openclaw.app.GatewayCronJobEdit
import ai.openclaw.app.GatewayCronJobSummary
import ai.openclaw.app.GatewayCronRunHistoryState
import ai.openclaw.app.GatewayExecApprovalNotice
import ai.openclaw.app.GatewayExecApprovalSummary
import ai.openclaw.app.GatewayTalkSetupReadiness
import ai.openclaw.app.GatewayTalkSetupState
@@ -97,6 +98,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -106,6 +108,7 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.GraphicEq
@@ -536,6 +539,7 @@ private fun ApprovalsSettingsScreen(
val execApprovals by viewModel.execApprovals.collectAsState()
val execApprovalsRefreshing by viewModel.execApprovalsRefreshing.collectAsState()
val execApprovalsErrorText by viewModel.execApprovalsErrorText.collectAsState()
val execApprovalsNotice by viewModel.execApprovalsNotice.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
@@ -567,6 +571,11 @@ private fun ApprovalsSettingsScreen(
Text(text = execApprovalsErrorText ?: "", style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
// Terminal outcomes always retire their card first, so the notice renders as a
// standalone banner above the list; it stays visible until the user dismisses it.
execApprovalsNotice?.let { notice ->
ExecApprovalNotice(notice = notice, onDismiss = { viewModel.dismissExecApprovalsNotice(notice) })
}
if (!isConnected) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -582,7 +591,10 @@ private fun ApprovalsSettingsScreen(
}
}
} else {
ExecApprovalsPanel(approvals = execApprovals, onResolve = viewModel::resolveExecApproval)
ExecApprovalsPanel(
approvals = execApprovals,
onResolve = viewModel::resolveExecApproval,
)
}
if (pendingToolCalls.isNotEmpty()) {
Text(text = "Session activity", style = ClawTheme.type.section, color = ClawTheme.colors.text)
@@ -1974,7 +1986,10 @@ private fun ExecApprovalsPanel(
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
approvals.forEach { approval ->
ExecApprovalCard(approval = approval, onResolve = onResolve)
ExecApprovalCard(
approval = approval,
onResolve = onResolve,
)
}
}
}
@@ -1989,47 +2004,106 @@ private fun ExecApprovalCard(
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = approval.commandText, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(text = "Command approval", style = ClawTheme.type.section, color = ClawTheme.colors.text)
approval.commandPreview?.let { preview ->
Text(text = preview, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
ClawStatusPill(text = if (resolving) "Sending" else "Review", status = if (resolving) ClawStatus.Warning else ClawStatus.Success)
}
ExecApprovalCommandReview(approval.commandText)
approval.warningText?.let { warningText ->
Text(text = warningText, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
Text(text = execApprovalMetadata(approval), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.errorText?.let { errorText ->
Text(text = errorText, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if ("allow-once" in approval.allowedDecisions) {
ClawPrimaryButton(
text = if (approval.resolvingDecision == "allow-once") "Allowing" else "Allow Once",
onClick = { onResolve(approval.id, "allow-once") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("allow-always" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "allow-always") "Saving" else "Always",
onClick = { onResolve(approval.id, "allow-always") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("deny" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "deny") "Denying" else "Deny",
onClick = { onResolve(approval.id, "deny") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
execApprovalActions(approval.allowedDecisions).forEach { action ->
if (action.decision == "allow-once") {
ClawPrimaryButton(
text = action.label,
onClick = { onResolve(approval.id, action.decision) },
enabled = !resolving,
modifier = Modifier.fillMaxWidth(),
)
} else {
ClawSecondaryButton(
text = action.label,
onClick = { onResolve(approval.id, action.decision) },
enabled = !resolving,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
@Composable
private fun ExecApprovalCommandReview(commandText: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
SelectionContainer {
Text(
text = commandText,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
style = ClawTheme.type.body.copy(fontFamily = FontFamily.Monospace),
color = ClawTheme.colors.text,
)
}
}
}
internal data class ExecApprovalAction(
val decision: String,
val label: String,
)
internal fun execApprovalActions(allowedDecisions: List<String>): List<ExecApprovalAction> =
allowedDecisions.mapNotNull { decision ->
when (decision) {
"allow-once" -> ExecApprovalAction(decision, "Allow Once")
"allow-always" -> ExecApprovalAction(decision, "Allow Always")
"deny" -> ExecApprovalAction(decision, "Deny")
else -> null
}
}
@Composable
private fun ExecApprovalNotice(
notice: GatewayExecApprovalNotice,
onDismiss: () -> Unit,
) {
ClawPanel {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = notice.message,
style = ClawTheme.type.body,
color = if (notice.warning) ClawTheme.colors.warning else ClawTheme.colors.success,
)
// The retired card is gone by the time this renders; keep the id association
// so the outcome stays attributable while other approval cards remain visible.
Text(
text = "Approval ${notice.approvalId}",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ClawPlainIconButton(icon = Icons.Default.Close, contentDescription = "Dismiss approval notice", onClick = onDismiss)
}
}
}
@Composable
private fun SessionToolCallsPanel(toolCalls: List<ChatPendingToolCall>) {
ClawListPanel(items = toolCalls) { toolCall ->
@@ -1,8 +1,8 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -11,7 +11,7 @@ class GatewayExecApprovalParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parsesGatewayExecApprovalListPayload() {
fun legacyListIsOpaqueDiscoveryOnly() {
val rows =
parseGatewayExecApprovalListPayload(
"""
@@ -24,25 +24,14 @@ class GatewayExecApprovalParsingTest {
"host": "node",
"nodeId": "node-1",
"agentId": "agent-1",
"command": "Sanitized command",
"commandPreview": "Sanitized preview",
"systemRunPlan": {
"commandText": "/bin/sh -lc 'echo secret'",
"commandPreview": "echo secret"
},
"allowedDecisions": ["allow-once", "deny"]
"command": "pnpm publish --token secret",
"commandPreview": "secret preview"
}
},
{
"id": "approval-1",
"createdAtMs": 10,
"expiresAtMs": 110,
"request": {
"host": "gateway",
"command": "pnpm test --token secret",
"commandPreview": "pnpm test",
"unavailableDecisions": ["allow-always"]
}
"expiresAtMs": 110
}
]
""".trimIndent(),
@@ -50,52 +39,531 @@ class GatewayExecApprovalParsingTest {
)
assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id })
assertEquals("pnpm test --token secret", rows[0].commandText)
assertEquals("pnpm test", rows[0].commandPreview)
assertEquals(emptyList<String>(), rows[0].allowedDecisions)
assertEquals("Sanitized command", rows[1].commandText)
assertEquals("Sanitized preview", rows[1].commandPreview)
assertEquals("node-1", rows[1].nodeId)
assertEquals("agent-1", rows[1].agentId)
assertEquals(listOf("Command request", "Command request"), rows.map { it.commandText })
assertTrue(rows.all { it.commandPreview == null })
assertTrue(rows.all { it.allowedDecisions.isEmpty() })
assertTrue(rows.all { it.host == null && it.nodeId == null && it.agentId == null })
}
@Test
fun parsesGatewayExecApprovalGetPayload() {
val root =
json
.parseToJsonElement(
"""
{
"id": "approval-1",
"commandText": "rm -rf build",
"commandPreview": "rm build",
"allowedDecisions": ["allow-once", "allow-always", "deny"],
"host": "gateway",
"nodeId": null,
"agentId": "agent-main",
"expiresAtMs": 200
}
""".trimIndent(),
).asObjectOrNull()
fun parsesPendingUnifiedExecApproval() {
val snapshot =
parseGatewayExecApprovalGetPayload(
pendingGetPayload(),
json,
expectedId = "approval-1",
)
requireNotNull(root)
val row = parseGatewayExecApprovalDetail(root, createdAtMs = 100)
val pending = snapshot as GatewayExecApprovalSnapshot.Pending
assertEquals("approval-1", pending.id)
assertEquals("rm -rf build", pending.summary.commandText)
assertEquals("rm build", pending.summary.commandPreview)
assertEquals("This command can delete files.", pending.summary.warningText)
assertEquals(listOf("allow-once", "allow-always", "deny"), pending.summary.allowedDecisions)
assertEquals("gateway", pending.summary.host)
assertNull(pending.summary.nodeId)
assertEquals("agent-main", pending.summary.agentId)
assertEquals(100L, pending.summary.createdAtMs)
assertEquals(200L, pending.summary.expiresAtMs)
}
requireNotNull(row)
assertEquals("approval-1", row.id)
assertEquals("rm -rf build", row.commandText)
assertEquals("rm build", row.commandPreview)
assertEquals(listOf("allow-once", "allow-always", "deny"), row.allowedDecisions)
assertEquals("gateway", row.host)
assertNull(row.nodeId)
assertEquals("agent-main", row.agentId)
assertEquals(100L, row.createdAtMs)
assertEquals(200L, row.expiresAtMs)
@Test
fun unifiedGetReturnsCanonicalTerminalSnapshot() {
val snapshot =
parseGatewayExecApprovalGetPayload(
terminalPayload(status = "expired", reason = "timeout"),
json,
expectedId = "approval-1",
)
val terminal = snapshot as GatewayExecApprovalSnapshot.Terminal
assertEquals(GatewayApprovalTerminalStatus.Expired, terminal.status)
assertNull(terminal.decision)
}
@Test
fun resolveAcceptsAnotherSurfacesCanonicalWinner() {
val resolution =
parseGatewayExecApprovalResolvePayload(
"""
{
"applied": false,
"approval": ${terminalApproval(status = "denied", reason = "user", decision = "deny")}
}
""".trimIndent(),
json,
expectedId = "approval-1",
expectedDecision = "allow-once",
)
requireNotNull(resolution)
assertFalse(resolution.applied)
assertEquals(GatewayApprovalTerminalStatus.Denied, resolution.approval.status)
assertEquals("deny", resolution.approval.decision)
}
@Test
fun resolveAcceptsAppliedAllowWinner() {
val resolution =
parseGatewayExecApprovalResolvePayload(
"""
{
"applied": true,
"approval": ${terminalApproval(status = "allowed", reason = "user", decision = "allow-once")}
}
""".trimIndent(),
json,
expectedId = "approval-1",
expectedDecision = "allow-once",
)
requireNotNull(resolution)
assertTrue(resolution.applied)
assertEquals(GatewayApprovalTerminalStatus.Allowed, resolution.approval.status)
assertEquals("allow-once", resolution.approval.decision)
}
@Test
fun unifiedParsingRejectsWrongOwnerIdentityAndMalformedVerdicts() {
assertNull(
parseGatewayExecApprovalGetPayload(
pendingGetPayload().replace("\"kind\": \"exec\"", "\"kind\": \"plugin\""),
json,
expectedId = "approval-1",
),
)
assertNull(
parseGatewayExecApprovalGetPayload(
pendingGetPayload(),
json,
expectedId = "approval-other",
),
)
assertNull(
parseGatewayExecApprovalResolvePayload(
"""{"applied":"false","approval":${terminalApproval(status = "denied", reason = "user", decision = "deny")}}""",
json,
expectedId = "approval-1",
expectedDecision = "deny",
),
)
assertNull(
parseGatewayExecApprovalResolvePayload(
"""{"applied":false,"approval":${terminalApproval(status = "allowed", reason = "user", decision = "deny")}}""",
json,
expectedId = "approval-1",
expectedDecision = "deny",
),
)
assertNull(
parseGatewayExecApprovalResolvePayload(
"""{"applied":false,"approval":${pendingApproval()}}""",
json,
expectedId = "approval-1",
expectedDecision = "deny",
),
)
assertNull(
parseGatewayExecApprovalResolvePayload(
"""{"applied":false,"approval":${terminalApproval(status = "denied", reason = "user", decision = "deny")}}""",
json,
expectedId = "approval-other",
expectedDecision = "deny",
),
)
assertNull(
parseGatewayExecApprovalResolvePayload(
"""{"applied":true,"approval":${terminalApproval(status = "denied", reason = "user", decision = "deny")}}""",
json,
expectedId = "approval-1",
expectedDecision = "allow-once",
),
)
}
@Test
fun acceptsOnlyExactClosedExecDecisions() {
assertEquals("allow-once", normalizeGatewayExecApprovalDecision("allow-once"))
assertEquals("allow-always", normalizeGatewayExecApprovalDecision("allow-always"))
assertEquals("deny", normalizeGatewayExecApprovalDecision("deny"))
assertNull(normalizeGatewayExecApprovalDecision(" allow-once "))
assertNull(normalizeGatewayExecApprovalDecision("ALLOW-ONCE"))
assertNull(normalizeGatewayExecApprovalDecision("deny\n"))
assertNull(normalizeGatewayExecApprovalDecision("deny\u0000"))
assertNull(normalizeGatewayExecApprovalDecision("accept"))
assertNull(normalizeGatewayExecApprovalDecision(""))
}
@Test
fun unifiedParsingRejectsUnknownFieldsAtEverySchemaBoundary() {
assertNull(
parseGatewayExecApprovalGetPayload(
pendingGetPayload().replaceFirst("{", "{\"unexpected\":true,"),
json,
expectedId = "approval-1",
),
)
assertNull(
parseGatewayExecApprovalGetPayload(
pendingGetPayload()
.replaceFirst(
"\"status\": \"pending\"",
"\"status\": \"pending\", \"resolvedBy\": \"phone\"",
),
json,
expectedId = "approval-1",
),
)
assertNull(
parseGatewayExecApprovalGetPayload(
pendingGetPayload()
.replaceFirst(
"\"kind\": \"exec\"",
"\"kind\": \"exec\", \"cwd\": \"/tmp\"",
),
json,
expectedId = "approval-1",
),
)
assertNull(
parseGatewayExecApprovalGetPayload(
terminalPayload(status = "denied", reason = "user", decision = "deny")
.replaceFirst(
"\"reason\": \"user\"",
"\"reason\": \"user\", \"resolvedBy\": \"phone\"",
),
json,
expectedId = "approval-1",
),
)
val terminal = terminalApproval(status = "denied", reason = "user", decision = "deny")
assertNull(
parseGatewayExecApprovalResolvePayload(
"""{"applied":false,"unexpected":true,"approval":$terminal}""",
json,
expectedId = "approval-1",
expectedDecision = "deny",
),
)
}
@Test
fun unifiedParsingRequiresPathStableWellFormedApprovalIds() {
val malformedIds =
listOf(
"\"\"" to "",
"\".\"" to ".",
"\"..\"" to "..",
"\"\\ud800\"" to "\uD800",
"\"\\udc00\"" to "\uDC00",
)
for ((encodedId, expectedId) in malformedIds) {
assertNull(
parseGatewayExecApprovalGetPayload(
pendingGetPayload().replaceFirst("\"approval-1\"", encodedId),
json,
expectedId = expectedId,
),
)
}
val astralId = "approval:🦞/percent%"
val snapshot =
parseGatewayExecApprovalGetPayload(
pendingGetPayload().replaceFirst("approval-1", astralId),
json,
expectedId = astralId,
)
assertEquals(astralId, snapshot?.id)
}
@Test
fun unifiedAllowedTerminalDecisionMustHaveBeenOffered() {
val payload =
terminalPayload(status = "allowed", reason = "user", decision = "allow-once")
.replace(
"[\"allow-once\", \"allow-always\", \"deny\"]",
"[\"allow-always\", \"deny\"]",
)
assertNull(parseGatewayExecApprovalGetPayload(payload, json, expectedId = "approval-1"))
}
@Test
fun buildsUnifiedRuntimeRequestsWithExplicitOwner() {
assertEquals("""{"id":"approval-1"}""", buildGatewayExecApprovalGetParams("approval-1").toString())
assertEquals(
"""{"id":"approval-1","kind":"exec","decision":"deny"}""",
buildGatewayExecApprovalResolveParams(id = "approval-1", decision = "deny").toString(),
)
}
@Test
fun legacyGatewayCompatibilityStillValidatesIdentityAndAck() {
val pending =
parseLegacyGatewayExecApprovalGetPayload(
"""
{
"id": "approval-1",
"commandText": "echo ok",
"commandPreview": "echo",
"allowedDecisions": ["allow-once", "deny"],
"host": "gateway",
"nodeId": null,
"agentId": "main",
"expiresAtMs": 200
}
""".trimIndent(),
json,
expectedId = "approval-1",
createdAtMs = 100,
)
requireNotNull(pending)
assertEquals(listOf("allow-once", "deny"), pending.summary.allowedDecisions)
assertNull(
parseLegacyGatewayExecApprovalGetPayload(
"""{"id":"other","commandText":"echo","allowedDecisions":["deny"]}""",
json,
expectedId = "approval-1",
createdAtMs = 100,
),
)
assertNull(
parseLegacyGatewayExecApprovalGetPayload(
"""{"id":"approval-1","commandText":"echo","expiresAtMs":200}""",
json,
expectedId = "approval-1",
createdAtMs = 100,
),
)
assertNull(
parseLegacyGatewayExecApprovalGetPayload(
"""{"id":"approval-1","commandText":"echo","allowedDecisions":["deny"]}""",
json,
expectedId = "approval-1",
createdAtMs = 100,
),
)
assertNull(
parseLegacyGatewayExecApprovalGetPayload(
"""{"id":"approval-1","commandText":"echo","allowedDecisions":["deny"],"expiresAtMs":-1}""",
json,
expectedId = "approval-1",
createdAtMs = 100,
),
)
assertNull(
parseLegacyGatewayExecApprovalGetPayload(
"""{"id":"approval-1","commandText":"echo","allowedDecisions":["deny"],"expiresAtMs":200}""",
json,
expectedId = "approval-1",
createdAtMs = -1,
),
)
assertTrue(parseLegacyGatewayExecApprovalResolvePayload("""{"ok":true}""", json))
assertFalse(parseLegacyGatewayExecApprovalResolvePayload("""{"ok":"true"}""", json))
assertFalse(parseLegacyGatewayExecApprovalResolvePayload("""{"ok":false}""", json))
}
@Test
fun approvalRpcFamilyPinsOnlyCompleteHelloCatalogs() {
assertEquals(
GatewayApprovalRpcFamily.Canonical,
selectGatewayApprovalRpcFamily(
setOf(
"approval.get",
"approval.resolve",
"exec.approval.get",
"exec.approval.resolve",
),
),
)
assertEquals(
GatewayApprovalRpcFamily.Legacy,
selectGatewayApprovalRpcFamily(
setOf("exec.approval.get", "exec.approval.resolve"),
),
)
val unavailableCatalogs: List<Set<String>> =
listOf(
emptySet(),
setOf("approval.get"),
setOf("approval.resolve"),
setOf("exec.approval.get"),
setOf("exec.approval.resolve"),
setOf("approval.get", "exec.approval.get", "exec.approval.resolve"),
setOf("approval.resolve", "exec.approval.get", "exec.approval.resolve"),
)
for (methods in unavailableCatalogs) {
assertEquals(
GatewayApprovalRpcFamily.Unavailable,
selectGatewayApprovalRpcFamily(methods),
)
}
}
@Test
fun localAndRemoteTerminalNoticesPreserveCanonicalOutcome() {
// Field comparison: every constructed notice carries a distinct publication token,
// so whole-value equality would never hold across separately built notices.
assertNoticeContent(
gatewayExecApprovalRemoteTerminalNotice(
terminal(status = GatewayApprovalTerminalStatus.Denied, decision = "deny"),
),
message = "A prior response already denied this approval.",
warning = true,
)
assertNoticeContent(
gatewayExecApprovalRemoteTerminalNotice(terminal(status = GatewayApprovalTerminalStatus.Expired)),
message = "This approval expired before it could be resolved.",
warning = true,
)
assertNoticeContent(
gatewayExecApprovalRemoteTerminalNotice(terminal(status = GatewayApprovalTerminalStatus.Cancelled)),
message = "This approval was cancelled before it could be resolved.",
warning = true,
)
assertNoticeContent(
gatewayExecApprovalResolutionNotice(
resolution(
applied = false,
status = GatewayApprovalTerminalStatus.Allowed,
decision = "allow-always",
),
),
message = "A prior response already allowed this command and saved the choice.",
warning = false,
)
assertNoticeContent(
gatewayExecApprovalResolutionNotice(
resolution(
applied = false,
status = GatewayApprovalTerminalStatus.Allowed,
decision = "allow-always",
attribution = GatewayExecApprovalResolutionAttribution.Unknown,
),
),
message = "Gateway recorded approval and saved the choice.",
warning = false,
)
assertNoticeContent(
gatewayExecApprovalResolutionNotice(
resolution(
applied = false,
status = GatewayApprovalTerminalStatus.Denied,
decision = "deny",
attribution = GatewayExecApprovalResolutionAttribution.Unknown,
),
),
message = "Gateway recorded a denial.",
warning = true,
)
}
private fun assertNoticeContent(
notice: GatewayExecApprovalNotice,
approvalId: String = "approval-1",
message: String,
warning: Boolean,
) {
assertEquals(approvalId, notice.approvalId)
assertEquals(message, notice.message)
assertEquals(warning, notice.warning)
}
@Test
fun ignoresMalformedGatewayExecApprovalListPayload() {
assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty())
assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty())
assertTrue(
parseGatewayExecApprovalListPayload(
"""[{"id":"approval-1","createdAtMs":-1,"expiresAtMs":100}]""",
json,
).isEmpty(),
)
assertTrue(
parseGatewayExecApprovalListPayload(
"""[{"id":"approval-1","createdAtMs":1}]""",
json,
).isEmpty(),
)
}
private fun pendingGetPayload(): String = """{"approval":${pendingApproval()}}"""
private fun resolution(
applied: Boolean,
status: GatewayApprovalTerminalStatus,
decision: String? = null,
attribution: GatewayExecApprovalResolutionAttribution =
if (applied) GatewayExecApprovalResolutionAttribution.AppliedHere else GatewayExecApprovalResolutionAttribution.PriorResponse,
): GatewayExecApprovalResolution =
GatewayExecApprovalResolution(
applied = applied,
approval = terminal(status = status, decision = decision),
attribution = attribution,
)
private fun terminal(
status: GatewayApprovalTerminalStatus,
decision: String? = null,
): GatewayExecApprovalSnapshot.Terminal =
GatewayExecApprovalSnapshot.Terminal(
id = "approval-1",
status = status,
decision = decision,
)
private fun pendingApproval(): String =
"""
{
"id": "approval-1",
"urlPath": "/approve/approval-1",
"status": "pending",
"createdAtMs": 100,
"expiresAtMs": 200,
"presentation": ${execPresentation()}
}
""".trimIndent()
private fun terminalPayload(
status: String,
reason: String,
decision: String? = null,
): String = """{"approval":${terminalApproval(status, reason, decision)}}"""
private fun terminalApproval(
status: String,
reason: String,
decision: String? = null,
): String {
val decisionField = decision?.let { ", \"decision\": \"$it\"" }.orEmpty()
return """
{
"id": "approval-1",
"urlPath": "/approve/approval-1",
"status": "$status",
"createdAtMs": 100,
"expiresAtMs": 200,
"presentation": ${execPresentation()},
"resolvedAtMs": 150,
"reason": "$reason"$decisionField
}
""".trimIndent()
}
private fun execPresentation(): String =
"""
{
"kind": "exec",
"commandText": "rm -rf build",
"commandPreview": "rm build",
"warningText": "This command can delete files.",
"host": "gateway",
"nodeId": null,
"agentId": "agent-main",
"allowedDecisions": ["allow-once", "allow-always", "deny"]
}
""".trimIndent()
}
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Request
@@ -154,6 +155,33 @@ private data class ReconnectServer(
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionReconnectTest {
@Test
fun connectedHelloPublishesCanonicalAndLegacyApprovalMethods() =
runBlocking {
val catalogs =
listOf(
setOf("approval.get", "approval.resolve"),
setOf("exec.approval.get", "exec.approval.resolve"),
)
for (methods in catalogs) {
val json = Json { ignoreUnknownKeys = true }
val hello = CompletableDeferred<GatewayHelloSummary>()
val server =
startGatewayServer(json = json) { webSocket, id, method ->
if (method == "connect") webSocket.send(connectResponseFrame(id, methods))
}
val harness = createReconnectHarness(onHello = hello::complete)
try {
connectNodeSession(harness.session, server.port)
assertEquals(methods, withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { hello.await() }.methods)
} finally {
shutdownReconnectHarness(harness, server)
}
}
}
@Test
fun disconnectAndJoinWaitsForNaturalFailureCallback() =
runBlocking {
@@ -903,6 +931,7 @@ class GatewaySessionReconnectTest {
private fun createReconnectHarness(
onConnected: () -> Unit = {},
onHello: (GatewayHelloSummary) -> Unit = {},
onDisconnected: (String) -> Unit = {},
deviceAuthStore: DeviceAuthTokenStore = ReconnectDeviceAuthStore(),
onEvent: (String, String?) -> Unit = { _, _ -> },
@@ -918,7 +947,10 @@ class GatewaySessionReconnectTest {
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = { onConnected() },
onConnected = { summary ->
onConnected()
onHello(summary)
},
onDisconnected = onDisconnected,
onConnectFailure = onConnectFailure,
onEvent = onEvent,
@@ -975,7 +1007,13 @@ class GatewaySessionReconnectTest {
servers.forEach { it.shutdown() }
}
private fun connectResponseFrame(id: String): String = """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
private fun connectResponseFrame(
id: String,
methods: Set<String> = emptySet(),
): String {
val encodedMethods = methods.joinToString(",") { JsonPrimitive(it).toString() }
return """{"type":"res","id":"$id","ok":true,"payload":{"features":{"methods":[$encodedMethods]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
}
private fun startGatewayServer(
json: Json,
@@ -4,7 +4,11 @@ import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeCapabilityApproval
import ai.openclaw.app.LocationMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.Locale
class SettingsScreensTest {
@@ -172,6 +176,68 @@ class SettingsScreensTest {
assertEquals(true, cronDetailDisposalClearsTransientState(isChangingConfigurations = false))
}
@Test
fun approvalActionsUseUnabridgedSafetyLabelsInLargeFontSafeOrder() {
assertEquals(
listOf(
ExecApprovalAction("allow-once", "Allow Once"),
ExecApprovalAction("allow-always", "Allow Always"),
ExecApprovalAction("deny", "Deny"),
),
execApprovalActions(listOf("allow-once", "allow-always", "deny")),
)
}
@Test
fun approvalCardShowsTheWholeMonospacedCommandBeforeStackedActions() {
val source = settingsScreensSource()
val cardStart = source.indexOf("private fun ExecApprovalCard(")
val reviewCall = source.indexOf("ExecApprovalCommandReview(approval.commandText)", cardStart)
val actionsCall = source.indexOf("execApprovalActions(approval.allowedDecisions)", reviewCall)
val reviewStart = source.indexOf("private fun ExecApprovalCommandReview(", actionsCall)
val reviewEnd = source.indexOf("internal data class ExecApprovalAction", reviewStart)
val reviewBody = source.substring(reviewStart, reviewEnd)
val actionBody = source.substring(reviewCall, reviewStart)
assertTrue(cardStart >= 0 && reviewCall > cardStart && actionsCall > reviewCall)
assertTrue(reviewBody.contains("FontFamily.Monospace"))
assertFalse(reviewBody.contains("maxLines"))
assertFalse(reviewBody.contains("TextOverflow"))
assertTrue(actionBody.contains("Column(modifier = Modifier.fillMaxWidth()"))
assertFalse(actionBody.contains("Modifier.weight(1f)"))
}
@Test
fun terminalNoticeRendersAsStandaloneDismissibleBannerRegardlessOfRemainingCards() {
val source = settingsScreensSource()
// Terminal outcomes retire their card before the notice publishes, so any
// card-scoped or empty-inbox-only rendering hides losing outcomes whenever
// another approval card remains visible.
assertFalse(source.contains("execApprovalNoticeForCard"))
assertFalse(source.contains("execApprovalEmptyInboxNotice"))
val screenStart = source.indexOf("private fun ApprovalsSettingsScreen(")
val bannerCall = source.indexOf("execApprovalsNotice?.let", screenStart)
val listPanelCall = source.indexOf("ExecApprovalsPanel(", screenStart)
assertTrue(screenStart >= 0 && bannerCall > screenStart && listPanelCall > bannerCall)
val noticeStart = source.indexOf("private fun ExecApprovalNotice(")
val noticeEnd = source.indexOf("@Composable", noticeStart + 1)
val noticeBody = source.substring(noticeStart, noticeEnd)
assertTrue(noticeBody.contains("onDismiss: () -> Unit"))
assertTrue(noticeBody.contains("notice.approvalId"))
assertTrue(noticeBody.contains("contentDescription = \"Dismiss approval notice\""))
}
private fun settingsScreensSource(): String {
val candidates =
listOf(
Path.of("src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"),
Path.of("apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"),
)
val path = candidates.firstOrNull(Files::exists) ?: error("SettingsScreens.kt not found")
return Files.readString(path)
}
private fun authProblem(code: String): GatewayConnectionProblem =
GatewayConnectionProblem(
code = code,
+4
View File
@@ -1,5 +1,9 @@
# OpenClaw iOS Changelog
## Unreleased
- Routes iPhone and Apple Watch exec approvals through durable Gateway records, preserves safety warnings, shows the first recorded decision across surfaces, reconciles uncertain replies, and remains compatible with shipped Gateway v4 approval RPCs.
## 2026.7.1 - 2026-07-08
- Added multi-gateway pairing and switching with gateway-scoped credentials, TLS trust, cached chats, push registration, and custom proxy headers.
+23 -23
View File
@@ -273,138 +273,138 @@
}
}
},
"Approve": {
"Allow Once": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Approve"
"value": "Allow Once"
}
},
"zh-CN": {
"stringUnit": {
"state": "translated",
"value": "批准"
"value": "允许一次"
}
},
"zh-TW": {
"stringUnit": {
"state": "translated",
"value": "核准"
"value": "允許一次"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Aprovar"
"value": "Permitir uma vez"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Genehmigen"
"value": "Einmal erlauben"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Aprobar"
"value": "Permitir una vez"
}
},
"ja-JP": {
"stringUnit": {
"state": "translated",
"value": "承認"
"value": "一度だけ許可"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "승인"
"value": "한 번 허용"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Approuver"
"value": "Autoriser une fois"
}
},
"hi": {
"stringUnit": {
"state": "translated",
"value": "स्वीकृत करें"
"value": "एक बार अनुमति दें"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "موافقة"
"value": "السماح مرة واحدة"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Approva"
"value": "Consenti una volta"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Onayla"
"value": "Bir kez izin ver"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Схвалити"
"value": "Дозволити один раз"
}
},
"id": {
"stringUnit": {
"state": "translated",
"value": "Setujui"
"value": "Izinkan sekali"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Zatwierdź"
"value": "Zezwól raz"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "อนุมัติ"
"value": "อนุญาตครั้งเดียว"
}
},
"vi": {
"stringUnit": {
"state": "translated",
"value": "Phê duyệt"
"value": "Cho phép một lần"
}
},
"nl": {
"stringUnit": {
"state": "translated",
"value": "Goedkeuren"
"value": "Eén keer toestaan"
}
},
"fa": {
"stringUnit": {
"state": "translated",
"value": "تأیید"
"value": "یک‌بار اجازه دادن"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Одобрить"
"value": "Разрешить один раз"
}
},
"sv": {
"stringUnit": {
"state": "translated",
"value": "Godkänn"
"value": "Tillåt en gång"
}
}
}
+13 -1
View File
@@ -6,6 +6,12 @@ struct GatewaySetupRequest {
let link: GatewayConnectDeepLink
}
enum GatewayConnectionAttempt: Equatable {
case gateway(GatewayStableIdentifier.Key)
case manual
case setupCode
}
struct SettingsProTab: View {
@Environment(NodeAppModel.self) var appModel
@Environment(VoiceWakeManager.self) var voiceWake
@@ -42,7 +48,7 @@ struct SettingsProTab: View {
@State var isReconnectingGateway = false
@State var isRefreshingGateway = false
@State var isChangingLocationMode = false
@State var connectingGatewayID: String?
@State var connectingGateway: GatewayConnectionAttempt?
@State var gatewayRegistry = GatewaySettingsStore.GatewayRegistry.empty
@State var pendingForgetGateway: GatewaySettingsStore.GatewayRegistryEntry?
@State var selectedAgentPickerId = ""
@@ -88,6 +94,7 @@ struct SettingsProTab: View {
let ownsNavigationStack: Bool
let navigateToRoute: ((SettingsRoute) -> Void)?
let onRouteChange: ((SettingsRoute?) -> Void)?
let onApprovalNotificationsRoute: ((String) -> Void)?
let gatewaySetupRequest: GatewaySetupRequest?
let onGatewaySetupRequestHandled: ((Int) -> Void)?
@@ -99,6 +106,7 @@ struct SettingsProTab: View {
ownsNavigationStack: Bool = true,
navigateToRoute: ((SettingsRoute) -> Void)? = nil,
onRouteChange: ((SettingsRoute?) -> Void)? = nil,
onApprovalNotificationsRoute: ((String) -> Void)? = nil,
gatewaySetupRequest: GatewaySetupRequest? = nil,
onGatewaySetupRequestHandled: ((Int) -> Void)? = nil)
{
@@ -109,6 +117,7 @@ struct SettingsProTab: View {
self.ownsNavigationStack = ownsNavigationStack
self.navigateToRoute = navigateToRoute
self.onRouteChange = onRouteChange
self.onApprovalNotificationsRoute = onApprovalNotificationsRoute
self.gatewaySetupRequest = gatewaySetupRequest
self.onGatewaySetupRequestHandled = onGatewaySetupRequestHandled
}
@@ -368,6 +377,9 @@ struct SettingsProTab: View {
func openNotificationsRouteFromApprovals() {
guard self.directRoute == nil else { return }
if let approvalID = ExecApprovalIdentifier.exact(self.appModel.pendingExecApprovalPrompt?.id) {
self.onApprovalNotificationsRoute?(approvalID)
}
if !self.ownsNavigationStack, let navigateToRoute {
navigateToRoute(.notifications)
return
@@ -117,11 +117,11 @@ extension SettingsProTab {
}
func switchGateway(to entry: GatewaySettingsStore.GatewayRegistryEntry) async {
guard self.connectingGatewayID == nil else { return }
self.connectingGatewayID = entry.stableID
guard self.connectingGateway == nil else { return }
self.connectingGateway = .gateway(entry.id)
self.setupStatusText = "Switching to \(entry.name)"
defer {
self.connectingGatewayID = nil
self.connectingGateway = nil
self.refreshGatewayRegistry()
}
if let failure = await self.gatewayController.switchToGateway(stableID: entry.stableID) {
@@ -139,7 +139,7 @@ extension SettingsProTab {
self.refreshGatewayRegistry()
return
}
if self.gatewayCredentialFieldStableID == entry.stableID {
if GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, entry.stableID) {
self.clearManualCredentialFields()
}
self.setupStatusText = "Forgot \(entry.name)."
@@ -261,9 +261,9 @@ extension SettingsProTab {
self.gatewayController.resumeAutoConnect(after: supersededSetupLease)
}
}
self.connectingGatewayID = gateway.id
self.connectingGateway = .gateway(gateway.id)
defer {
self.connectingGatewayID = nil
self.connectingGateway = nil
self.refreshGatewayRegistry()
}
self.manualGatewayEnabled = false
@@ -370,7 +370,7 @@ extension SettingsProTab {
self.stagedGatewaySetupLink = nil
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
self.scannerScanID = self.scannerResultHandoff.beginScan()
self.connectingGatewayID = nil
self.connectingGateway = nil
self.setupStatusText = "Opening QR scanner..."
self.showQRScanner = true
}
@@ -466,23 +466,29 @@ extension SettingsProTab {
self.setupStatusText = "Failed: invalid port"
return
}
self.connectingGatewayID = "manual"
self.connectingGateway = .manual
self.manualGatewayEnabled = true
defer {
self.connectingGatewayID = nil
self.connectingGateway = nil
self.refreshGatewayRegistry()
}
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID,
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
if GatewayStableIdentifier.matches(
self.appModel.activeGatewayConnectConfig?.effectiveStableID,
stableID),
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
{
self.pendingManualAuthOverride = nil
}
let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID
let pendingOverride = self.pendingManualAuthOverride?.targetStableID == stableID
let fieldsMatchTarget = GatewayStableIdentifier.matches(
self.gatewayCredentialFieldStableID,
stableID)
let pendingOverride = GatewayStableIdentifier.matches(
self.pendingManualAuthOverride?.targetStableID,
stableID)
? self.pendingManualAuthOverride
: nil
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
@@ -541,10 +547,10 @@ extension SettingsProTab {
}
func beginGatewaySetupAttempt() -> UUID? {
guard self.connectingGatewayID == nil else { return nil }
guard self.connectingGateway == nil else { return nil }
let attemptID = UUID()
self.setupAttemptID = attemptID
self.connectingGatewayID = "setup-code"
self.connectingGateway = .setupCode
return attemptID
}
@@ -555,7 +561,7 @@ extension SettingsProTab {
func invalidateGatewaySetupAttempt() {
self.setupAttemptID = nil
self.connectingGatewayID = nil
self.connectingGateway = nil
}
func handleLocationModeChange(_ newValue: String) {
@@ -801,11 +807,11 @@ extension SettingsProTab {
var gatewayCustomHeadersTargetStableID: String? {
guard let stableID = self.gatewayCredentialTargetStableID else { return nil }
if self.currentManualGatewayStableID == stableID {
if GatewayStableIdentifier.matches(self.currentManualGatewayStableID, stableID) {
return self.manualGatewayTLS ? stableID : nil
}
if let active = self.appModel.activeGatewayConnectConfig,
active.effectiveStableID == stableID
GatewayStableIdentifier.matches(active.effectiveStableID, stableID)
{
return active.url.scheme?.lowercased() == "wss" ? stableID : nil
}
@@ -840,7 +846,9 @@ extension SettingsProTab {
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualGatewayHost = value
if previousStableID != self.currentManualGatewayStableID {
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
{
self.clearManualCredentialFields()
}
})
@@ -926,7 +934,9 @@ extension SettingsProTab {
let filtered = newValue.filter(\.isNumber)
self.manualGatewayPortText = filtered
self.manualGatewayPort = Int(filtered) ?? 0
if previousStableID != self.currentManualGatewayStableID {
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
{
self.clearManualCredentialFields()
}
})
@@ -941,7 +951,7 @@ extension SettingsProTab {
private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) {
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if self.gatewayCredentialFieldStableID != stableID {
if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID) {
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceId,
gatewayStableID: stableID)
@@ -1162,6 +1172,14 @@ extension SettingsProTab {
self.appModel.pendingExecApprovalPrompt
}
var pendingApprovalCount: Int {
self.appModel.pendingExecApprovalCount
}
var approvalWaitingText: String {
self.pendingApprovalCount == 1 ? "1 waiting" : "\(self.pendingApprovalCount) waiting"
}
var notificationsNeedAttention: Bool {
self.notificationPresentation.needsAttention
}
@@ -328,12 +328,16 @@ extension SettingsProTab {
Text(entry.name)
.font(OpenClawType.body)
} icon: {
Image(systemName: entry.stableID == self.gatewayRegistry.activeStableID
Image(systemName: GatewayStableIdentifier.matches(
entry.stableID,
self.gatewayRegistry.activeStableID)
? "checkmark.circle.fill"
: "circle")
}
}
.disabled(entry.stableID == self.gatewayRegistry.activeStableID || self.connectingGatewayID != nil)
.disabled(
GatewayStableIdentifier.matches(entry.stableID, self.gatewayRegistry.activeStableID) ||
self.connectingGateway != nil)
}
} label: {
Image(systemName: "arrow.triangle.2.circlepath")
@@ -350,13 +354,13 @@ extension SettingsProTab {
title: "Approvals",
detail: self.notificationsNeedAttention
? "Out-of-app approval alerts need notification permission."
: (self.pendingApproval == nil ? "No gateway actions are waiting for review." :
"Review the pending gateway action."),
: (self.pendingApprovalCount == 0 ? "No gateway actions are waiting for review." :
"Review pending gateway actions."),
value: self.notificationsNeedAttention
? "Alerts Off"
: (self.pendingApproval == nil ? "clear" : "1 waiting"),
: (self.pendingApprovalCount == 0 ? "clear" : self.approvalWaitingText),
color: self.notificationsNeedAttention ? OpenClawBrand.warn :
(self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn))
(self.pendingApprovalCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn))
if self.notificationsNeedAttention {
self.approvalNotificationsWarningCard
@@ -436,41 +440,101 @@ extension SettingsProTab {
@ViewBuilder
var approvalsReviewCard: some View {
if !self.appModel.pendingExecApprovalInboxItems.isEmpty {
Section("Pending approvals") {
ForEach(self.appModel.pendingExecApprovalInboxItems) { item in
Button {
self.appModel.presentPendingExecApprovalFromInbox(item.id)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(item.prompt.commandPreview ?? item.prompt.commandText)
.font(OpenClawType.body)
.foregroundStyle(.primary)
.lineLimit(2)
Text(item.prompt.gatewayStableID)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.accessibilityLabel("Review exec approval")
.accessibilityValue(item.prompt.commandPreview ?? item.prompt.commandText)
}
}
}
if let pendingApproval {
Section {
Section("Reviewing") {
ForEach(self.approvalItems, id: \.id) { item in
SettingsApprovalRow(item: item)
}
if let warningText = pendingApproval.warningText {
Label {
Text(warningText)
.font(OpenClawType.caption)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
}
.foregroundStyle(OpenClawBrand.warn)
.fixedSize(horizontal: false, vertical: true)
}
if let errorText = self.appModel.pendingExecApprovalPromptErrorText {
Text(errorText)
.font(OpenClawType.caption)
.foregroundStyle(OpenClawBrand.danger)
}
Button {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
} label: {
Label("Allow", systemImage: "checkmark")
.font(OpenClawType.body)
}
.disabled(self.appModel.pendingExecApprovalPromptResolving)
if pendingApproval.allowsAllowAlways {
if let resolvedText = self.appModel.pendingExecApprovalPromptResolvedText {
Text(resolvedText)
.font(OpenClawType.caption)
.foregroundStyle(self.approvalOutcomeColor)
Button {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always") }
self.appModel.dismissPendingExecApprovalPrompt()
} label: {
Label("Always Allow", systemImage: "checkmark.shield")
Label("Dismiss", systemImage: "xmark")
.font(OpenClawType.body)
}
.disabled(self.appModel.pendingExecApprovalPromptResolving)
} else {
if pendingApproval.allowsAllowOnce {
Button {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
} label: {
Label("Allow Once", systemImage: "checkmark")
.font(OpenClawType.body)
}
.disabled(self.appModel.pendingExecApprovalPromptResolving)
}
if pendingApproval.allowsAllowAlways {
Button {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always") }
} label: {
Label("Allow Always", systemImage: "checkmark.shield")
.font(OpenClawType.body)
}
.disabled(self.appModel.pendingExecApprovalPromptResolving)
}
if pendingApproval.allowsDeny {
Button(role: .destructive) {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
} label: {
Label("Deny", systemImage: "xmark")
.font(OpenClawType.body)
}
.disabled(self.appModel.pendingExecApprovalPromptResolving)
}
if self.appModel.pendingExecApprovalPromptResolving,
self.appModel.pendingExecApprovalPromptCanDismiss
{
Button(role: .cancel) {
self.appModel.dismissPendingExecApprovalPrompt()
} label: {
Label("Dismiss", systemImage: "xmark")
.font(OpenClawType.body)
}
}
}
Button(role: .destructive) {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
} label: {
Label("Deny", systemImage: "xmark")
.font(OpenClawType.body)
}
.disabled(self.appModel.pendingExecApprovalPromptResolving)
}
} else {
} else if self.pendingApprovalCount == 0 {
Section {
Label {
VStack(alignment: .leading, spacing: 2) {
@@ -488,6 +552,19 @@ extension SettingsProTab {
}
}
private var approvalOutcomeColor: Color {
switch self.appModel.pendingExecApprovalPromptOutcome?.tone {
case .success:
OpenClawBrand.ok
case .danger:
OpenClawBrand.danger
case .warning:
OpenClawBrand.warn
case .neutral, nil:
.secondary
}
}
var permissionsDestination: some View {
Group {
self.toggleCard(
@@ -875,13 +952,13 @@ extension SettingsProTab {
.font(OpenClawType.body)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(self.connectingGatewayID != nil)
.disabled(self.connectingGateway != nil)
self.gatewayActionButton(
title: "Scan QR",
icon: "qrcode.viewfinder",
color: OpenClawBrand.accent,
isBusy: false,
isDisabled: self.connectingGatewayID != nil)
isDisabled: self.connectingGateway != nil)
{
self.openGatewayQRScanner()
}
@@ -890,8 +967,8 @@ extension SettingsProTab {
title: "Connect",
icon: "bolt.horizontal.circle",
color: OpenClawBrand.accent,
isBusy: self.connectingGatewayID == "manual",
isDisabled: !self.canApplyGatewaySetup || self.connectingGatewayID != nil)
isBusy: self.connectingGateway == .manual,
isDisabled: !self.canApplyGatewaySetup || self.connectingGateway != nil)
{
Task { await self.applySetupCodeAndConnect() }
}
@@ -943,7 +1020,9 @@ extension SettingsProTab {
}
func pairedGatewayRow(_ entry: GatewaySettingsStore.GatewayRegistryEntry) -> some View {
let isActive = entry.stableID == self.gatewayRegistry.activeStableID
let isActive = GatewayStableIdentifier.matches(
entry.stableID,
self.gatewayRegistry.activeStableID)
return Button {
guard !isActive else { return }
Task { await self.switchGateway(to: entry) }
@@ -958,7 +1037,7 @@ extension SettingsProTab {
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if self.connectingGatewayID == entry.stableID {
if self.connectingGateway == .gateway(entry.id) {
ProgressView()
.controlSize(.small)
} else if isActive {
@@ -971,7 +1050,7 @@ extension SettingsProTab {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(self.connectingGatewayID != nil)
.disabled(self.connectingGateway != nil)
.swipeActions {
Button(role: .destructive) {
self.pendingForgetGateway = entry
@@ -1015,7 +1094,7 @@ extension SettingsProTab {
Button {
Task { await self.connect(gateway) }
} label: {
if self.connectingGatewayID == gateway.id {
if self.connectingGateway == .gateway(gateway.id) {
ProgressView().controlSize(.small)
} else {
Text(availability.actionTitle)
@@ -1024,7 +1103,7 @@ extension SettingsProTab {
}
.font(OpenClawType.captionSemiBold)
.buttonStyle(.bordered)
.disabled(self.connectingGatewayID != nil)
.disabled(self.connectingGateway != nil)
} else {
Text(availability.actionTitle)
.font(OpenClawType.captionSemiBold)
@@ -1073,7 +1152,7 @@ extension SettingsProTab {
title: "Connect Manual",
icon: "network",
color: OpenClawBrand.accent,
isBusy: self.connectingGatewayID == "manual",
isBusy: self.connectingGateway == .manual,
isDisabled: self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !self.manualPortIsValid)
{
@@ -2,61 +2,109 @@ import SwiftUI
private struct ExecApprovalPromptDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
let suppressedApprovalID: String?
@AccessibilityFocusState private var approvalCardFocused: Bool
let suppressedApproval: NodeAppModel.ExecApprovalInboxKey?
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingExecApprovalPrompt,
prompt.id != self.suppressedApprovalID
{
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
let prompt = self.presentedPrompt
ZStack {
content
.allowsHitTesting(prompt == nil)
.accessibilityHidden(prompt != nil)
ExecApprovalPromptCard(
prompt: prompt,
isResolving: self.appModel.pendingExecApprovalPromptResolving,
errorText: self.appModel.pendingExecApprovalPromptErrorText,
onAllowOnce: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
}
},
onAllowAlways: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
},
onDeny: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny")
}
},
onCancel: {
self.appModel.dismissPendingExecApprovalPrompt()
})
.padding(.horizontal, 20)
.frame(maxWidth: 460)
.transition(.scale(scale: 0.98).combined(with: .opacity))
}
.zIndex(1)
if let prompt {
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
.accessibilityHidden(true)
ExecApprovalPromptCard(
prompt: prompt,
isResolving: self.appModel.pendingExecApprovalPromptResolving,
canDismiss: self.appModel.pendingExecApprovalPromptCanDismiss,
errorText: self.appModel.pendingExecApprovalPromptErrorText,
resolvedText: self.appModel.pendingExecApprovalPromptResolvedText,
resolvedTone: self.appModel.pendingExecApprovalPromptOutcome?.tone,
onAllowOnce: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
}
},
onAllowAlways: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
},
onDeny: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny")
}
},
onCancel: {
self.appModel.dismissPendingExecApprovalPrompt()
})
.frame(maxHeight: 680)
.padding(.horizontal, 20)
.padding(.vertical, 16)
.frame(maxWidth: 460)
.accessibilityElement(children: .contain)
.accessibilityAddTraits(.isModal)
.accessibilityFocused(self.$approvalCardFocused)
.onAppear { self.approvalCardFocused = true }
.transition(.scale(scale: 0.98).combined(with: .opacity))
}
.zIndex(1)
}
.animation(.easeInOut(duration: 0.18), value: self.appModel.pendingExecApprovalPrompt?.id)
}
.onChange(of: self.presentedPromptKey) { _, key in
self.approvalCardFocused = key != nil
}
.animation(.easeInOut(duration: 0.18), value: self.presentedPromptKey)
}
private var presentedPrompt: NodeAppModel.ExecApprovalPrompt? {
guard let prompt = self.appModel.pendingExecApprovalPrompt,
NodeAppModel.execApprovalInboxKey(prompt) != self.suppressedApproval
else { return nil }
return prompt
}
private var presentedPromptKey: NodeAppModel.ExecApprovalInboxKey? {
NodeAppModel.execApprovalInboxKey(self.presentedPrompt)
}
}
private struct ExecApprovalPromptCard: View {
let prompt: NodeAppModel.ExecApprovalPrompt
let isResolving: Bool
let canDismiss: Bool
let errorText: String?
let resolvedText: String?
let resolvedTone: NodeAppModel.ExecApprovalOutcomeTone?
let onAllowOnce: () -> Void
let onAllowAlways: () -> Void
let onDeny: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(spacing: 0) {
ScrollView {
self.reviewContent
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
}
.accessibilityIdentifier("exec-approval-review-scroll")
Divider()
self.actionFooter
.padding(18)
.accessibilityIdentifier("exec-approval-actions")
}
.proPanelSurface(tint: OpenClawBrand.accentHot, radius: 20, isProminent: true)
}
private var reviewContent: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Exec approval required")
@@ -74,6 +122,17 @@ private struct ExecApprovalPromptCard: View {
.black.opacity(0.14),
in: RoundedRectangle(cornerRadius: OpenClawRadius.md, style: .continuous))
if let warningText = self.normalized(self.prompt.warningText) {
Label {
Text(warningText)
.font(OpenClawType.footnote)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
}
.foregroundStyle(OpenClawBrand.warn)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 8) {
if let host = self.normalized(self.prompt.host) {
ExecApprovalPromptMetadataRow(label: "Host", value: host)
@@ -95,6 +154,12 @@ private struct ExecApprovalPromptCard: View {
.foregroundStyle(OpenClawBrand.danger)
}
if let resolvedText = self.normalized(self.resolvedText) {
Text(resolvedText)
.font(OpenClawType.footnote)
.foregroundStyle(self.resolvedColor)
}
if self.isResolving {
HStack(spacing: 8) {
ProgressView()
@@ -104,17 +169,23 @@ private struct ExecApprovalPromptCard: View {
.foregroundStyle(.secondary)
}
}
}
}
VStack(spacing: 10) {
Button {
self.onAllowOnce()
} label: {
Text("Allow Once")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
private var actionFooter: some View {
VStack(spacing: 10) {
if self.resolvedText == nil {
if self.prompt.allowsAllowOnce {
Button {
self.onAllowOnce()
} label: {
Text("Allow Once")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(self.isResolving)
}
.buttonStyle(.borderedProminent)
.disabled(self.isResolving)
if self.prompt.allowsAllowAlways {
Button {
@@ -128,33 +199,58 @@ private struct ExecApprovalPromptCard: View {
.disabled(self.isResolving)
}
HStack(spacing: 10) {
Button(role: .destructive) {
self.onDeny()
} label: {
Text("Deny")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
if self.prompt.allowsDeny {
self.denyButton
}
self.cancelButton
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
Button(role: .cancel) {
self.onCancel()
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
VStack(spacing: 10) {
if self.prompt.allowsDeny {
self.denyButton
}
self.cancelButton
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
} else {
Button(role: .cancel) {
self.onCancel()
} label: {
Text("Dismiss")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.padding(18)
.proPanelSurface(tint: OpenClawBrand.accentHot, radius: 20, isProminent: true)
.controlSize(.large)
.frame(maxWidth: .infinity)
}
private var denyButton: some View {
Button(role: .destructive) {
self.onDeny()
} label: {
Text("Deny")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
private var cancelButton: some View {
Button(role: .cancel) {
self.onCancel()
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(!self.canDismiss)
}
private func normalized(_ value: String?) -> String? {
@@ -162,6 +258,19 @@ private struct ExecApprovalPromptCard: View {
return trimmed.isEmpty ? nil : trimmed
}
private var resolvedColor: Color {
switch self.resolvedTone {
case .success:
OpenClawBrand.ok
case .danger:
OpenClawBrand.danger
case .warning:
OpenClawBrand.warn
case .neutral, nil:
.secondary
}
}
private func expiresText(_ expiresAtMs: Int64?) -> String? {
guard let expiresAtMs else { return nil }
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
@@ -197,7 +306,9 @@ private struct ExecApprovalPromptMetadataRow: View {
}
extension View {
func execApprovalPromptDialog(suppressedApprovalID: String? = nil) -> some View {
modifier(ExecApprovalPromptDialogModifier(suppressedApprovalID: suppressedApprovalID))
func execApprovalPromptDialog(
suppressedApproval: NodeAppModel.ExecApprovalInboxKey? = nil) -> some View
{
modifier(ExecApprovalPromptDialogModifier(suppressedApproval: suppressedApproval))
}
}
@@ -21,14 +21,12 @@ struct GatewayConnectConfig {
/// Stable, non-empty route identifier used for UI/event ownership.
/// If the caller doesn't provide a stableID, fall back to URL identity.
var effectiveStableID: String {
let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return self.url.absoluteString }
return trimmed
GatewayStableIdentifier.exact(self.stableID) ?? self.url.absoluteString
}
func hasSameConnectionInputs(as other: GatewayConnectConfig) -> Bool {
self.url == other.url &&
self.stableID == other.stableID &&
Self.sameStableID(self.effectiveStableID, other.effectiveStableID) &&
Self.sameTLS(self.tls, other.tls) &&
self.token == other.token &&
self.bootstrapToken == other.bootstrapToken &&
@@ -65,7 +63,7 @@ struct GatewayConnectConfig {
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhs.allowStoredDeviceAuth == rhs.allowStoredDeviceAuth &&
lhs.deviceAuthGatewayID == rhs.deviceAuthGatewayID &&
Self.sameOptionalStableID(lhs.deviceAuthGatewayID, rhs.deviceAuthGatewayID) &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&
lhsCommands == rhsCommands &&
@@ -77,4 +75,19 @@ struct GatewayConnectConfig {
.filter { !$0.isEmpty }
.sorted()
}
private static func sameStableID(_ lhs: String, _ rhs: String) -> Bool {
ExactOpaqueIdentifierKey(lhs) == ExactOpaqueIdentifierKey(rhs)
}
private static func sameOptionalStableID(_ lhs: String?, _ rhs: String?) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
true
case let (lhs?, rhs?):
self.sameStableID(lhs, rhs)
default:
false
}
}
}
@@ -75,7 +75,7 @@ extension GatewayConnectionController {
clientMode: "node",
clientDisplayName: displayName,
allowStoredDeviceAuth: allowStoredDeviceAuth,
deviceAuthGatewayID: deviceAuthGatewayID)
deviceAuthGatewayID: GatewayStableIdentifier.exact(deviceAuthGatewayID))
}
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
@@ -42,9 +42,8 @@ extension GatewayConnectionController {
}
DeviceAuthStore.discardUnscopedTokens(deviceId: primaryIdentity.deviceId)
guard let relay else { return }
let relayStableID = relay.gatewayStableID?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard relayStableID.isEmpty else { return }
// Stable IDs are opaque byte-exact tokens; do not trim or normalize before comparing.
guard GatewayStableIdentifier.exact(relay.gatewayStableID) == nil else { return }
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
gatewayURLString: relay.gatewayURLString,
gatewayStableID: migrationGatewayID,
@@ -57,9 +56,7 @@ extension GatewayConnectionController {
private static func legacyDeviceAuthMigrationGatewayID() -> String? {
guard let relay = ShareGatewayRelaySettings.loadConfig() else { return nil }
if let stableID = relay.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!stableID.isEmpty
{
if let stableID = GatewayStableIdentifier.exact(relay.gatewayStableID) {
return stableID
}
guard let active = GatewaySettingsStore.activeGatewayEntry(),
@@ -164,7 +161,9 @@ extension GatewayConnectionController {
guard let pendingOverride else {
return ManualAuthOverride.normalized(token: token, bootstrapToken: nil, password: password)
}
if let pendingTarget = pendingOverride.targetStableID, pendingTarget != targetStableID {
if let pendingTarget = pendingOverride.targetStableID,
!GatewayStableIdentifier.matches(pendingTarget, targetStableID)
{
let normalizedInput = ManualAuthOverride.explicit(
token: token,
bootstrapToken: nil,
@@ -112,7 +112,7 @@ final class GatewayConnectionController {
@ObservationIgnored private var pendingAutoConnectGeneration: UInt64?
@ObservationIgnored private var pendingAutoConnectSuppressionGeneration: UInt64?
@ObservationIgnored private var pendingForgetCleanups: [
String: (id: UUID, task: Task<Void, Never>)
GatewayStableIdentifier.Key: (id: UUID, task: Task<Void, Never>)
] = [:]
private var pendingConnectionStableID: String?
private let tcpReachabilityProbe: GatewayTCPReachabilityProbe
@@ -482,7 +482,9 @@ final class GatewayConnectionController {
guard let host = active.host, let port = active.port else { return }
await self.connectManual(host: host, port: port, useTLS: active.useTLS, forceReconnect: true)
case .discovered:
if let gateway = self.gateways.first(where: { $0.stableID == active.stableID }) {
if let gateway = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, active.stableID)
}) {
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
return
}
@@ -494,9 +496,11 @@ final class GatewayConnectionController {
/// Returns `nil` after initiating a switch, or a user-facing discovery failure.
func switchToGateway(stableID: String) async -> String? {
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard let stableID = GatewayStableIdentifier.exact(stableID) else {
return "This paired gateway is no longer available."
}
guard let entry = GatewaySettingsStore.loadGatewayRegistry().entries.first(where: {
$0.stableID == stableID
GatewayStableIdentifier.matches($0.stableID, stableID)
}) else {
return "This paired gateway is no longer available."
}
@@ -516,7 +520,9 @@ final class GatewayConnectionController {
forceReconnect: true)
return nil
case .discovered:
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else {
guard let gateway = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, stableID)
}) else {
return "\(entry.name) is not currently discoverable on this network."
}
guard GatewaySettingsStore.setActiveGateway(stableID: stableID) else {
@@ -528,23 +534,27 @@ final class GatewayConnectionController {
@discardableResult
func forgetGateway(stableID: String) -> Bool {
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty else { return false }
if self.pendingForgetCleanups[stableID] != nil {
guard let stableID = GatewayStableIdentifier.exact(stableID),
let stableIDKey = GatewayStableIdentifier.key(stableID)
else { return false }
if self.pendingForgetCleanups[stableIDKey] != nil {
return true
}
guard GatewaySettingsStore.removeGatewayRegistryEntry(stableID: stableID) else {
return false
}
if self.pendingConnectionStableID == stableID {
if GatewayStableIdentifier.matches(self.pendingConnectionStableID, stableID) {
let cancellationLease = self.cancelPendingConnectionAttempts()
self.releaseAutoConnectSuppression(after: cancellationLease)
}
let wasConnected = self.appModel?.activeGatewayConnectConfig?.effectiveStableID == stableID ||
self.appModel?.connectedGatewayID == stableID
let wasConnected = GatewayStableIdentifier.matches(
self.appModel?.activeGatewayConnectConfig?.effectiveStableID,
stableID) || GatewayStableIdentifier.matches(self.appModel?.connectedGatewayID, stableID)
let shouldDisconnect = wasConnected
if shouldDisconnect {
let hasDifferentPendingTarget = self.pendingConnectionStableID.map { $0 != stableID } ?? false
let hasDifferentPendingTarget = self.pendingConnectionStableID.map {
!GatewayStableIdentifier.matches($0, stableID)
} ?? false
self.appModel?.disconnectForgottenGateway(
preservingPendingConnectAttempt: hasDifferentPendingTarget)
}
@@ -556,9 +566,8 @@ final class GatewayConnectionController {
_ = GatewayTLSStore.clearFingerprint(stableID: stableID)
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: stableID, clientId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: nil)
let shareRelayGatewayID = ShareGatewayRelaySettings.loadConfig()?.gatewayStableID?
.trimmingCharacters(in: .whitespacesAndNewlines)
if shareRelayGatewayID == stableID {
let shareRelayGatewayID = ShareGatewayRelaySettings.loadConfig()?.gatewayStableID
if GatewayStableIdentifier.matches(shareRelayGatewayID, stableID) {
ShareGatewayRelaySettings.clearConfig()
}
@@ -576,20 +585,22 @@ final class GatewayConnectionController {
OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL)
}
}
self.pendingForgetCleanups[stableID] = (cleanupID, cleanupTask)
self.pendingForgetCleanups[stableIDKey] = (cleanupID, cleanupTask)
Task { @MainActor [weak self] in
await cleanupTask.value
guard self?.pendingForgetCleanups[stableID]?.id == cleanupID else { return }
self?.pendingForgetCleanups[stableID] = nil
guard self?.pendingForgetCleanups[stableIDKey]?.id == cleanupID else { return }
self?.pendingForgetCleanups[stableIDKey] = nil
}
return true
}
private func waitForPendingForgetCleanup(stableID: String) async {
guard let pending = self.pendingForgetCleanups[stableID] else { return }
guard let stableIDKey = GatewayStableIdentifier.key(stableID),
let pending = self.pendingForgetCleanups[stableIDKey]
else { return }
await pending.task.value
if self.pendingForgetCleanups[stableID]?.id == pending.id {
self.pendingForgetCleanups[stableID] = nil
if self.pendingForgetCleanups[stableIDKey]?.id == pending.id {
self.pendingForgetCleanups[stableIDKey] = nil
}
}
@@ -625,7 +636,10 @@ final class GatewayConnectionController {
let port = Self.resolvedManualPort(
host: host,
port: defaults.integer(forKey: "gateway.manual.port"))
guard !host.isEmpty, let port, self.manualStableID(host: host, port: port) == stableID else { return }
guard !host.isEmpty,
let port,
GatewayStableIdentifier.matches(self.manualStableID(host: host, port: port), stableID)
else { return }
defaults.set(false, forKey: "gateway.manual.enabled")
defaults.removeObject(forKey: "gateway.manual.host")
defaults.removeObject(forKey: "gateway.manual.port")
@@ -738,7 +752,7 @@ final class GatewayConnectionController {
func acceptPendingTrustPrompt() async {
guard let pending = self.pendingTrustConnect,
let prompt = self.pendingTrustPrompt,
pending.stableID == prompt.stableID
GatewayStableIdentifier.matches(pending.stableID, prompt.stableID)
else { return }
guard self.persistTLSFingerprint(prompt.fingerprintSha256, pending.stableID) else {
@@ -885,7 +899,9 @@ extension GatewayConnectionController {
return
}
if active.kind == .discovered,
let target = self.gateways.first(where: { $0.stableID == active.stableID }),
let target = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, active.stableID)
}),
GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil
{
self.didAutoConnect = true
@@ -910,16 +926,18 @@ extension GatewayConnectionController {
return
}
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let preferredStableID = GatewayStableIdentifier.exact(
defaults.string(forKey: "gateway.preferredStableID"))
let lastDiscoveredStableID = GatewayStableIdentifier.exact(
defaults.string(forKey: "gateway.lastDiscoveredStableID"))
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
let candidates = [preferredStableID, lastDiscoveredStableID].compactMap(\.self)
if let targetStableID = candidates.first(where: { id in
self.gateways.contains(where: { $0.stableID == id })
self.gateways.contains(where: { GatewayStableIdentifier.matches($0.stableID, id) })
}) {
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
guard let target = self.gateways.first(where: {
GatewayStableIdentifier.matches($0.stableID, targetStableID)
}) else { return }
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return }
@@ -1029,19 +1047,19 @@ extension GatewayConnectionController {
let lhsConnected = lhs.lastConnectedAtMs ?? Int.min
let rhsConnected = rhs.lastConnectedAtMs ?? Int.min
if lhsConnected != rhsConnected { return lhsConnected < rhsConnected }
return lhs.stableID > rhs.stableID
return GatewayStableIdentifier.sortsBefore(rhs.stableID, lhs.stableID)
}
}
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
let defaults = UserDefaults.standard
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let existingLast = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let preferred = GatewayStableIdentifier.exact(
defaults.string(forKey: "gateway.preferredStableID"))
let existingLast = GatewayStableIdentifier.exact(
defaults.string(forKey: "gateway.lastDiscoveredStableID"))
// Avoid overriding user intent (preferred/lastDiscovered are also set on manual Connect).
guard preferred.isEmpty, existingLast.isEmpty else { return }
guard preferred == nil, existingLast == nil else { return }
guard let first = gateways.first else { return }
defaults.set(first.stableID, forKey: "gateway.lastDiscoveredStableID")
@@ -1061,7 +1079,9 @@ extension GatewayConnectionController {
suppressionGeneration: UInt64? = nil,
expectedGeneration: UInt64? = nil) -> Bool
{
guard let appModel else { return false }
guard let appModel,
let gatewayStableID = GatewayStableIdentifier.exact(gatewayStableID)
else { return false }
if let expectedGeneration {
guard expectedGeneration == appModel.gatewayConnectGeneration else { return false }
}
@@ -1083,7 +1103,7 @@ extension GatewayConnectionController {
self.pendingAutoConnectTask = nil
self.pendingAutoConnectGeneration = nil
self.pendingAutoConnectSuppressionGeneration = nil
if self.pendingConnectionStableID == gatewayStableID {
if GatewayStableIdentifier.matches(self.pendingConnectionStableID, gatewayStableID) {
self.pendingConnectionStableID = nil
}
}
@@ -13,8 +13,8 @@ final class GatewayDiscoveryModel {
}
struct DiscoveredGateway: Identifiable, Equatable {
var id: String {
self.stableID
var id: GatewayStableIdentifier.Key {
GatewayStableIdentifier.Key(self.stableID)
}
var name: String
@@ -28,6 +28,20 @@ final class GatewayDiscoveryModel {
var tlsEnabled: Bool
var tlsFingerprintSha256: String?
var cliPath: String?
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.name == rhs.name &&
lhs.endpoint == rhs.endpoint &&
GatewayStableIdentifier.matches(lhs.stableID, rhs.stableID) &&
lhs.debugID == rhs.debugID &&
lhs.lanHost == rhs.lanHost &&
lhs.tailnetDns == rhs.tailnetDns &&
lhs.gatewayPort == rhs.gatewayPort &&
lhs.canvasPort == rhs.canvasPort &&
lhs.tlsEnabled == rhs.tlsEnabled &&
lhs.tlsFingerprintSha256 == rhs.tlsFingerprintSha256 &&
lhs.cliPath == rhs.cliPath
}
}
var gateways: [DiscoveredGateway] = []
@@ -38,7 +52,7 @@ final class GatewayDiscoveryModel {
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
private var statesByDomain: [String: NWBrowser.State] = [:]
private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>()
private var lastStableIDs = Set<GatewayStableIdentifier.Key>()
func setDebugLoggingEnabled(_ enabled: Bool) {
let wasEnabled = self.debugLoggingEnabled
@@ -119,7 +133,7 @@ final class GatewayDiscoveryModel {
.flatMap(\.self)
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
let nextIDs = Set(next.map(\.stableID))
let nextIDs = Set(next.map { GatewayStableIdentifier.Key($0.stableID) })
let added = nextIDs.subtracting(self.lastStableIDs)
let removed = self.lastStableIDs.subtracting(nextIDs)
if !added.isEmpty || !removed.isEmpty {
@@ -55,8 +55,18 @@ enum GatewaySettingsStore {
var useTLS: Bool
var lastConnectedAtMs: Int?
var id: String {
self.stableID
var id: GatewayStableIdentifier.Key {
GatewayStableIdentifier.Key(self.stableID)
}
static func == (lhs: Self, rhs: Self) -> Bool {
GatewayStableIdentifier.matches(lhs.stableID, rhs.stableID) &&
lhs.kind == rhs.kind &&
lhs.name == rhs.name &&
lhs.host == rhs.host &&
lhs.port == rhs.port &&
lhs.useTLS == rhs.useTLS &&
lhs.lastConnectedAtMs == rhs.lastConnectedAtMs
}
}
@@ -137,18 +147,13 @@ enum GatewaySettingsStore {
}
static func loadPreferredGatewayStableID() -> String? {
if let value = KeychainStore.loadString(
GatewayStableIdentifier.exact(KeychainStore.loadString(
service: self.gatewayService,
account: self.preferredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
{
return value
}
return nil
account: self.preferredGatewayStableIDAccount))
}
static func savePreferredGatewayStableID(_ stableID: String) {
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
_ = KeychainStore.saveString(
stableID,
service: self.gatewayService,
@@ -163,18 +168,13 @@ enum GatewaySettingsStore {
}
static func loadLastDiscoveredGatewayStableID() -> String? {
if let value = KeychainStore.loadString(
GatewayStableIdentifier.exact(KeychainStore.loadString(
service: self.gatewayService,
account: self.lastDiscoveredGatewayStableIDAccount)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
{
return value
}
return nil
account: self.lastDiscoveredGatewayStableIDAccount))
}
static func saveLastDiscoveredGatewayStableID(_ stableID: String) {
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
_ = KeychainStore.saveString(
stableID,
service: self.gatewayService,
@@ -239,6 +239,9 @@ enum GatewaySettingsStore {
let hasCredentials = bundle.token != nil || bundle.bootstrapToken != nil || bundle.password != nil
guard hasCredentials || suppressStoredDeviceAuth else {
let deleted = KeychainStore.delete(service: self.gatewayService, account: account)
self.deleteLegacyScopedCredentialBundleIfOwned(
instanceId: trimmedInstanceID,
stableID: stableID)
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
return deleted || KeychainStore.loadString(service: self.gatewayService, account: account) == nil
}
@@ -257,6 +260,9 @@ enum GatewaySettingsStore {
// known-good bundle; callers already treat this attempted update as uncommitted.
return false
}
self.deleteLegacyScopedCredentialBundleIfOwned(
instanceId: trimmedInstanceID,
stableID: stableID)
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
return true
}
@@ -309,7 +315,7 @@ enum GatewaySettingsStore {
/// Certificate pins prove transport trust for one route; they are not gateway identities.
/// Wildcard certificates and reverse proxies may legitimately reuse a leaf certificate.
static func authenticationOwnerID(routeStableID: String) -> String {
routeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
GatewayStableIdentifier.exact(routeStableID) ?? ""
}
/// Custom proxy headers are per-gateway credentials (Cloudflare Access-style service
@@ -324,13 +330,22 @@ enum GatewaySettingsStore {
service: String) -> [String: String]
{
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
guard !stableID.isEmpty,
let json = KeychainStore.loadString(
service: service,
account: self.customHeadersAccount(stableID: stableID)),
guard !stableID.isEmpty else { return [:] }
let account = self.customHeadersAccount(stableID: stableID)
let legacyAccount = self.legacyCustomHeadersAccount(stableID: stableID)
let canonicalJSON = KeychainStore.loadString(service: service, account: account)
let legacyJSON = self.canSafelyReadLegacyRawStorageKey(stableID)
? KeychainStore.loadString(service: service, account: legacyAccount)
: nil
guard let json = canonicalJSON ?? legacyJSON,
let data = json.data(using: .utf8),
let headers = try? JSONDecoder().decode([String: String].self, from: data)
else { return [:] }
if canonicalJSON == nil,
KeychainStore.saveString(json, service: service, account: account)
{
_ = KeychainStore.delete(service: service, account: legacyAccount)
}
return GatewayCustomHeaders.sanitized(headers)
}
@@ -350,16 +365,21 @@ enum GatewaySettingsStore {
{
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
guard !stableID.isEmpty else { return false }
let account = self.customHeadersAccount(stableID: stableID)
let sanitized = GatewayCustomHeaders.sanitized(headers)
guard !sanitized.isEmpty else {
let deleted = KeychainStore.delete(service: service, account: account)
return deleted || KeychainStore.loadString(service: service, account: account) == nil
return self.clearGatewayCustomHeaders(gatewayStableID: stableID, service: service)
}
let account = self.customHeadersAccount(stableID: stableID)
guard let data = try? JSONEncoder().encode(sanitized),
let json = String(data: data, encoding: .utf8)
else { return false }
return KeychainStore.saveString(json, service: service, account: account)
guard KeychainStore.saveString(json, service: service, account: account) else { return false }
if self.canSafelyReadLegacyRawStorageKey(stableID) {
_ = KeychainStore.delete(
service: service,
account: self.legacyCustomHeadersAccount(stableID: stableID))
}
return true
}
/// Full onboarding reset is the explicit forget boundary for every gateway's proxy secrets.
@@ -380,8 +400,15 @@ enum GatewaySettingsStore {
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
guard !stableID.isEmpty else { return false }
let account = self.customHeadersAccount(stableID: stableID)
let deleted = KeychainStore.delete(service: service, account: account)
return deleted || KeychainStore.loadString(service: service, account: account) == nil
let canonicalDeleted = KeychainStore.delete(service: service, account: account)
var legacyCleared = true
if self.canSafelyReadLegacyRawStorageKey(stableID) {
let legacyAccount = self.legacyCustomHeadersAccount(stableID: stableID)
let legacyDeleted = KeychainStore.delete(service: service, account: legacyAccount)
legacyCleared = legacyDeleted || KeychainStore.loadString(service: service, account: legacyAccount) == nil
}
let canonicalCleared = canonicalDeleted || KeychainStore.loadString(service: service, account: account) == nil
return canonicalCleared && legacyCleared
}
@discardableResult
@@ -390,6 +417,10 @@ enum GatewaySettingsStore {
}
private static func customHeadersAccount(stableID: String) -> String {
"customHeaders.v2.\(GatewayStableIdentifier.storageComponent(stableID)!)"
}
private static func legacyCustomHeadersAccount(stableID: String) -> String {
"customHeaders.\(stableID)"
}
@@ -401,8 +432,9 @@ enum GatewaySettingsStore {
password: String?) -> Bool
{
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInstanceID.isEmpty, !stableID.isEmpty else { return false }
guard let stableID = GatewayStableIdentifier.exact(gatewayStableID),
!trimmedInstanceID.isEmpty
else { return false }
let legacyAccounts = [
self.gatewayTokenAccount(instanceId: trimmedInstanceID),
self.gatewayBootstrapTokenAccount(instanceId: trimmedInstanceID),
@@ -488,7 +520,9 @@ enum GatewaySettingsStore {
static func upsertGatewayRegistryEntry(_ entry: GatewayRegistryEntry, activate: Bool) -> Bool {
guard let normalized = self.normalizedGatewayRegistryEntry(entry) else { return false }
var registry = self.loadGatewayRegistry()
if let index = registry.entries.firstIndex(where: { $0.stableID == normalized.stableID }) {
if let index = registry.entries.firstIndex(where: {
GatewayStableIdentifier.matches($0.stableID, normalized.stableID)
}) {
var replacement = normalized
if replacement.lastConnectedAtMs == nil {
replacement.lastConnectedAtMs = registry.entries[index].lastConnectedAtMs
@@ -505,28 +539,32 @@ enum GatewaySettingsStore {
@discardableResult
static func setActiveGateway(stableID: String) -> Bool {
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
var registry = self.loadGatewayRegistry()
guard registry.entries.contains(where: { $0.stableID == stableID }) else { return false }
registry.activeStableID = stableID
guard let storedID = registry.entries.first(where: {
GatewayStableIdentifier.matches($0.stableID, stableID)
})?.stableID else { return false }
registry.activeStableID = storedID
return self.saveGatewayRegistry(registry)
}
@discardableResult
static func markGatewayConnected(stableID: String, atMs: Int) -> Bool {
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
var registry = self.loadGatewayRegistry()
guard let index = registry.entries.firstIndex(where: { $0.stableID == stableID }) else { return false }
guard let index = registry.entries.firstIndex(where: {
GatewayStableIdentifier.matches($0.stableID, stableID)
}) else { return false }
registry.entries[index].lastConnectedAtMs = atMs
return self.saveGatewayRegistry(registry)
}
@discardableResult
static func removeGatewayRegistryEntry(stableID: String) -> Bool {
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
var registry = self.loadGatewayRegistry()
registry.entries.removeAll { $0.stableID == stableID }
if registry.activeStableID == stableID {
registry.entries.removeAll { GatewayStableIdentifier.matches($0.stableID, stableID) }
if GatewayStableIdentifier.matches(registry.activeStableID, stableID) {
registry.activeStableID = nil
}
return self.saveGatewayRegistry(registry)
@@ -535,25 +573,24 @@ enum GatewaySettingsStore {
static func activeGatewayEntry() -> GatewayRegistryEntry? {
let registry = self.loadGatewayRegistry()
guard let activeStableID = registry.activeStableID else { return nil }
return registry.entries.first { $0.stableID == activeStableID }
return registry.entries.first {
GatewayStableIdentifier.matches($0.stableID, activeStableID)
}
}
static func clearLegacyGatewaySelectors(stableID: String) {
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty else { return }
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
let defaults = UserDefaults.standard
for (defaultsKey, account) in [
(self.preferredGatewayStableIDDefaultsKey, self.preferredGatewayStableIDAccount),
(self.lastDiscoveredGatewayStableIDDefaultsKey, self.lastDiscoveredGatewayStableIDAccount),
] {
let defaultsValue = defaults.string(forKey: defaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if defaultsValue == stableID {
let defaultsValue = defaults.string(forKey: defaultsKey)
if GatewayStableIdentifier.matches(defaultsValue, stableID) {
defaults.removeObject(forKey: defaultsKey)
}
let keychainValue = KeychainStore.loadString(service: self.gatewayService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if keychainValue == stableID {
let keychainValue = KeychainStore.loadString(service: self.gatewayService, account: account)
if GatewayStableIdentifier.matches(keychainValue, stableID) {
_ = KeychainStore.delete(service: self.gatewayService, account: account)
}
}
@@ -579,16 +616,21 @@ enum GatewaySettingsStore {
}
private static func normalizedGatewayRegistry(_ registry: GatewayRegistry) -> GatewayRegistry {
var seen = Set<String>()
var seen = Set<GatewayStableIdentifier.Key>()
let entries = registry.entries
.compactMap(self.normalizedGatewayRegistryEntry)
.filter { seen.insert($0.stableID).inserted }
.filter { entry in
guard let key = GatewayStableIdentifier.key(entry.stableID) else { return false }
return seen.insert(key).inserted
}
.sorted { lhs, rhs in
if lhs.name != rhs.name { return lhs.name < rhs.name }
return lhs.stableID < rhs.stableID
return GatewayStableIdentifier.sortsBefore(lhs.stableID, rhs.stableID)
}
let activeStableID = registry.activeStableID.flatMap { activeID in
entries.contains(where: { $0.stableID == activeID }) ? activeID : nil
entries.first(where: {
GatewayStableIdentifier.matches($0.stableID, activeID)
})?.stableID
}
return GatewayRegistry(version: 1, activeStableID: activeStableID, entries: entries)
}
@@ -596,8 +638,7 @@ enum GatewaySettingsStore {
private static func normalizedGatewayRegistryEntry(
_ entry: GatewayRegistryEntry) -> GatewayRegistryEntry?
{
let stableID = entry.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty else { return nil }
guard let stableID = GatewayStableIdentifier.exact(entry.stableID) else { return nil }
let name = entry.name.trimmingCharacters(in: .whitespacesAndNewlines)
if entry.kind == .manual {
let host = entry.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -647,9 +688,9 @@ enum GatewaySettingsStore {
{
return stored
}
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !stableID.isEmpty else { return nil }
guard let stableID = GatewayStableIdentifier.exact(
defaults.string(forKey: self.lastGatewayStableIDDefaultsKey))
else { return nil }
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let kind = GatewayRegistryEntry.Kind(rawValue: kindRaw) ?? .manual
@@ -686,11 +727,11 @@ enum GatewaySettingsStore {
static func deleteGatewayCredentials(instanceId: String, stableID: String) {
let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
let stableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !stableID.isEmpty else { return }
guard let stableID = GatewayStableIdentifier.exact(stableID), !trimmed.isEmpty else { return }
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayCredentialBundleAccount(instanceId: trimmed, stableID: stableID))
self.deleteLegacyScopedCredentialBundleIfOwned(instanceId: trimmed, stableID: stableID)
}
static func deleteAllGatewayCredentials(instanceId: String) {
@@ -706,47 +747,71 @@ enum GatewaySettingsStore {
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return nil }
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
let value = UserDefaults.standard.string(forKey: key)?
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return nil }
let defaults = UserDefaults.standard
let key = self.gatewayDefaultsKey(prefix: self.clientIdOverrideDefaultsPrefix, stableID: stableID)
let legacyKey = self.clientIdOverrideDefaultsPrefix + stableID
let value = (defaults.string(forKey: key) ??
(self.canSafelyReadLegacyRawStorageKey(stableID) ? defaults.string(forKey: legacyKey) : nil))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
if value?.isEmpty == false {
if defaults.string(forKey: key) == nil {
defaults.set(value, forKey: key)
defaults.removeObject(forKey: legacyKey)
}
return value
}
return nil
}
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return }
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
let key = self.gatewayDefaultsKey(prefix: self.clientIdOverrideDefaultsPrefix, stableID: stableID)
let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmedClientId.isEmpty {
UserDefaults.standard.removeObject(forKey: key)
} else {
UserDefaults.standard.set(trimmedClientId, forKey: key)
}
if self.canSafelyReadLegacyRawStorageKey(stableID) {
UserDefaults.standard.removeObject(forKey: self.clientIdOverrideDefaultsPrefix + stableID)
}
}
static func loadGatewaySelectedAgentId(stableID: String) -> String? {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return nil }
let key = self.selectedAgentDefaultsPrefix + trimmedID
let value = UserDefaults.standard.string(forKey: key)?
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return nil }
let defaults = UserDefaults.standard
let key = self.gatewayDefaultsKey(prefix: self.selectedAgentDefaultsPrefix, stableID: stableID)
let legacyKey = self.selectedAgentDefaultsPrefix + stableID
let value = (defaults.string(forKey: key) ??
(self.canSafelyReadLegacyRawStorageKey(stableID) ? defaults.string(forKey: legacyKey) : nil))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
if value?.isEmpty == false {
if defaults.string(forKey: key) == nil {
defaults.set(value, forKey: key)
defaults.removeObject(forKey: legacyKey)
}
return value
}
return nil
}
static func saveGatewaySelectedAgentId(stableID: String, agentId: String?) {
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedID.isEmpty else { return }
let key = self.selectedAgentDefaultsPrefix + trimmedID
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return }
let key = self.gatewayDefaultsKey(prefix: self.selectedAgentDefaultsPrefix, stableID: stableID)
let trimmedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmedAgentId.isEmpty {
UserDefaults.standard.removeObject(forKey: key)
} else {
UserDefaults.standard.set(trimmedAgentId, forKey: key)
}
if self.canSafelyReadLegacyRawStorageKey(stableID) {
UserDefaults.standard.removeObject(forKey: self.selectedAgentDefaultsPrefix + stableID)
}
}
private static func gatewayDefaultsKey(prefix: String, stableID: String) -> String {
"\(prefix)v2.\(GatewayStableIdentifier.storageComponent(stableID)!)"
}
private static func gatewayTokenAccount(instanceId: String) -> String {
@@ -766,6 +831,13 @@ enum GatewaySettingsStore {
}
private static func gatewayCredentialBundleAccount(instanceId: String, stableID: String) -> String {
"gateway-credentials.\(instanceId).v2.\(GatewayStableIdentifier.storageComponent(stableID)!)"
}
private static func legacyScopedGatewayCredentialBundleAccount(
instanceId: String,
stableID: String) -> String
{
"gateway-credentials.\(instanceId).\(stableID)"
}
@@ -773,22 +845,40 @@ enum GatewaySettingsStore {
instanceId: String,
gatewayStableID: String) -> GatewayCredentialBundle?
{
let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty else { return nil }
guard let json = KeychainStore.loadString(
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard let stableID = GatewayStableIdentifier.exact(gatewayStableID),
!trimmedInstanceID.isEmpty
else { return nil }
let account = self.gatewayCredentialBundleAccount(
instanceId: trimmedInstanceID,
stableID: stableID)
let legacyAccount = self.legacyScopedGatewayCredentialBundleAccount(
instanceId: trimmedInstanceID,
stableID: stableID)
let canonicalJSON = KeychainStore.loadString(service: self.gatewayService, account: account)
guard let json = canonicalJSON ?? KeychainStore.loadString(
service: self.gatewayService,
account: self.gatewayCredentialBundleAccount(instanceId: instanceId, stableID: stableID)),
account: legacyAccount),
let data = json.data(using: .utf8),
let decoded = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data)
else { return nil }
let decodedStableID = decoded.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard decodedStableID == stableID else { return nil }
return GatewayCredentialBundle(
guard let decodedStableID = GatewayStableIdentifier.exact(decoded.gatewayStableID),
GatewayStableIdentifier.matches(decodedStableID, stableID)
else { return nil }
let bundle = GatewayCredentialBundle(
gatewayStableID: decodedStableID,
suppressStoredDeviceAuth: decoded.suppressStoredDeviceAuth,
token: self.normalizedCredential(decoded.token),
bootstrapToken: self.normalizedCredential(decoded.bootstrapToken),
password: self.normalizedCredential(decoded.password))
if canonicalJSON == nil,
let migratedData = try? JSONEncoder().encode(bundle),
let migratedJSON = String(data: migratedData, encoding: .utf8),
KeychainStore.saveString(migratedJSON, service: self.gatewayService, account: account)
{
_ = KeychainStore.delete(service: self.gatewayService, account: legacyAccount)
}
return bundle
}
private static func migrateGatewayCredentialBundleIfNeeded(instanceId: String) {
@@ -799,8 +889,7 @@ enum GatewaySettingsStore {
let data = json.data(using: .utf8),
let legacy = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data)
else { return }
let stableID = legacy.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty else { return }
guard let stableID = GatewayStableIdentifier.exact(legacy.gatewayStableID) else { return }
let scopedAccount = self.gatewayCredentialBundleAccount(instanceId: instanceID, stableID: stableID)
let scopedExists = KeychainStore.loadString(service: self.gatewayService, account: scopedAccount) != nil
guard scopedExists || KeychainStore.saveString(
@@ -812,6 +901,27 @@ enum GatewaySettingsStore {
self.deleteLegacyGatewayCredentials(instanceId: instanceID)
}
private static func deleteLegacyScopedCredentialBundleIfOwned(
instanceId: String,
stableID: String)
{
let account = self.legacyScopedGatewayCredentialBundleAccount(
instanceId: instanceId,
stableID: stableID)
guard let json = KeychainStore.loadString(service: self.gatewayService, account: account),
let data = json.data(using: .utf8),
let bundle = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data),
GatewayStableIdentifier.matches(bundle.gatewayStableID, stableID)
else { return }
_ = KeychainStore.delete(service: self.gatewayService, account: account)
}
private static func canSafelyReadLegacyRawStorageKey(_ stableID: String) -> Bool {
// Legacy header/default records do not embed their owner. Only ASCII keys outside
// the v2 namespace can be attributed without aliasing another owner's encoded key.
!stableID.hasPrefix("v2.") && stableID.unicodeScalars.allSatisfy(\.isASCII)
}
private static func normalizedCredential(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
@@ -867,9 +977,8 @@ enum GatewaySettingsStore {
private static func ensurePreferredGatewayStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
if let existing = GatewayStableIdentifier.exact(
defaults.string(forKey: self.preferredGatewayStableIDDefaultsKey))
{
if self.loadPreferredGatewayStableID() == nil {
self.savePreferredGatewayStableID(existing)
@@ -885,9 +994,8 @@ enum GatewaySettingsStore {
private static func ensureLastDiscoveredGatewayStableID() {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
if let existing = GatewayStableIdentifier.exact(
defaults.string(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey))
{
if self.loadLastDiscoveredGatewayStableID() == nil {
self.saveLastDiscoveredGatewayStableID(existing)
File diff suppressed because it is too large Load Diff
@@ -213,7 +213,7 @@ struct OnboardingStagedGatewaySetupSection: View {
struct OnboardingDiscoveredGatewaysSection: View {
let gateways: [GatewayDiscoveryModel.DiscoveredGateway]
let gatewayController: GatewayConnectionController
let connectingGatewayID: String?
let connectingGateway: OnboardingGatewayConnectionAttempt?
let onConnect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
let onRestartDiscovery: () -> Void
@@ -243,7 +243,7 @@ struct OnboardingDiscoveredGatewaysSection: View {
Button {
self.onConnect(gateway)
} label: {
if self.connectingGatewayID == gateway.id {
if self.connectingGateway == .gateway(gateway.id) {
ProgressView()
.progressViewStyle(.circular)
} else {
@@ -252,7 +252,7 @@ struct OnboardingDiscoveredGatewaysSection: View {
}
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil)
.disabled(self.connectingGateway != nil)
} else {
Text(availability.actionTitle)
.font(OpenClawType.subheadSemiBold)
@@ -275,7 +275,7 @@ struct OnboardingDiscoveredGatewaysSection: View {
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil)
.disabled(self.connectingGateway != nil)
} header: {
Text("Discovered Gateways")
.font(OpenClawType.footnoteSemiBold)
@@ -43,6 +43,17 @@ enum OnboardingConnectPhase {
case ready
}
/// Typed connection attempt replaces string sentinels ("manual", "retry", ...) so
/// gateway attempts compare by byte-exact stable-ID key, never trimmed strings.
enum OnboardingGatewayConnectionAttempt: Equatable {
case gateway(GatewayStableIdentifier.Key)
case manual
case retry
case retryAutomatically
case setupCode
case trustCertificate
}
struct GatewaySetupLinkStaging {
private(set) var link: GatewayConnectDeepLink?
@@ -33,7 +33,7 @@ struct OnboardingWizardView: View {
@State private var connectMessage: String?
@State private var localConnectionFailure: String?
@State private var statusLine: String = ""
@State private var connectingGatewayID: String?
@State private var connectingGateway: OnboardingGatewayConnectionAttempt?
@State private var issue: GatewayConnectionIssue = .none
@State private var didMarkCompleted = false
@State private var pairingRequestId: String?
@@ -82,7 +82,7 @@ struct OnboardingWizardView: View {
}
private var connectPhase: OnboardingConnectPhase {
if self.connectingGatewayID != nil {
if self.connectingGateway != nil {
return .connecting(detail: self.statusLine.isEmpty ? "Connecting…" : self.statusLine)
}
if let message = self.localConnectionFailure {
@@ -405,7 +405,7 @@ struct OnboardingWizardView: View {
private var welcomeStep: some View {
OnboardingWelcomeStep(
statusLine: self.statusLine,
isConnecting: self.connectingGatewayID != nil,
isConnecting: self.connectingGateway != nil,
onScanQRCode: {
self.openQRScannerFromOnboarding()
},
@@ -429,7 +429,7 @@ struct OnboardingWizardView: View {
self.selectedMode = nil
}
}),
isConnecting: self.connectingGatewayID != nil,
isConnecting: self.connectingGateway != nil,
onSelectMode: self.selectMode,
onContinue: {
self.navigate(to: .connect)
@@ -492,8 +492,8 @@ struct OnboardingWizardView: View {
private func stagedGatewaySetupSection(_ link: GatewayConnectDeepLink) -> some View {
OnboardingStagedGatewaySetupSection(
link: link,
isConnecting: self.connectingGatewayID == "manual",
isBusy: self.connectingGatewayID != nil,
isConnecting: self.connectingGateway == .manual,
isBusy: self.connectingGateway != nil,
onConnect: {
Task { await self.connectStagedGatewaySetupLink() }
},
@@ -505,7 +505,7 @@ struct OnboardingWizardView: View {
OnboardingDiscoveredGatewaysSection(
gateways: self.gatewayController.gateways,
gatewayController: self.gatewayController,
connectingGatewayID: self.connectingGatewayID,
connectingGateway: self.connectingGateway,
onConnect: { gateway in
Task { await self.connectDiscoveredGateway(gateway) }
},
@@ -579,7 +579,7 @@ struct OnboardingWizardView: View {
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil)
.disabled(self.connectingGateway != nil)
} header: {
Text("Pairing Approval")
.font(OpenClawType.footnoteSemiBold)
@@ -609,12 +609,12 @@ struct OnboardingWizardView: View {
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil)
.disabled(self.connectingGateway != nil)
Button {
Task { await self.retryLastAttempt() }
} label: {
if self.connectingGatewayID == "retry" {
if self.connectingGateway == .retry {
ProgressView()
.progressViewStyle(.circular)
} else {
@@ -623,7 +623,7 @@ struct OnboardingWizardView: View {
}
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil)
.disabled(self.connectingGateway != nil)
}
}
@@ -660,7 +660,7 @@ extension OnboardingWizardView {
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
if self.connectingGatewayID == "setup-code" {
if self.connectingGateway == .setupCode {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
@@ -693,7 +693,7 @@ extension OnboardingWizardView {
private var canApplySetupCode: Bool {
!self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& self.connectingGatewayID == nil
&& self.connectingGateway == nil
}
private func manualConnectionFieldsSection(title: LocalizedStringKey) -> some View {
@@ -814,7 +814,7 @@ extension OnboardingWizardView {
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
if self.connectingGateway == .manual {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
@@ -827,7 +827,7 @@ extension OnboardingWizardView {
}
}
.font(OpenClawType.subheadSemiBold)
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
.disabled(!self.canConnectManual || self.connectingGateway != nil)
}
private func applySetupCodeAndConnect() async {
@@ -926,7 +926,7 @@ extension OnboardingWizardView {
}
private func connectStagedGatewaySetupLink() async {
guard self.connectingGatewayID == nil else { return }
guard self.connectingGateway == nil else { return }
guard let link = self.setupLinkStaging.link else { return }
guard link.isValidEndpoint else {
let message = "Setup link has an invalid gateway endpoint."
@@ -934,9 +934,9 @@ extension OnboardingWizardView {
self.setConnectionFailure(message)
return
}
self.connectingGatewayID = "manual"
self.connectingGateway = .manual
self.localConnectionFailure = nil
defer { self.connectingGatewayID = nil }
defer { self.connectingGateway = nil }
let lease = self.gatewayController.cancelPendingConnectionAttempts()
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
defer { self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) }
@@ -1013,7 +1013,7 @@ extension OnboardingWizardView {
_ = self.setupLinkStaging.cancel()
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
self.scannerScanID = self.scannerResultHandoff.beginScan()
self.connectingGatewayID = nil
self.connectingGateway = nil
self.localConnectionFailure = nil
self.connectMessage = nil
self.issue = .none
@@ -1048,7 +1048,7 @@ extension OnboardingWizardView {
guard self.scenePhase == .active else { return }
guard self.step == .auth else { return }
guard self.issue.needsPairing else { return }
guard self.connectingGatewayID == nil else { return }
guard self.connectingGateway == nil else { return }
let now = Date()
if let last = lastPairingAutoResumeAttemptAt, now.timeIntervalSince(last) < 6 {
@@ -1158,10 +1158,10 @@ extension OnboardingWizardView {
}
private func beginSetupAttempt() -> UUID? {
guard self.connectingGatewayID == nil else { return nil }
guard self.connectingGateway == nil else { return nil }
let attemptID = UUID()
self.setupAttemptID = attemptID
self.connectingGatewayID = "setup-code"
self.connectingGateway = .setupCode
return attemptID
}
@@ -1172,7 +1172,7 @@ extension OnboardingWizardView {
private func invalidateSetupAttempt() {
self.setupAttemptID = nil
self.connectingGatewayID = nil
self.connectingGateway = nil
}
private var canConnectManual: Bool {
@@ -1283,7 +1283,9 @@ extension OnboardingWizardView {
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualHost = value
if previousStableID != self.currentManualGatewayStableID {
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
{
self.clearManualCredentialFields()
}
})
@@ -1297,7 +1299,9 @@ extension OnboardingWizardView {
let digits = value.filter(\.isNumber)
self.manualPortText = digits
self.manualPort = min(Int(digits) ?? 0, 65535)
if previousStableID != self.currentManualGatewayStableID {
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
{
self.clearManualCredentialFields()
}
})
@@ -1346,7 +1350,7 @@ extension OnboardingWizardView {
private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) {
let instanceId = GatewaySettingsStore.currentInstanceID()
if self.gatewayCredentialFieldStableID != stableID {
if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID) {
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceId,
gatewayStableID: stableID)
@@ -1367,12 +1371,12 @@ extension OnboardingWizardView {
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)
self.connectingGatewayID = gateway.id
self.connectingGateway = .gateway(gateway.id)
self.localConnectionFailure = nil
self.issue = .none
self.connectMessage = "Connecting to \(gateway.name)"
self.statusLine = "Connecting to \(gateway.name)"
defer { self.connectingGatewayID = nil }
defer { self.connectingGateway = nil }
await self.gatewayController.connect(gateway)
}
@@ -1384,7 +1388,9 @@ extension OnboardingWizardView {
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
let previousStableID = self.currentManualGatewayStableID
defer {
if previousStableID != self.currentManualGatewayStableID {
if GatewayStableIdentifier.key(previousStableID) !=
GatewayStableIdentifier.key(self.currentManualGatewayStableID)
{
self.clearManualCredentialFields()
}
}
@@ -1415,12 +1421,12 @@ extension OnboardingWizardView {
}
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return }
self.connectingGatewayID = "manual"
self.connectingGateway = .manual
self.localConnectionFailure = nil
self.issue = .none
self.connectMessage = "Connecting to \(host)"
self.statusLine = "Connecting to \(host):\(port)"
defer { self.connectingGatewayID = nil }
defer { self.connectingGateway = nil }
await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: false)
}
@@ -1429,13 +1435,19 @@ extension OnboardingWizardView {
host: host,
port: port)
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID,
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
if GatewayStableIdentifier.matches(
self.appModel.activeGatewayConnectConfig?.effectiveStableID,
stableID),
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
{
self.pendingManualAuthOverride = nil
}
let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID
let pendingOverride = self.pendingManualAuthOverride?.targetStableID == stableID
let fieldsMatchTarget = GatewayStableIdentifier.matches(
self.gatewayCredentialFieldStableID,
stableID)
let pendingOverride = GatewayStableIdentifier.matches(
self.pendingManualAuthOverride?.targetStableID,
stableID)
? self.pendingManualAuthOverride
: nil
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
@@ -1465,14 +1477,14 @@ extension OnboardingWizardView {
}
private func retryLastAttempt(silent: Bool = false) async {
self.connectingGatewayID = silent ? "retry-auto" : "retry"
self.connectingGateway = silent ? .retryAutomatically : .retry
self.localConnectionFailure = nil
// Keep current auth/pairing issue sticky while retrying to avoid Step 3 UI flip-flop.
if !silent {
self.connectMessage = "Retrying…"
self.statusLine = "Retrying last connection…"
}
defer { self.connectingGatewayID = nil }
defer { self.connectingGateway = nil }
switch GatewaySettingsStore.activeGatewayEntry()?.kind {
case .discovered:
@@ -1507,7 +1519,7 @@ extension OnboardingWizardView {
self.gatewayPassword = ""
self.gatewayCredentialFieldStableID = nil
self.pendingManualAuthOverride = nil
self.connectingGatewayID = nil
self.connectingGateway = nil
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
@@ -1516,10 +1528,10 @@ extension OnboardingWizardView {
return
}
if problem.canTrustRotatedCertificate {
self.connectingGatewayID = "trust-certificate"
self.connectingGateway = .trustCertificate
self.connectMessage = "Updating gateway certificate…"
self.statusLine = "Updating gateway certificate…"
defer { self.connectingGatewayID = nil }
defer { self.connectingGateway = nil }
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
return
}
@@ -1,9 +1,60 @@
import Foundation
@preconcurrency import UserNotifications
private struct ExecApprovalNotificationUTF8Key: Hashable {
let bytes: [UInt8]
init(_ rawValue: String) {
self.bytes = Array(rawValue.utf8)
}
var notificationComponent: String {
let hexDigits = Array("0123456789ABCDEF".utf8)
var encoded: [UInt8] = []
encoded.reserveCapacity(self.bytes.count)
for byte in self.bytes {
switch byte {
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A, 0x2D, 0x2E, 0x5F, 0x7E:
encoded.append(byte)
default:
encoded.append(0x25)
encoded.append(hexDigits[Int(byte >> 4)])
encoded.append(hexDigits[Int(byte & 0x0F)])
}
}
guard let component = String(bytes: encoded, encoding: .utf8) else {
preconditionFailure("Percent-encoded approval ID must be UTF-8")
}
return component
}
}
private enum ExecApprovalNotificationID {
static func validated(_ rawValue: String?) -> String? {
ExecApprovalIdentifier.exact(rawValue)
}
static func key(_ rawValue: String?) -> ExecApprovalNotificationUTF8Key? {
self.validated(rawValue).map(ExecApprovalNotificationUTF8Key.init)
}
}
struct ExecApprovalNotificationPrompt: Codable, Equatable, Hashable {
let approvalId: String
let gatewayDeviceId: String?
static func == (lhs: Self, rhs: Self) -> Bool {
let sameApprovalID = ExecApprovalNotificationUTF8Key(lhs.approvalId) ==
ExecApprovalNotificationUTF8Key(rhs.approvalId)
let sameGatewayID = lhs.gatewayDeviceId.map(ExecApprovalNotificationUTF8Key.init) ==
rhs.gatewayDeviceId.map(ExecApprovalNotificationUTF8Key.init)
return sameApprovalID && sameGatewayID
}
func hash(into hasher: inout Hasher) {
hasher.combine(ExecApprovalNotificationUTF8Key(self.approvalId))
hasher.combine(self.gatewayDeviceId.map(ExecApprovalNotificationUTF8Key.init))
}
}
enum ExecApprovalNotificationBridge {
@@ -12,7 +63,10 @@ enum ExecApprovalNotificationBridge {
static let categoryIdentifier = "openclaw.exec-approval"
static let reviewActionIdentifier = "openclaw.exec-approval.review"
private static let localRequestPrefix = "exec.approval."
// A disjoint top-level namespace prevents encoded v2 identifiers from aliasing
// arbitrary owner/id combinations created by the legacy dotted format.
private static let encodedRequestPrefix = "exec.approval-v2."
private static let legacyRequestPrefix = "exec.approval."
static func registerCategory(center: UNUserNotificationCenter = .current()) {
let category = UNNotificationCategory(
@@ -63,12 +117,20 @@ enum ExecApprovalNotificationBridge {
notificationCenter: NotificationCentering,
includingLegacyOwnerless: Bool = false) async
{
var pendingIdentifiers = [self.localRequestIdentifier(for: push)]
guard let requestIdentifier = self.localRequestIdentifier(for: push) else { return }
let legacyOwner = push.gatewayDeviceId ?? "legacy"
var pendingIdentifiers = [
requestIdentifier,
"\(self.legacyRequestPrefix)\(legacyOwner).\(push.approvalId)",
]
if includingLegacyOwnerless {
pendingIdentifiers.append("\(self.localRequestPrefix)\(push.approvalId)")
pendingIdentifiers.append(self.localRequestIdentifier(for: ExecApprovalNotificationPrompt(
pendingIdentifiers.append("\(self.legacyRequestPrefix)\(push.approvalId)")
if let ownerlessIdentifier = self.localRequestIdentifier(for: ExecApprovalNotificationPrompt(
approvalId: push.approvalId,
gatewayDeviceId: nil)))
gatewayDeviceId: nil))
{
pendingIdentifiers.append(ownerlessIdentifier)
}
}
var seenPendingIdentifiers = Set<String>()
pendingIdentifiers = pendingIdentifiers.filter { seenPendingIdentifiers.insert($0).inserted }
@@ -80,7 +142,8 @@ enum ExecApprovalNotificationBridge {
guard let requestedPush = self.parseRequestedPush(userInfo: snapshot.userInfo) else { return nil }
let matchesCurrentOwner = requestedPush == push
let matchesLegacyOwnerless = includingLegacyOwnerless &&
requestedPush.approvalId == push.approvalId &&
ExecApprovalNotificationUTF8Key(requestedPush.approvalId) ==
ExecApprovalNotificationUTF8Key(push.approvalId) &&
requestedPush.gatewayDeviceId == nil
guard matchesCurrentOwner || matchesLegacyOwnerless else { return nil }
return snapshot.identifier
@@ -90,33 +153,40 @@ enum ExecApprovalNotificationBridge {
static func approvalID(from userInfo: [AnyHashable: Any]) -> String? {
let raw = self.openClawPayload(userInfo: userInfo)?["approvalId"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func gatewayDeviceID(from userInfo: [AnyHashable: Any]) -> String? {
let raw = self.openClawPayload(userInfo: userInfo)?["gatewayDeviceId"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
return ExecApprovalNotificationID.validated(raw)
}
private static func parsePush(
userInfo: [AnyHashable: Any],
expectedKind: String) -> ExecApprovalNotificationPrompt?
{
guard self.payloadKind(userInfo: userInfo) == expectedKind,
guard let payload = self.openClawPayload(userInfo: userInfo),
self.payloadKind(userInfo: userInfo) == expectedKind,
let approvalId = approvalID(from: userInfo)
else {
return nil
}
let gatewayDeviceId: String?
if let rawGatewayDeviceId = payload["gatewayDeviceId"] {
guard let rawGatewayDeviceId = rawGatewayDeviceId as? String,
let exactGatewayDeviceId = GatewayStableIdentifier.exact(rawGatewayDeviceId)
else { return nil }
gatewayDeviceId = exactGatewayDeviceId
} else {
gatewayDeviceId = nil
}
return ExecApprovalNotificationPrompt(
approvalId: approvalId,
gatewayDeviceId: self.gatewayDeviceID(from: userInfo))
gatewayDeviceId: gatewayDeviceId)
}
private static func localRequestIdentifier(for push: ExecApprovalNotificationPrompt) -> String {
private static func localRequestIdentifier(for push: ExecApprovalNotificationPrompt) -> String? {
let owner = push.gatewayDeviceId ?? "legacy"
return "\(self.localRequestPrefix)\(owner).\(push.approvalId)"
guard let approvalComponent = ExecApprovalNotificationID.key(push.approvalId)?.notificationComponent else {
return nil
}
let ownerComponent = ExecApprovalNotificationUTF8Key(owner).notificationComponent
return "\(self.encodedRequestPrefix)\(ownerComponent.utf8.count):\(ownerComponent).\(approvalComponent)"
}
static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
+48 -13
View File
@@ -23,6 +23,7 @@ struct RootTabs: View {
@State private var selectedTab: AppTab = Self.initialTab
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
@State private var selectedSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute
@State private var activeSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute
@State private var selectedSettingsRouteRequestID: Int = 0
@State private var phoneControlNavigationRequest: PhoneControlNavigationRequest?
@State private var phoneChatReturn: PhoneChatReturn?
@@ -52,7 +53,7 @@ struct RootTabs: View {
@State private var didAutoOpenSettings: Bool = false
@State private var didApplyInitialChatSession: Bool = false
@State private var gatewaySetupRequest: GatewaySetupRequest?
@State private var suppressedExecApprovalPromptIDForNotificationSettings: String?
@State private var suppressedExecApprovalForNotificationSettings: NodeAppModel.ExecApprovalInboxKey?
private static var initialTab: AppTab {
Self.initialTab(arguments: ProcessInfo.processInfo.arguments)
@@ -189,7 +190,7 @@ struct RootTabs: View {
openRootDestination: { self.selectSidebarDestination($0) },
openChatFromControlDetail: { self.openChatFromControlDetail($0) })
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
.badge(self.appModel.pendingExecApprovalCount)
.tag(AppTab.control)
PhoneTabSettingsHost { openSettingsRoute in
@@ -206,6 +207,7 @@ struct RootTabs: View {
self.selectedTab == .settings &&
self.selectedSettingsRoute == .gateway,
onRouteChange: self.handleSettingsRouteChange,
onApprovalNotificationsRoute: self.suppressExecApprovalPromptForNotificationSettings,
gatewaySetupRequest: self.gatewaySetupRequest,
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
.id(self.settingsTabViewID)
@@ -524,6 +526,7 @@ struct RootTabs: View {
ownsNavigationStack: false,
navigateToRoute: pushSidebarSettingsRoute,
onRouteChange: handleSettingsRouteChange,
onApprovalNotificationsRoute: suppressExecApprovalPromptForNotificationSettings,
gatewaySetupRequest: self.gatewaySetupRequest,
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
} else {
@@ -532,6 +535,7 @@ struct RootTabs: View {
ownsNavigationStack: false,
navigateToRoute: pushSidebarSettingsRoute,
onRouteChange: handleSettingsRouteChange,
onApprovalNotificationsRoute: suppressExecApprovalPromptForNotificationSettings,
gatewaySetupRequest: self.gatewaySetupRequest,
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
}
@@ -543,6 +547,7 @@ struct RootTabs: View {
ownsNavigationStack: false,
navigateToRoute: pushSidebarSettingsRoute,
onRouteChange: handleSettingsRouteChange,
onApprovalNotificationsRoute: suppressExecApprovalPromptForNotificationSettings,
gatewaySetupRequest: self.gatewaySetupRequest,
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
}
@@ -552,6 +557,9 @@ struct RootTabs: View {
NavigationStack(path: self.$sidebarNavigationPath) {
self.sidebarDetailShell
}
.onChange(of: self.sidebarNavigationPath) { _, navigationPath in
self.handleSidebarSettingsNavigationPathChange(navigationPath)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.clipped()
}
@@ -579,9 +587,16 @@ struct RootTabs: View {
return "\(routeID):\(self.selectedSettingsRouteRequestID)"
}
private var activeExecApprovalPromptSuppressionID: String? {
guard self.selectedTab == .settings, self.selectedSettingsRoute == .notifications else { return nil }
return self.suppressedExecApprovalPromptIDForNotificationSettings
private var activeExecApprovalPromptSuppression: NodeAppModel.ExecApprovalInboxKey? {
guard self.selectedTab == .settings else { return nil }
switch self.activeSettingsRoute {
case .approvals:
return NodeAppModel.execApprovalInboxKey(self.appModel.pendingExecApprovalPrompt)
case .notifications:
return self.suppressedExecApprovalForNotificationSettings
default:
return nil
}
}
private var shouldCollapseSidebarAfterSelection: Bool {
@@ -887,9 +902,9 @@ struct RootTabs: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
}
.onChange(of: self.appModel.pendingExecApprovalPrompt?.id) { _, newValue in
if newValue != self.suppressedExecApprovalPromptIDForNotificationSettings {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
.onChange(of: NodeAppModel.execApprovalInboxKey(self.appModel.pendingExecApprovalPrompt)) { _, newValue in
if newValue != self.suppressedExecApprovalForNotificationSettings {
self.suppressedExecApprovalForNotificationSettings = nil
}
}
}
@@ -938,9 +953,9 @@ struct RootTabs: View {
.gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog(
suppressedApprovalID: self.activeExecApprovalPromptSuppressionID)
suppressedApproval: self.activeExecApprovalPromptSuppression)
.notificationPermissionGuidanceDialog(openNotifications: { approvalId in
self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId
self.suppressExecApprovalPromptForNotificationSettings(approvalId)
self.selectSettingsRoute(.notifications)
})
}
@@ -1082,10 +1097,11 @@ extension RootTabs {
}
self.sidebarNavigationPath.removeAll()
if destination.settingsRoute != .notifications {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
self.suppressedExecApprovalForNotificationSettings = nil
}
self.selectedSidebarDestination = destination
self.selectedSettingsRoute = destination.settingsRoute
self.activeSettingsRoute = destination.settingsRoute
self.selectedTab = destination.appTab
self.requestPhoneControlDestinationIfNeeded(destination)
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
@@ -1147,9 +1163,10 @@ extension RootTabs {
self.phoneChatReturn = nil
self.sidebarNavigationPath.removeAll()
if route != .notifications {
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
self.suppressedExecApprovalForNotificationSettings = nil
}
self.selectedSettingsRoute = route
self.activeSettingsRoute = route
self.selectedSettingsRouteRequestID &+= 1
self.selectedSidebarDestination = .settings
self.selectedTab = .settings
@@ -1166,7 +1183,16 @@ extension RootTabs {
self.handleSettingsRouteChange(route)
}
private func suppressExecApprovalPromptForNotificationSettings(_ approvalID: String) {
guard let approvalID = ExecApprovalIdentifier.key(approvalID),
let prompt = self.appModel.pendingExecApprovalPrompt,
ExecApprovalIdentifier.key(prompt.id) == approvalID
else { return }
self.suppressedExecApprovalForNotificationSettings = NodeAppModel.execApprovalInboxKey(prompt)
}
private func handleSettingsRouteChange(_ route: SettingsRoute?) {
self.activeSettingsRoute = route
guard route != .notifications else { return }
if route == nil {
self.selectedSettingsRoute = nil
@@ -1174,7 +1200,16 @@ extension RootTabs {
self.selectedSidebarDestination = .settings
}
}
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
self.suppressedExecApprovalForNotificationSettings = nil
}
private func handleSidebarSettingsNavigationPathChange(_ navigationPath: [SettingsRoute]) {
guard self.selectedTab == .settings else { return }
let baseRoute = self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute
let route = Self.visibleSettingsRoute(
navigationPath: navigationPath,
baseRoute: baseRoute)
self.handleSettingsRouteChange(route)
}
private func showSidebar() {
@@ -171,6 +171,13 @@ extension RootTabs {
!isSidebarVisible
}
static func visibleSettingsRoute(
navigationPath: [SettingsRoute],
baseRoute: SettingsRoute?) -> SettingsRoute?
{
navigationPath.last ?? baseRoute
}
static func shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: Bool,
layoutMode: SidebarLayoutMode) -> Bool
@@ -0,0 +1,90 @@
import Foundation
struct ExactOpaqueIdentifierKey: Hashable, Sendable {
let rawValue: String
private let bytes: [UInt8]
init(_ rawValue: String) {
self.rawValue = rawValue
self.bytes = Array(rawValue.utf8)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.bytes == rhs.bytes
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.bytes)
}
}
enum ExactOpaqueIdentifier {
static func exact(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value
}
static func key(_ value: String?) -> ExactOpaqueIdentifierKey? {
self.exact(value).map(ExactOpaqueIdentifierKey.init)
}
}
enum ExecApprovalIdentifier {
typealias Key = ExactOpaqueIdentifierKey
static func exact(_ value: String?) -> String? {
guard let value = ExactOpaqueIdentifier.exact(value), value != ".", value != ".." else {
return nil
}
return value
}
static func key(_ value: String?) -> Key? {
self.exact(value).map(Key.init)
}
static func matches(_ lhs: String, _ rhs: String) -> Bool {
guard let lhsKey = self.key(lhs), let rhsKey = self.key(rhs) else { return false }
return lhsKey == rhsKey
}
static func sortsBefore(_ lhs: String, _ rhs: String) -> Bool {
Array(lhs.utf8).lexicographicallyPrecedes(Array(rhs.utf8))
}
}
enum GatewayStableIdentifier {
typealias Key = ExactOpaqueIdentifierKey
static func exact(_ value: String?) -> String? {
ExactOpaqueIdentifier.exact(value)
}
static func key(_ value: String?) -> Key? {
ExactOpaqueIdentifier.key(value)
}
static func matches(_ lhs: String, _ rhs: String) -> Bool {
guard let lhsKey = self.key(lhs), let rhsKey = self.key(rhs) else { return false }
return lhsKey == rhsKey
}
static func matches(_ lhs: String?, _ rhs: String?) -> Bool {
guard let lhsKey = self.key(lhs), let rhsKey = self.key(rhs) else { return false }
return lhsKey == rhsKey
}
static func sortsBefore(_ lhs: String, _ rhs: String) -> Bool {
Array(lhs.utf8).lexicographicallyPrecedes(Array(rhs.utf8))
}
/// Storage attributes can apply Unicode equivalence. Encode the original UTF-8
/// bytes so canonically equivalent gateway owners remain separate persisted keys.
static func storageComponent(_ value: String) -> String? {
guard let value = self.exact(value) else { return nil }
return Data(value.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
@@ -102,10 +102,31 @@ struct WatchExecApprovalResolveEvent: Codable, Equatable {
var transport: String
}
struct WatchExecApprovalSnapshotRequestItem: Equatable {
var approvalId: String
var activeResolutionAttemptId: String?
}
struct WatchExecApprovalSnapshotRequestEvent: Equatable {
var requestId: String
var gatewayStableID: String?
var heldApprovals: [WatchExecApprovalSnapshotRequestItem]
var sentAtMs: Int64?
var transport: String
init(
requestId: String,
gatewayStableID: String? = nil,
heldApprovals: [WatchExecApprovalSnapshotRequestItem] = [],
sentAtMs: Int64?,
transport: String)
{
self.requestId = requestId
self.gatewayStableID = gatewayStableID
self.heldApprovals = heldApprovals
self.sentAtMs = sentAtMs
self.transport = transport
}
}
struct WatchAppSnapshotRequestEvent: Equatable {
@@ -18,6 +18,11 @@ enum WatchMessagingPayloadCodec {
return trimmed.isEmpty ? nil : trimmed
}
static func exactNonEmpty(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value
}
static func encodeNotificationPayload(
id: String,
params: OpenClawWatchNotifyParams,
@@ -37,7 +42,7 @@ enum WatchMessagingPayloadCodec {
if let sessionKey = nonEmpty(params.sessionKey) {
payload["sessionKey"] = sessionKey
}
if let gatewayStableID = nonEmpty(gatewayStableID) {
if let gatewayStableID = GatewayStableIdentifier.exact(gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let kind = nonEmpty(params.kind) {
@@ -81,12 +86,15 @@ enum WatchMessagingPayloadCodec {
"commandText": item.commandText,
"allowedDecisions": item.allowedDecisions.map(\.rawValue),
]
if let gatewayStableID = nonEmpty(item.gatewayStableID) {
if let gatewayStableID = GatewayStableIdentifier.exact(item.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let commandPreview = nonEmpty(item.commandPreview) {
payload["commandPreview"] = commandPreview
}
if let warningText = nonEmpty(item.warningText) {
payload["warningText"] = warningText
}
if let host = nonEmpty(item.host) {
payload["host"] = host
}
@@ -115,11 +123,8 @@ enum WatchMessagingPayloadCodec {
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
if let deliveryId = nonEmpty(message.deliveryId) {
payload["deliveryId"] = deliveryId
}
if message.resetResolvingState == true {
payload["resetResolvingState"] = true
if let resetResolutionAttemptId = exactNonEmpty(message.resetResolutionAttemptId) {
payload["resetResolutionAttemptId"] = resetResolutionAttemptId
}
return payload
}
@@ -131,7 +136,7 @@ enum WatchMessagingPayloadCodec {
"type": OpenClawWatchPayloadType.execApprovalResolved.rawValue,
"approvalId": message.approvalId,
]
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let decision = message.decision {
@@ -143,6 +148,9 @@ enum WatchMessagingPayloadCodec {
if let source = nonEmpty(message.source) {
payload["source"] = source
}
if let outcomeText = nonEmpty(message.outcomeText) {
payload["outcomeText"] = outcomeText
}
return payload
}
@@ -154,7 +162,7 @@ enum WatchMessagingPayloadCodec {
"approvalId": message.approvalId,
"reason": message.reason.rawValue,
]
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let expiredAtMs = message.expiredAtMs {
@@ -170,7 +178,7 @@ enum WatchMessagingPayloadCodec {
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
"approvals": message.approvals.map(self.encodeExecApprovalItem),
]
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let sentAtMs = message.sentAtMs {
@@ -179,6 +187,12 @@ enum WatchMessagingPayloadCodec {
if let snapshotId = nonEmpty(message.snapshotId) {
payload["snapshotId"] = snapshotId
}
if let requestId = exactNonEmpty(message.requestId) {
payload["requestId"] = requestId
}
if let requestGatewayStableID = GatewayStableIdentifier.exact(message.requestGatewayStableID) {
payload["requestGatewayStableID"] = requestGatewayStableID
}
return payload
}
@@ -203,7 +217,7 @@ enum WatchMessagingPayloadCodec {
if let agentAvatarText = nonEmpty(message.agentAvatarText) {
payload["agentAvatarText"] = agentAvatarText
}
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let sentAtMs = message.sentAtMs {
@@ -286,7 +300,7 @@ enum WatchMessagingPayloadCodec {
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String)
let note = self.nonEmpty(payload["note"] as? String)
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
@@ -309,14 +323,14 @@ enum WatchMessagingPayloadCodec {
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalResolve.rawValue else {
return nil
}
guard let approvalId = nonEmpty(payload["approvalId"] as? String),
guard let approvalId = ExecApprovalIdentifier.exact(payload["approvalId"] as? String),
let rawDecision = nonEmpty(payload["decision"] as? String),
let decision = OpenClawWatchExecApprovalDecision(rawValue: rawDecision)
else {
return nil
}
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let replyId = self.exactNonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String)
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchExecApprovalResolveEvent(
replyId: replyId,
@@ -334,10 +348,44 @@ enum WatchMessagingPayloadCodec {
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
return nil
}
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
// Version-skew compat: shipped Watch binaries request snapshots without requestId or
// heldApprovals. A missing key decodes as the shipped shape (present-but-malformed
// still rejects); remove once the minimum paired Watch app version sends heldApprovals.
let requestId = self.exactNonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
let rawHeldApprovals: [Any]
if let rawHeldApprovalsValue = payload["heldApprovals"] {
guard let heldApprovalsArray = rawHeldApprovalsValue as? [Any] else { return nil }
rawHeldApprovals = heldApprovalsArray
} else {
rawHeldApprovals = []
}
var heldApprovals: [WatchExecApprovalSnapshotRequestItem] = []
heldApprovals.reserveCapacity(rawHeldApprovals.count)
for rawItem in rawHeldApprovals {
guard let item = rawItem as? [String: Any],
let approvalId = ExecApprovalIdentifier.exact(item["approvalId"] as? String)
else {
return nil
}
let activeResolutionAttemptId: String?
if let rawAttemptId = item["activeResolutionAttemptId"] {
guard let attemptId = exactNonEmpty(rawAttemptId as? String) else {
return nil
}
activeResolutionAttemptId = attemptId
} else {
activeResolutionAttemptId = nil
}
heldApprovals.append(WatchExecApprovalSnapshotRequestItem(
approvalId: approvalId,
activeResolutionAttemptId: activeResolutionAttemptId))
}
let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String)
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchExecApprovalSnapshotRequestEvent(
requestId: requestId,
gatewayStableID: gatewayStableID,
heldApprovals: heldApprovals,
sentAtMs: sentAtMs,
transport: transport)
}
@@ -371,7 +419,7 @@ enum WatchMessagingPayloadCodec {
}
let commandId = self.nonEmpty(payload["commandId"] as? String) ?? UUID().uuidString
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String)
let text = self.nonEmpty(payload["text"] as? String)
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchAppCommandEvent(
@@ -107,10 +107,136 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
for: push,
notificationCenter: center)
#expect(center.pendingRemovedIdentifiers == [["exec.approval.gateway-a.approval-123"]])
#expect(center.pendingRemovedIdentifiers == [[
"exec.approval-v2.9:gateway-a.approval-123",
"exec.approval.gateway-a.approval-123",
]])
#expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]])
}
@Test func `approval IDs preserve gateway exact boundary semantics`() throws {
for approvalID in [
"\u{001C}approval-control",
"\u{0085}approval-next-line",
"\u{200B}approval-zero-width",
" approval",
"approval\u{FEFF}",
] {
let prompt = try #require(ExecApprovalNotificationBridge.parseRequestedPush(userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": approvalID,
],
]))
#expect(Array(prompt.approvalId.utf8) == Array(approvalID.utf8))
}
for approvalID in ["", ".", ".."] {
#expect(ExecApprovalNotificationBridge.parseRequestedPush(userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": approvalID,
],
]) == nil)
}
}
@Test func `gateway device owners preserve all nonempty exact bytes`() throws {
for exactOwner in ["\u{0085}gateway-e\u{0301}\u{0085}", " gateway", "gateway\u{FEFF}"] {
let prompt = try #require(ExecApprovalNotificationBridge.parseRequestedPush(userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-owner-exact",
"gatewayDeviceId": exactOwner,
],
]))
#expect(try Array(#require(prompt.gatewayDeviceId).utf8) == Array(exactOwner.utf8))
}
for invalidOwner in [""] {
#expect(ExecApprovalNotificationBridge.parseRequestedPush(userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-owner-invalid",
"gatewayDeviceId": invalidOwner,
],
]) == nil)
}
}
@Test @MainActor func `byte-distinct canonical approval IDs target independently`() async {
let composedID = "approval-\u{00E9}"
let decomposedID = "approval-e\u{0301}"
let composed = ExecApprovalNotificationPrompt(
approvalId: composedID,
gatewayDeviceId: "gateway-a")
let decomposed = ExecApprovalNotificationPrompt(
approvalId: decomposedID,
gatewayDeviceId: "gateway-a")
#expect(composedID == decomposedID)
#expect(composed != decomposed)
#expect(Set([composed, decomposed]).count == 2)
let center = MockNotificationCenter()
center.delivered = [
NotificationSnapshot(
identifier: "composed-request",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": composedID,
"gatewayDeviceId": "gateway-a",
],
]),
NotificationSnapshot(
identifier: "decomposed-request",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": decomposedID,
"gatewayDeviceId": "gateway-a",
],
]),
]
await ExecApprovalNotificationBridge.removeNotifications(
for: composed,
notificationCenter: center)
let encodedComposedID = "approval-%C3%A9"
let encodedDecomposedID = "approval-e%CC%81"
#expect(encodedComposedID != encodedDecomposedID)
#expect(center.pendingRemovedIdentifiers == [[
"exec.approval-v2.9:gateway-a.\(encodedComposedID)",
"exec.approval.gateway-a.\(composedID)",
]])
#expect(center.deliveredRemovedIdentifiers == [["composed-request"]])
}
@Test @MainActor func `encoded notification IDs cannot alias legacy raw IDs`() async throws {
let slashCenter = MockNotificationCenter()
let escapedCenter = MockNotificationCenter()
await ExecApprovalNotificationBridge.removeNotifications(
for: ExecApprovalNotificationPrompt(approvalId: "/", gatewayDeviceId: "gateway-a"),
notificationCenter: slashCenter)
await ExecApprovalNotificationBridge.removeNotifications(
for: ExecApprovalNotificationPrompt(approvalId: "%2F", gatewayDeviceId: "gateway-a"),
notificationCenter: escapedCenter)
let slashIdentifiers = try Set(#require(slashCenter.pendingRemovedIdentifiers.first))
let escapedIdentifiers = try Set(#require(escapedCenter.pendingRemovedIdentifiers.first))
#expect(slashIdentifiers == [
"exec.approval-v2.9:gateway-a.%2F",
"exec.approval.gateway-a./",
])
#expect(escapedIdentifiers == [
"exec.approval-v2.9:gateway-a.%252F",
"exec.approval.gateway-a.%2F",
])
#expect(slashIdentifiers.isDisjoint(with: escapedIdentifiers))
}
@Test func `legacy ownerless approval pushes remain parseable for authenticated route validation`() {
let userInfo: [AnyHashable: Any] = [
"openclaw": [
@@ -157,9 +283,10 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
includingLegacyOwnerless: true)
#expect(center.pendingRemovedIdentifiers == [[
"exec.approval-v2.9:gateway-a.approval-shared",
"exec.approval.gateway-a.approval-shared",
"exec.approval.approval-shared",
"exec.approval.legacy.approval-shared",
"exec.approval-v2.6:legacy.approval-shared",
]])
#expect(center.deliveredRemovedIdentifiers == [["legacy-ownerless"]])
}
@@ -1,5 +1,7 @@
import Foundation
import Network
import OpenClawChatUI
import os
import Testing
import UIKit
@testable import OpenClaw
@@ -121,6 +123,20 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn
gatewayStableID: "gateway-b",
lastToken: "device-token",
lastGatewayStableID: "gateway-a"))
#expect(NodeAppModel.shouldPublishDirectAPNsRegistration(
token: "device-token",
gatewayStableID: "gateway-\u{00E9}",
lastToken: "device-token",
lastGatewayStableID: "gateway-e\u{0301}"))
}
@Test func `push relay identity preserves exact opaque gateway bytes`() throws {
for deviceID in ["\u{0085}gateway-\u{00E9}", " gateway", "gateway\u{FEFF}"] {
let identity = try NodeAppModel._test_decodePushRelayGatewayIdentity(
#"{"deviceId":"\#(deviceID)","publicKey":"public-key"}"#)
#expect(Array(identity.deviceId.utf8) == Array(deviceID.utf8))
}
}
@Test @MainActor func `resolved display name sets default when missing`() {
@@ -392,6 +408,59 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn
#expect(lhs.hasSameConnectionInputs(as: rhs))
}
@Test func `gateway connect config keeps stable owner bytes exact`() {
let composedID = "gateway-\u{00E9}"
let decomposedID = "gateway-e\u{0301}"
let boundaryID = "\u{0085}gateway"
let composed = Self.makeGatewayConnectConfig(stableID: composedID)
let decomposed = Self.makeGatewayConnectConfig(stableID: decomposedID)
let boundary = Self.makeGatewayConnectConfig(stableID: boundaryID)
#expect(composedID == decomposedID)
#expect(Array(composed.effectiveStableID.utf8) == Array(composedID.utf8))
#expect(Array(boundary.effectiveStableID.utf8) == Array(boundaryID.utf8))
#expect(!composed.hasSameConnectionInputs(as: decomposed))
var composedOptions = composed.nodeOptions
composedOptions.deviceAuthGatewayID = composedID
var decomposedOptions = composed.nodeOptions
decomposedOptions.deviceAuthGatewayID = decomposedID
let composedAuthOwner = GatewayConnectConfig(
url: composed.url,
stableID: "shared-route",
tls: composed.tls,
token: composed.token,
bootstrapToken: composed.bootstrapToken,
password: composed.password,
nodeOptions: composedOptions)
let decomposedAuthOwner = GatewayConnectConfig(
url: composed.url,
stableID: "shared-route",
tls: composed.tls,
token: composed.token,
bootstrapToken: composed.bootstrapToken,
password: composed.password,
nodeOptions: decomposedOptions)
#expect(!composedAuthOwner.hasSameConnectionInputs(as: decomposedAuthOwner))
}
@Test @MainActor func `gateway reconnect options stay scoped to exact owner bytes`() {
let composedID = "gateway-\u{00E9}"
let decomposedID = "gateway-e\u{0301}"
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let config = Self.makeGatewayConnectConfig(stableID: composedID)
appModel.applyGatewayConnectConfig(config)
var fallback = config.nodeOptions
fallback.clientId = "fallback-client"
let selected = appModel._test_currentGatewayReconnectOptions(
stableID: decomposedID,
fallback: fallback)
#expect(selected.clientId == "fallback-client")
}
@Test func `setup auth override is scoped to scanned endpoint`() {
let link = GatewayConnectDeepLink(
host: "first.gateway.example.com",
@@ -1286,6 +1355,54 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn
#expect(appModel.activeGatewayConnectConfig?.nodeOptions.deviceAuthGatewayID == stableID)
}
@Test @MainActor func `discovered connect preserves exact device auth owner bytes`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let stableID = "\u{0085}gateway-e\u{0301}"
let endpoint: NWEndpoint = .service(
name: "Exact Owner",
type: "_openclaw-gw._tcp",
domain: "local.",
interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Exact Owner",
endpoint: endpoint,
stableID: stableID,
debugID: "exact-owner",
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: nil,
cliPath: nil)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let persistedOwnerBytes = OSAllocatedUnfairLock<[UInt8]?>(initialState: nil)
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("exact-owner-fingerprint") },
serviceEndpointResolver: { _ in (host: "127.0.0.1", port: 1) },
persistTLSFingerprint: { _, owner in
persistedOwnerBytes.withLock { $0 = Array(owner.utf8) }
return true
})
#expect(await controller.connectWithDiagnostics(gateway) == nil)
await controller.acceptPendingTrustPrompt()
for _ in 0..<100 where appModel.activeGatewayConnectConfig == nil {
try await Task.sleep(for: .milliseconds(10))
}
#expect(persistedOwnerBytes.withLock { $0 } == Array(stableID.utf8))
#expect(appModel.activeGatewayConnectConfig.map { Array($0.stableID.utf8) } == Array(stableID.utf8))
#expect(appModel.activeGatewayConnectConfig
.flatMap(\.nodeOptions.deviceAuthGatewayID)
.map { Array($0.utf8) } == Array(stableID.utf8))
}
@Test @MainActor func `first trust aborts when certificate pin is not durable`() async {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
@@ -570,6 +570,83 @@ import Testing
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil)
}
@Test func `TLS fingerprints preserve exact unicode gateway owners`() {
let suffix = UUID().uuidString
let composedOwner = "gateway-\u{00E9}-\(suffix)"
let decomposedOwner = "gateway-e\u{0301}-\(suffix)"
defer {
GatewayTLSStore.clearFingerprint(stableID: composedOwner)
GatewayTLSStore.clearFingerprint(stableID: decomposedOwner)
}
#expect(composedOwner == decomposedOwner)
GatewayTLSStore.saveFingerprint("composed-pin", stableID: composedOwner)
GatewayTLSStore.saveFingerprint("decomposed-pin", stableID: decomposedOwner)
#expect(GatewayTLSStore.loadFingerprint(stableID: composedOwner) == "composed-pin")
#expect(GatewayTLSStore.loadFingerprint(stableID: decomposedOwner) == "decomposed-pin")
#expect(GatewayTLSStore.clearFingerprint(stableID: decomposedOwner))
#expect(GatewayTLSStore.loadFingerprint(stableID: composedOwner) == "composed-pin")
#expect(GatewayTLSStore.loadFingerprint(stableID: decomposedOwner) == nil)
}
@Test func `ASCII legacy TLS fingerprint migrates to encoded account`() {
let stableID = "legacy-tls-owner-\(UUID().uuidString)"
let service = "ai.openclaw.tls-pinning"
defer {
GatewayTLSStore.clearFingerprint(stableID: stableID)
GenericPasswordKeychainStore.delete(service: service, account: stableID)
}
GatewayTLSStore.clearFingerprint(stableID: stableID)
#expect(GenericPasswordKeychainStore.saveString(
"legacy-pin",
service: service,
account: stableID))
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "legacy-pin")
#expect(GenericPasswordKeychainStore.loadString(service: service, account: stableID) == nil)
}
@Test func `ambiguous unicode legacy TLS fingerprint fails closed`() {
let suffix = UUID().uuidString
let composedOwner = "legacy-gateway-\u{00E9}-\(suffix)"
let decomposedOwner = "legacy-gateway-e\u{0301}-\(suffix)"
let service = "ai.openclaw.tls-pinning"
defer {
GatewayTLSStore.clearFingerprint(stableID: composedOwner)
GatewayTLSStore.clearFingerprint(stableID: decomposedOwner)
GenericPasswordKeychainStore.delete(service: service, account: composedOwner)
}
GatewayTLSStore.clearFingerprint(stableID: composedOwner)
GatewayTLSStore.clearFingerprint(stableID: decomposedOwner)
#expect(GenericPasswordKeychainStore.saveString(
"ambiguous-legacy-pin",
service: service,
account: composedOwner))
#expect(GatewayTLSStore.loadFingerprint(stableID: composedOwner) == nil)
#expect(GatewayTLSStore.loadFingerprint(stableID: decomposedOwner) == nil)
}
@Test func `legacy TLS account cannot alias encoded owner account`() {
let exactOwner = "gateway-\(UUID().uuidString)"
let component = Data(exactOwner.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let collidingLegacyOwner = "fingerprint.v2.\(component)"
defer {
GatewayTLSStore.clearFingerprint(stableID: exactOwner)
GatewayTLSStore.clearFingerprint(stableID: collidingLegacyOwner)
}
GatewayTLSStore.saveFingerprint("exact-owner-pin", stableID: exactOwner)
#expect(GatewayTLSStore.loadFingerprint(stableID: collidingLegacyOwner) == nil)
#expect(GatewayTLSStore.clearFingerprint(stableID: collidingLegacyOwner))
#expect(GatewayTLSStore.loadFingerprint(stableID: exactOwner) == "exact-owner-pin")
}
@Test func `trusted pin mismatch can be recovered by replacing stored pin`() {
let stableID = "test|\(UUID().uuidString)"
defer { GatewayTLSStore.clearFingerprint(stableID: stableID) }
+164 -1
View File
@@ -101,6 +101,16 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
}
@Suite(.serialized) struct GatewaySettingsStoreTests {
@Test func `opaque identifier validation preserves protocol-valid edge bytes`() {
#expect(ExecApprovalIdentifier.exact("") == nil)
#expect(ExecApprovalIdentifier.key(".") == nil)
#expect(ExecApprovalIdentifier.key("..") == nil)
#expect(ExecApprovalIdentifier.exact(" approval ") == " approval ")
#expect(ExecApprovalIdentifier.exact("\u{0085}approval") == "\u{0085}approval")
#expect(GatewayStableIdentifier.exact(" gateway ") == " gateway ")
#expect(GatewayStableIdentifier.exact("\u{0085}gateway") == "\u{0085}gateway")
}
@Test func `custom headers round trip per gateway`() {
let service = "\(gatewayService).custom-headers-test.\(UUID().uuidString)"
let gatewayID = "manual|headers.example.com|443|\(UUID().uuidString)"
@@ -136,6 +146,88 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
service: service) == ["X-Other": "other-value"])
}
@Test func `custom headers keep canonically equivalent owners isolated`() {
let service = "\(gatewayService).custom-headers-exact-test.\(UUID().uuidString)"
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let nextLineOwner = "\u{0085}gateway"
defer { GatewaySettingsStore.clearGatewayCustomHeaders(service: service) }
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Owner": "composed"],
gatewayStableID: composedOwner,
service: service))
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Owner": "decomposed"],
gatewayStableID: decomposedOwner,
service: service))
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Owner": "next-line"],
gatewayStableID: nextLineOwner,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: composedOwner,
service: service)["X-Owner"] == "composed")
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: decomposedOwner,
service: service)["X-Owner"] == "decomposed")
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: nextLineOwner,
service: service)["X-Owner"] == "next-line")
}
@Test func `legacy custom header account cannot alias encoded owner account`() {
let service = "\(gatewayService).custom-headers-prefix-test.\(UUID().uuidString)"
let exactOwner = "gateway-\(UUID().uuidString)"
let component = Data(exactOwner.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let collidingLegacyOwner = "v2.\(component)"
defer { GatewaySettingsStore.clearGatewayCustomHeaders(service: service) }
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["X-Owner": "exact"],
gatewayStableID: exactOwner,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: collidingLegacyOwner,
service: service).isEmpty)
#expect(GatewaySettingsStore.clearGatewayCustomHeaders(
gatewayStableID: collidingLegacyOwner,
service: service))
#expect(GatewaySettingsStore.loadGatewayCustomHeaders(
gatewayStableID: exactOwner,
service: service)["X-Owner"] == "exact")
}
@Test func `legacy gateway defaults cannot alias encoded owner keys`() {
let exactOwner = "gateway-\(UUID().uuidString)"
let component = Data(exactOwner.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let collidingLegacyOwner = "v2.\(component)"
defer {
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: exactOwner, clientId: nil)
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: collidingLegacyOwner, clientId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: exactOwner, agentId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: collidingLegacyOwner, agentId: nil)
}
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: exactOwner, clientId: "exact-client")
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: exactOwner, agentId: "exact-agent")
#expect(GatewaySettingsStore.loadGatewayClientIdOverride(stableID: collidingLegacyOwner) == nil)
#expect(GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: collidingLegacyOwner) == nil)
GatewaySettingsStore.saveGatewayClientIdOverride(stableID: collidingLegacyOwner, clientId: nil)
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: collidingLegacyOwner, agentId: nil)
#expect(GatewaySettingsStore.loadGatewayClientIdOverride(stableID: exactOwner) == "exact-client")
#expect(GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: exactOwner) == "exact-agent")
}
@Test func `custom header storage drops reserved names`() {
let service = "\(gatewayService).custom-headers-test.\(UUID().uuidString)"
let gatewayID = "manual|reserved.example.com|443|\(UUID().uuidString)"
@@ -231,6 +323,46 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
gatewayStableID: secondGatewayID) == .empty)
}
@Test func `credentials preserve exact unicode gateway owners`() {
let instanceID = "credential-exact-owner-\(UUID().uuidString)"
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let nextLineOwner = "\u{0085}gateway"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
for (owner, token) in [
(composedOwner, "composed-token"),
(decomposedOwner, "decomposed-token"),
(nextLineOwner, "next-line-token"),
] {
#expect(GatewaySettingsStore.saveGatewayCredentials(
token: token,
bootstrapToken: nil,
password: nil,
gatewayStableID: owner,
suppressStoredDeviceAuth: false,
instanceId: instanceID))
}
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: composedOwner).token == "composed-token")
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: decomposedOwner).token == "decomposed-token")
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: nextLineOwner).token == "next-line-token")
GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID, stableID: decomposedOwner)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: composedOwner).token == "composed-token")
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: decomposedOwner) == .empty)
}
@Test func `shared tls certificate does not alias distinct routes`() {
let instanceID = "tls-owner-\(UUID().uuidString)"
let discoveredID = "bonjour|_openclaw._tcp|local|gateway-\(UUID().uuidString)"
@@ -555,6 +687,36 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
}
}
@Test func `registry preserves byte-distinct unicode gateway owners`() {
withLastGatewaySnapshot {
applyKeychain([gatewayRegistryKeychainEntry: nil, lastGatewayKeychainEntry: nil])
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let nextLineOwner = "\u{0085}gateway"
for owner in [composedOwner, decomposedOwner, nextLineOwner] {
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: owner,
kind: .discovered,
name: "Gateway",
host: nil,
port: nil,
useTLS: true,
lastConnectedAtMs: nil)))
}
let registry = GatewaySettingsStore.loadGatewayRegistry()
#expect(Set(registry.entries.compactMap { GatewayStableIdentifier.key($0.stableID) }).count == 3)
#expect(GatewaySettingsStore.setActiveGateway(stableID: decomposedOwner))
#expect(GatewaySettingsStore.activeGatewayEntry().map { Array($0.stableID.utf8) } ==
Array(decomposedOwner.utf8))
#expect(GatewaySettingsStore.removeGatewayRegistryEntry(stableID: composedOwner))
let remaining = GatewaySettingsStore.loadGatewayRegistry().entries
#expect(remaining.contains(where: {
GatewayStableIdentifier.matches($0.stableID, decomposedOwner)
}))
}
}
@Test func `legacy manual last connection migrates once into active registry`() {
withLastGatewaySnapshot {
applyKeychain([
@@ -634,6 +796,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
@Test func `legacy unscoped credential bundle migrates to its gateway account`() {
withBootstrapSnapshots {
let instanceID = "legacy-bundle-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteAllGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|credentials.example.com|443"
let legacyAccount = "gateway-credentials.\(instanceID)"
let scopedAccount = "\(legacyAccount).\(gatewayID)"
@@ -663,7 +826,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
#expect(credentials.password == "legacy-password")
#expect(credentials.suppressStoredDeviceAuth)
#expect(KeychainStore.loadString(service: gatewayService, account: legacyAccount) == nil)
#expect(KeychainStore.loadString(service: gatewayService, account: scopedAccount) != nil)
#expect(KeychainStore.loadString(service: gatewayService, account: scopedAccount) == nil)
}
}
File diff suppressed because it is too large Load Diff
@@ -163,6 +163,9 @@ struct OpenClawTypographyTests {
let settingsSupport = try String(
contentsOf: Self.sourceURL("Design/SettingsProTabSupport.swift"),
encoding: .utf8)
let approvalDialog = try String(
contentsOf: Self.sourceURL("Gateway/ExecApprovalPromptDialog.swift"),
encoding: .utf8)
let privacyAccess = try String(
contentsOf: Self.sourceURL("Settings/PrivacyAccessSectionView.swift"),
encoding: .utf8)
@@ -254,6 +257,15 @@ struct OpenClawTypographyTests {
#expect(onboardingSecureOption.contains(".font(OpenClawType.captionSemiBold)"))
#expect(settingsSections.contains(".font(OpenClawType.body)"))
#expect(settingsSections.contains("Text(warningText)"))
#expect(settingsSections.contains(".font(OpenClawType.caption)"))
#expect(approvalDialog.contains("Text(warningText)"))
#expect(approvalDialog.contains(".font(OpenClawType.footnote)"))
#expect(approvalDialog.contains("ScrollView {"))
#expect(approvalDialog.contains("self.actionFooter"))
#expect(approvalDialog.contains("exec-approval-review-scroll"))
#expect(approvalDialog.contains("exec-approval-actions"))
#expect(approvalDialog.contains("ViewThatFits(in: .horizontal)"))
#expect(settingsSections.contains("self.settingsToggle(\"Show Talk Control\", isOn: self.$talkButtonEnabled)"))
#expect(settingsSections.contains("OpenClawToggleIndicator(isOn: isOn.wrappedValue)"))
#expect(settingsSections.contains("TextField(\"Default Share Instruction\""))
@@ -448,6 +448,21 @@ struct RootTabsPresentationTests {
#expect(!embedded.ownsNavigationStack)
}
@Test func `settings sidebar route follows navigation top then direct base`() {
#expect(RootTabs.visibleSettingsRoute(
navigationPath: [.approvals],
baseRoute: nil) == .approvals)
#expect(RootTabs.visibleSettingsRoute(
navigationPath: [.approvals, .notifications],
baseRoute: .gateway) == .notifications)
#expect(RootTabs.visibleSettingsRoute(
navigationPath: [],
baseRoute: .approvals) == .approvals)
#expect(RootTabs.visibleSettingsRoute(
navigationPath: [],
baseRoute: nil) == nil)
}
@Test func `i pad portrait uses hidden drawer sidebar`() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1024, height: 1366))
@@ -81,6 +81,9 @@ extension RootTabsSourceGuardTests {
// Gateway problems surface once, as the root toast; the settings page must not
// embed a second copy of the banner.
#expect(!sectionsSource.contains("GatewayProblemBanner("))
// Sections compare gateway owners byte-exact, not with raw string equality.
#expect(!sectionsSource.contains("entry.stableID == self.gatewayRegistry.activeStableID"))
#expect(sectionsSource.components(separatedBy: "GatewayStableIdentifier.matches(").count >= 4)
#expect(rootSource.contains("GatewayProblemBanner("))
#expect(rootSource.contains(".gesture(self.gatewayToastSwipeGesture)"))
// Operator auth/pairing problems can coexist with a connected node, so the
@@ -209,7 +212,10 @@ extension RootTabsSourceGuardTests {
#expect(!stagedSetupConnect.contains("self.appModel.disconnectGateway()"))
#expect(stagedSetupConnect.contains(
"self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)"))
#expect(stagedSetupConnect.contains("guard self.connectingGatewayID == nil else { return }"))
#expect(stagedSetupConnect.contains("guard self.connectingGateway == nil else { return }"))
#expect(onboardingSource.contains("case gateway(GatewayStableIdentifier.Key)"))
#expect(onboardingSource.contains("self.connectingGateway = .gateway(gateway.id)"))
#expect(!onboardingSource.contains("connectingGatewayID"))
#expect(stagedSetupConnect.contains("self.setConnectionFailure(message)"))
#expect(connectionFailure.contains("self.localConnectionFailure = message"))
#expect(!connectionFailure.contains("self.connectMessage = message"))
@@ -247,6 +253,17 @@ extension RootTabsSourceGuardTests {
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
#expect(actionsSource.contains(
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
// Gateway stable IDs compare byte-exact via GatewayStableIdentifier, never
// via trimmed/string equality; a regressed comparison silently reuses
// credentials across distinct gateway owners.
#expect(onboardingSource.contains(
"if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID)"))
#expect(actionsSource.contains(
"if !GatewayStableIdentifier.matches(self.gatewayCredentialFieldStableID, stableID)"))
#expect(!onboardingSource.contains("gatewayCredentialFieldStableID == stableID"))
#expect(!actionsSource.contains("gatewayCredentialFieldStableID == stableID"))
#expect(onboardingSource.contains("GatewayStableIdentifier.key(previousStableID) !="))
#expect(actionsSource.contains("GatewayStableIdentifier.key(previousStableID) !="))
}
static func assertGatewayReconnectGuards() throws {
@@ -294,5 +311,6 @@ extension RootTabsSourceGuardTests {
#expect(backgroundReconnect.contains("expectedGeneration: generation"))
#expect(modelSource.contains("expectedGeneration: UInt64)"))
#expect(!modelSource.contains("expectedGeneration: UInt64?"))
#expect(modelSource.contains("GatewayStableIdentifier.exact(self.connectedGatewayID)"))
}
}
+301 -12
View File
@@ -725,6 +725,28 @@ extension RootTabsSourceGuardTests {
+ #"[\s\S]*?headerLeadingAction: self\.sidebarHeaderLeadingAction,"#
+ #"[\s\S]*?ownsNavigationStack: false"#
+ #"[\s\S]*?onRouteChange: handleSettingsRouteChange"#)
let approvalSuppression = try Self.extract(
rootSource,
from: "private var activeExecApprovalPromptSuppression: NodeAppModel.ExecApprovalInboxKey?",
to: "private var shouldCollapseSidebarAfterSelection: Bool")
let sidebarNavigationShell = try Self.extract(
rootSource,
from: "private var sidebarDetailNavigationShell: some View",
to: "private var usesSidebarTabs: Bool")
let settingsRoutePropagation = try Self.extract(
rootSource,
from: "private func handleSettingsRouteChange(_ route: SettingsRoute?)",
to: "private func showSidebar()")
let approvalNotificationsRoute = try Self.extract(
settingsTabSource,
from: "func openNotificationsRouteFromApprovals()",
to: "private func applyInitialRouteIfNeeded()")
let suppressionCapture = try #require(
approvalNotificationsRoute.range(of: "self.onApprovalNotificationsRoute?(approvalID)"))
let externalNavigation = try #require(
approvalNotificationsRoute.range(of: "navigateToRoute(.notifications)"))
let ownedNavigation = try #require(
approvalNotificationsRoute.range(of: "self.navigationPath.append(.notifications)"))
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
#expect(rootSource.matches(of: /openVoiceSettings: \{ openSettingsRoute\(\.voice\) \}/).count == 1)
@@ -761,21 +783,39 @@ extension RootTabsSourceGuardTests {
#expect(rootSource.matches(of: /SettingsProTab\(\s*initialRoute: self\.selectedSettingsRoute,/).count == 1)
#expect(rootSource.contains(".id(self.settingsTabViewID)"))
#expect(rootSource.contains("@State private var selectedSettingsRouteRequestID: Int = 0"))
#expect(rootSource.contains("@State private var activeSettingsRoute: SettingsRoute?"))
#expect(rootSource.contains("self.selectedSettingsRouteRequestID &+= 1"))
#expect(rootSource.contains("@State private var suppressedExecApprovalPromptIDForNotificationSettings"))
#expect(rootSource.contains("private var activeExecApprovalPromptSuppressionID: String?"))
#expect(rootSource.contains("suppressedApprovalID: self.activeExecApprovalPromptSuppressionID"))
#expect(rootSource.contains("@State private var suppressedExecApprovalForNotificationSettings"))
#expect(rootSource.contains(
"private var activeExecApprovalPromptSuppression: NodeAppModel.ExecApprovalInboxKey?"))
#expect(rootSource.contains("suppressedApproval: self.activeExecApprovalPromptSuppression"))
#expect(approvalSuppression.contains("case .approvals:"))
#expect(approvalSuppression.contains("switch self.activeSettingsRoute"))
#expect(approvalSuppression.contains(
"NodeAppModel.execApprovalInboxKey(self.appModel.pendingExecApprovalPrompt)"))
#expect(sidebarNavigationShell.contains(".onChange(of: self.sidebarNavigationPath)"))
#expect(sidebarNavigationShell.contains("self.handleSidebarSettingsNavigationPathChange(navigationPath)"))
#expect(settingsRoutePropagation.contains("self.activeSettingsRoute = route"))
#expect(settingsRoutePropagation.contains("navigationPath: navigationPath"))
#expect(settingsRoutePropagation.contains("baseRoute: baseRoute"))
#expect(rootSource.contains("if destination.settingsRoute != .notifications"))
#expect(rootSource.contains("if route != .notifications"))
#expect(rootSource.contains("if route == nil"))
#expect(rootSource.contains("self.selectedSettingsRoute = nil"))
#expect(rootSource.contains("self.selectedSidebarDestination = .settings"))
#expect(rootSource.contains("self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId"))
#expect(rootSource.contains(
"self.suppressedExecApprovalForNotificationSettings = NodeAppModel.execApprovalInboxKey(prompt)"))
#expect(rootSource.contains(
"onApprovalNotificationsRoute: self.suppressExecApprovalPromptForNotificationSettings"))
#expect(rootSource.contains("private func suppressExecApprovalPromptForNotificationSettings("))
#expect(rootSource.contains("onRouteChange: handleSettingsRouteChange"))
#expect(rootSource.contains("navigateToRoute: pushSidebarSettingsRoute"))
#expect(rootSource.contains("private func pushSidebarSettingsRoute(_ route: SettingsRoute)"))
#expect(rootSource.contains("self.sidebarNavigationPath.append(route)"))
#expect(settingsTabSource.contains("let navigateToRoute: ((SettingsRoute) -> Void)?"))
#expect(settingsTabSource.contains("let onApprovalNotificationsRoute: ((String) -> Void)?"))
#expect(suppressionCapture.lowerBound < externalNavigation.lowerBound)
#expect(suppressionCapture.lowerBound < ownedNavigation.lowerBound)
#expect(settingsTabSource.contains("navigateToRoute(.notifications)"))
// Cross-route settings shortcuts push so Back returns to the origin
// screen; replacing the path resets Back to the Settings root.
@@ -936,7 +976,8 @@ extension RootTabsSourceGuardTests {
to: "private func connectManual")
#expect(modeDefaults.contains("let previousStableID = self.currentManualGatewayStableID"))
#expect(modeDefaults.contains("previousStableID != self.currentManualGatewayStableID"))
#expect(modeDefaults.contains("GatewayStableIdentifier.key(previousStableID) !="))
#expect(modeDefaults.contains("GatewayStableIdentifier.key(self.currentManualGatewayStableID)"))
#expect(modeDefaults.contains("self.clearManualCredentialFields()"))
}
@@ -957,10 +998,11 @@ extension RootTabsSourceGuardTests {
to: "func markAppSnapshotRequestStarted()")
#expect(appSnapshotConsume.lowerBound < approvalSnapshotConsume.lowerBound)
#expect(consumeAppSnapshot.contains("if hasExistingAppSnapshot, previousGatewayID == nextGatewayID"))
let matchingOwnerGuard = "if hasExistingAppSnapshot, Self.gatewayIDsMatch(previousGatewayID, nextGatewayID)"
#expect(consumeAppSnapshot.contains(matchingOwnerGuard))
let ownerMatchedMerge = try Self.extract(
consumeAppSnapshot,
from: "if hasExistingAppSnapshot, previousGatewayID == nextGatewayID",
from: matchingOwnerGuard,
to: "self.appSnapshot = merged")
#expect(ownerMatchedMerge.contains("merged.chatItems = self.appSnapshot?.chatItems"))
#expect(ownerMatchedMerge.contains("merged.chatStatusText = self.appSnapshot?.chatStatusText"))
@@ -993,7 +1035,8 @@ extension RootTabsSourceGuardTests {
#expect(consumeMessage.contains("self.routeGatewayPayload(.notification"))
#expect(consumeAppSnapshot.contains("self.clearMessagePrompt()"))
#expect(consumeAppSnapshot.contains("if !hasExistingAppSnapshot || previousGatewayID != nextGatewayID"))
#expect(consumeAppSnapshot.contains(
"if !hasExistingAppSnapshot || !Self.gatewayIDsMatch(previousGatewayID, nextGatewayID)"))
#expect(source.contains("private var deferredGatewayPayloads: [DeferredGatewayPayload]"))
#expect(routeGatewayPayload.contains("guard let activeSnapshot = appSnapshot else { return true }"))
#expect(acceptsGatewayOwner.contains("guard let activeSnapshot = appSnapshot else { return true }"))
@@ -1003,7 +1046,7 @@ extension RootTabsSourceGuardTests {
#expect(replay.contains("WatchDeferredPayloadOrdering.isNewerThanSnapshot"))
#expect(replay.contains("WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot"))
#expect(replay.contains("case let .notification(message, transport):"))
#expect(replay.contains("approvalSnapshotGatewayID == activeGatewayID"))
#expect(replay.contains("approvalSnapshotGatewayID,\n activeGatewayID"))
#expect(replay.contains("payload.isFullyRepresentedByExecApprovalSnapshot"))
#expect(replay.contains("let approval = payload.approvalPrompt"))
#expect(source.contains("if hasSameSnapshotOwner"))
@@ -1023,12 +1066,86 @@ extension RootTabsSourceGuardTests {
to: "func markAppSnapshotRequestStarted()")
#expect(identifier.contains("gatewayStableID.utf8.count"))
#expect(identifier.contains("gatewayStableID)\\(approvalID)"))
#expect(identifier.contains("approvalKey.notificationComponent"))
#expect(routeChange.contains("removeExecApprovalNotifications(approvals: invalidatedApprovals)"))
#expect(!source.contains("identifier: \"watch.execApproval.\\(message.approval.id)\""))
#expect(source.contains("let ownerlessApprovals = state.execApprovals.filter"))
#expect(source.contains("let ownerlessApprovals = validApprovals.filter"))
#expect(source.contains("self.lastExecApprovalSnapshotID = nil"))
#expect(source.contains("\"watch.execApproval.\\(approvalID)\""))
#expect(source.contains("approvalKey.notificationComponent"))
}
@Test func `watch terminal approvals cannot be resurrected by delayed deliveries`() throws {
let source = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8)
let promptConsume = try Self.extract(
source,
from: "func consume(\n execApprovalPrompt",
to: "func consume(\n execApprovalSnapshot")
let snapshotConsume = try Self.extract(
source,
from: "func consume(\n execApprovalSnapshot",
to: "func consume(appSnapshot")
let terminalConsumes = try Self.extract(
source,
from: "func consume(execApprovalResolved",
to: "func selectExecApproval")
let terminalHelpers = try Self.extract(
source,
from: "private static func execApprovalOwnerKey(",
to: "private func pruneExpiredExecApprovals")
let restore = try Self.extract(
source,
from: "private func restorePersistedState()",
to: "private func persistState()")
let merge = try Self.extract(
source,
from: "private func mergedExecApprovalRecord(",
to: "private func removeExecApproval")
let upsert = try Self.extract(
source,
from: "private func upsertExecApproval(",
to: "private func mergedExecApprovalRecord(")
#expect(promptConsume.contains("!self.isExecApprovalTerminal("))
#expect(promptConsume.contains("expiresAtMs <= nowMs"))
#expect(promptConsume.contains("self.isExecApprovalPromptSupersededBySnapshot(message)"))
let promptPrune = try #require(promptConsume.range(of: "self.pruneExpiredExecApprovals(nowMs: nowMs)"))
let promptExpiry = try #require(promptConsume.range(of: "expiresAtMs <= nowMs"))
#expect(promptPrune.lowerBound < promptExpiry.lowerBound)
#expect(snapshotConsume.contains("!self.isExecApprovalTerminal("))
#expect(snapshotConsume.contains("Self.snapshotCanReplace("))
#expect(snapshotConsume.contains("recordKey.gatewayID == WatchGatewayID.key(snapshotGatewayID)"))
#expect(snapshotConsume.contains(
"Self.gatewayIDsMatch(approval.gatewayStableID, snapshotGatewayID)"))
#expect(snapshotConsume.contains("Approval resolved elsewhere"))
#expect(snapshotConsume.contains("authoritativeOutcome: false"))
#expect(terminalConsumes.components(separatedBy: "self.recordExecApprovalTerminal(").count == 3)
#expect(terminalConsumes.contains("func terminalExecApprovalOutcomeText("))
#expect(terminalHelpers.contains("WatchApprovalID.key(tombstone.approvalId) == key.approvalID"))
#expect(terminalHelpers.contains("WatchGatewayID.key(tombstone.gatewayStableID) == key.gatewayID"))
#expect(terminalHelpers.contains("maxExecApprovalTerminalOutcomeCharacters"))
#expect(terminalHelpers.contains("maxExecApprovalTerminalTombstones"))
#expect(terminalHelpers.contains("upgraded.recordedAt = Date()"))
#expect(source.contains("execApprovalTerminalTombstoneLifetime: TimeInterval"))
#expect(source.contains("execApprovalTerminalTombstones: [ExecApprovalTerminalTombstone]?"))
// WatchExecApprovalRecord's transport timestamp lives in WatchInboxMessages.swift
// since the watch message/model types were split out of WatchInboxStore.swift.
let messagesSource = try String(
contentsOf: Self.watchInboxMessagesSourceURL(),
encoding: .utf8)
#expect(messagesSource.contains("var sourceSentAtMs: Int64?"))
#expect(source.contains("var outcomeIsAuthoritative: Bool?"))
#expect(source.contains("guard let recordSentAtMs = record.sourceSentAtMs else { return true }"))
#expect(restore.contains("state.execApprovalTerminalTombstones ?? []"))
#expect(restore.contains("self.isExecApprovalTerminal("))
// An explicit pending readback can clear an uncertain accepted or queued send.
#expect(upsert.contains("guard Self.snapshotCanReplace("))
#expect(upsert.contains("WatchOpaqueUTF8Key(resetResolutionAttemptID)"))
#expect(upsert.contains("WatchOpaqueUTF8Key(activeResolutionAttemptID)"))
#expect(merge.contains("let isResolving = resetResolvingState ? false"))
#expect(merge.contains("let pendingDecision = resetResolvingState ? nil"))
#expect(merge.contains("let activeResolutionAttemptID = resetResolvingState ? nil"))
#expect(!source.contains("appliedResetDeliveryIDs"))
}
@Test func `setup route probes yield to newer manual actions`() throws {
@@ -1181,6 +1298,164 @@ extension RootTabsSourceGuardTests {
}
extension RootTabsSourceGuardTests {
@Test func `approval fetch revalidates captured operator route before interpreting response`() throws {
let source = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let routeAdmission = try Self.extract(
source,
from: "private func isCurrentGatewaySessionRoute(",
to: "private func ackPendingForegroundNodeAction(")
let unified = try Self.extract(
source,
from: "private func fetchExecApprovalPrompt(",
to: "private static func decodeUnifiedExecApprovalGet(")
let legacy = try Self.extract(
source,
from: "private func fetchLegacyExecApprovalPrompt(",
to: "func dismissPendingExecApprovalPrompt()")
let unifiedSuccess = try Self.extract(
unified,
from: "let response = try await operatorGateway.request(",
to: "} catch is CancellationError")
let legacySuccess = try Self.extract(
legacy,
from: "let response = try await self.operatorGateway.request(",
to: "} catch is CancellationError")
let unifiedCatch = try #require(unified.range(of: "} catch {"))
let legacyCatch = try #require(legacy.range(of: "} catch {"))
let unifiedError = String(unified[unifiedCatch.lowerBound...])
let legacyError = String(legacy[legacyCatch.lowerBound...])
let unifiedAdmission = try #require(unifiedSuccess.range(of: "isCurrentGatewaySessionRoute"))
let unifiedDecode = try #require(unifiedSuccess.range(of: "decodeUnifiedExecApprovalGet"))
let unifiedErrorAdmission = try #require(unifiedError.range(of: "isCurrentGatewaySessionRoute"))
let unifiedStale = try #require(unifiedError.range(of: "isApprovalNotificationStaleError"))
let legacyAdmission = try #require(legacySuccess.range(of: "isCurrentGatewaySessionRoute"))
let legacyDecode = try #require(legacySuccess.range(of: "JSONDecoder().decode"))
let legacyErrorAdmission = try #require(legacyError.range(of: "isCurrentGatewaySessionRoute"))
let legacyStale = try #require(legacyError.range(of: "isApprovalNotificationStaleError"))
#expect(routeAdmission.contains("await session.currentRoute() == context.route"))
#expect(unifiedSuccess.contains("guard await self.isCurrentGatewaySessionRoute("))
#expect(unifiedSuccess.contains("session: self.operatorGateway"))
#expect(unifiedAdmission.lowerBound < unifiedDecode.lowerBound)
#expect(unifiedErrorAdmission.lowerBound < unifiedStale.lowerBound)
#expect(legacySuccess.contains("guard await self.isCurrentGatewaySessionRoute("))
#expect(legacySuccess.contains("session: self.operatorGateway"))
#expect(legacyAdmission.lowerBound < legacyDecode.lowerBound)
#expect(legacyErrorAdmission.lowerBound < legacyStale.lowerBound)
}
@Test func `approval resolve revalidates captured operator route before classifying replies`() throws {
let source = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let unified = try Self.extract(
source,
from: "private func resolveExecApprovalNotificationDecision(",
to: "private func execApprovalRPCFamily(")
let legacy = try Self.extract(
source,
from: "private func resolveLegacyExecApproval(",
to: "private func reconcileUnknownExecApprovalResolution(")
let unifiedSuccess = try Self.extract(
unified,
from: "let response = try await self.operatorGateway.request(",
to: "} catch {")
let legacySuccess = try Self.extract(
legacy,
from: "let response = try await self.operatorGateway.request(",
to: "} catch {")
let unifiedCatch = try #require(unified.range(of: "} catch {"))
let legacyCatch = try #require(legacy.range(of: "} catch {"))
let unifiedError = String(unified[unifiedCatch.lowerBound...])
let legacyError = String(legacy[legacyCatch.lowerBound...])
let unifiedAdmission = try #require(unifiedSuccess.range(of: "isCurrentGatewaySessionRoute"))
let unifiedSettled = try #require(unifiedSuccess.range(of: "markExecApprovalResolutionWriteSettled"))
let unifiedDecode = try #require(unifiedSuccess.range(of: "JSONDecoder().decode"))
let unifiedErrorAdmission = try #require(unifiedError.range(of: "isCurrentGatewaySessionRoute"))
let unifiedErrorReconcile = try #require(unifiedError.range(of: "reconcileUnknownExecApprovalResolution"))
let legacyAdmission = try #require(legacySuccess.range(of: "isCurrentGatewaySessionRoute"))
let legacySettled = try #require(legacySuccess.range(of: "markExecApprovalResolutionWriteSettled"))
let legacyDecode = try #require(legacySuccess.range(of: "JSONDecoder().decode"))
let legacyErrorAdmission = try #require(legacyError.range(of: "isCurrentGatewaySessionRoute"))
let legacyAlreadyResolved = try #require(legacyError.range(of: "isApprovalAlreadyResolvedError"))
#expect(unified.contains("ifCurrentRoute: context.route"))
#expect(unified.contains("distinguishPreDispatchRouteChange: true"))
#expect(unifiedSuccess.contains("return .uncertain("))
#expect(unifiedError.contains("case .routeChangedBeforeDispatch"))
#expect(unifiedError.contains("return .uncertain("))
#expect(unifiedAdmission.lowerBound < unifiedSettled.lowerBound)
#expect(unifiedSettled.lowerBound < unifiedDecode.lowerBound)
#expect(unifiedErrorAdmission.lowerBound < unifiedErrorReconcile.lowerBound)
#expect(legacy.contains("ifCurrentRoute: context.route"))
#expect(legacy.contains("distinguishPreDispatchRouteChange: true"))
#expect(legacySuccess.contains("return .uncertain("))
#expect(legacyError.contains("case .routeChangedBeforeDispatch"))
#expect(legacyError.contains("return .uncertain("))
#expect(legacyAdmission.lowerBound < legacySettled.lowerBound)
#expect(legacySettled.lowerBound < legacyDecode.lowerBound)
#expect(legacyErrorAdmission.lowerBound < legacyAlreadyResolved.lowerBound)
}
@Test func `phone approval write lease survives pending reconciliation`() throws {
let source = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let resolution = try Self.extract(
source,
from: "func resolvePendingExecApprovalPrompt(decision: String) async",
to: "private func resolveExecApprovalNotificationDecision(")
let presentation = try Self.extract(
source,
from: "private func presentFetchedExecApprovalPrompt(",
to: "private static func makeExecApprovalPrompt(")
let begin = try #require(resolution.range(of: "beginExecApprovalResolutionAttempt"))
let request = try #require(resolution.range(of: "await resolveExecApprovalNotificationDecision"))
#expect(begin.lowerBound < request.lowerBound)
#expect(resolution.contains("defer { self.finishExecApprovalResolutionAttempt(resolutionAttempt) }"))
#expect(resolution.contains("guard self.isActiveExecApprovalResolutionAttempt(resolutionAttempt)"))
#expect(presentation.contains("let preserveActiveResolution"))
// Re-presenting while the write fence is held must render as resolving.
#expect(presentation.contains("} else if preserveActiveResolution {"))
#expect(presentation.contains("self.pendingExecApprovalPromptResolving = true"))
}
@Test func `uncertain approval remains dismissible on modal and settings surfaces`() throws {
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let dialogSource = try String(contentsOf: Self.execApprovalPromptDialogSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let approvals = try Self.extract(
settingsSource,
from: "var approvalsReviewCard: some View",
to: "private var approvalOutcomeColor: Color")
#expect(modelSource.contains("var pendingExecApprovalPromptCanDismiss: Bool"))
#expect(modelSource.contains(
"!self.pendingExecApprovalPromptResolving || self.pendingExecApprovalPromptErrorText != nil"))
#expect(dialogSource.contains("canDismiss: self.appModel.pendingExecApprovalPromptCanDismiss"))
#expect(dialogSource.contains(".disabled(!self.canDismiss)"))
#expect(approvals.contains("self.appModel.pendingExecApprovalPromptResolving,"))
#expect(approvals.contains("self.appModel.pendingExecApprovalPromptCanDismiss"))
#expect(approvals.contains("self.appModel.dismissPendingExecApprovalPrompt()"))
}
@Test func `approval inbox stays reopenable and modal isolates accessibility`() throws {
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let dialogSource = try String(contentsOf: Self.execApprovalPromptDialogSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
#expect(rootSource.contains(".badge(self.appModel.pendingExecApprovalCount)"))
#expect(modelSource.contains("var pendingExecApprovalInboxItems: [ExecApprovalInboxItem]"))
#expect(modelSource.contains("self.dismissedExecApprovalPresentationKeys.insert(inboxKey)"))
#expect(modelSource.contains("func presentPendingExecApprovalFromInbox("))
#expect(settingsSource.contains("ForEach(self.appModel.pendingExecApprovalInboxItems)"))
#expect(settingsSource.contains("self.appModel.presentPendingExecApprovalFromInbox(item.id)"))
#expect(settingsSource.contains("Label(\"Allow Once\""))
#expect(settingsSource.contains("Label(\"Allow Always\""))
#expect(dialogSource.contains(".accessibilityHidden(prompt != nil)"))
#expect(dialogSource.contains(".accessibilityAddTraits(.isModal)"))
#expect(dialogSource.contains(".accessibilityFocused(self.$approvalCardFocused)"))
}
static func rootTabsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -1195,6 +1470,13 @@ extension RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Model/NodeAppModel.swift")
}
private static func execApprovalPromptDialogSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Gateway/ExecApprovalPromptDialog.swift")
}
private static func iOSGatewayChatTransportSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -1455,6 +1737,13 @@ extension RootTabsSourceGuardTests {
.appendingPathComponent("WatchApp/Sources/WatchInboxStore.swift")
}
private static func watchInboxMessagesSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("WatchApp/Sources/WatchInboxMessages.swift")
}
private static func channelsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -371,6 +371,37 @@ struct SwiftUIRenderSmokeTests {
#expect(window.rootViewController?.presentedViewController is UIAlertController)
}
@Test @MainActor func `exec approval dialog builds on compact screens with accessibility text`() throws {
var windows: [UIWindow] = []
defer { windows.forEach { $0.isHidden = true } }
let layouts: [(CGSize, DynamicTypeSize)] = [
(CGSize(width: 320, height: 568), .accessibility5),
(CGSize(width: 568, height: 320), .accessibility3),
]
for (size, typeSize) in layouts {
let appModel = NodeAppModel()
let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-layout",
commandText: String(repeating: "/usr/bin/find /private/var/mobile/Documents ", count: 12),
warningText: String(
repeating: "This command can modify files outside the current workspace. ",
count: 12),
allowedDecisions: ["allow-once", "allow-always", "deny"],
host: "gateway.example.com",
nodeId: "node-mobile",
agentId: "main",
expiresAtMs: Int64.max))
appModel._test_presentExecApprovalPrompt(prompt)
let root = Color.clear
.execApprovalPromptDialog()
.environment(appModel)
.environment(\.dynamicTypeSize, typeSize)
windows.append(Self.host(root, size: size))
}
}
@Test @MainActor func `root prompt alert stack presents gateway trust prompt`() async {
let appModel = NodeAppModel()
let gatewayController = Self.gatewayControllerWithCapturedTLSFingerprint(appModel: appModel)
@@ -0,0 +1,446 @@
import Foundation
import Testing
private struct TestSnapshotCorrelation: Hashable {
let requestID: [UInt8]
let gatewayID: [UInt8]
init(requestID: String, gatewayID: String) {
self.requestID = Array(requestID.utf8)
self.gatewayID = Array(gatewayID.utf8)
}
}
struct WatchApprovalTransportSourceGuardTests {
@Test func `watch approval loading and screenshot proof are visible`() throws {
let appSource = try Self.readWatchSource("OpenClawWatchApp.swift")
let viewSource = try Self.readWatchSource("WatchInboxView.swift")
let approvalFace = try Self.extract(
viewSource,
from: "private var approvalsFace: some View",
to: "private var connectionFace: some View")
#expect(appSource.contains("--openclaw-watch-approval-screenshot-mode"))
#expect(appSource.contains("includeApproval: WatchScreenshotMode.approvals"))
#expect(viewSource.contains("selectedFace = WatchScreenshotMode.approvals ? 2 : 0"))
#expect(appSource.contains("id: \"watch-screenshot-approval\""))
#expect(appSource.contains("pendingApprovalCount: approvals.count"))
#expect(approvalFace.contains("self.store.isExecApprovalReviewLoading"))
#expect(approvalFace.contains("title: \"Loading approval\""))
#expect(approvalFace.contains("self.approvalCount > 0"))
#expect(approvalFace.contains("title: \"Approval not loaded\""))
#expect(approvalFace.contains("Approval details have not loaded"))
#expect(approvalFace.contains("WatchSecondaryButton(title: \"Review again\")"))
}
@Test func `watch distinguishes unsent approval from uncertain delivery`() throws {
let source = try Self.readWatchSource("OpenClawWatchApp.swift")
let storeSource = try Self.readWatchSource("WatchInboxStore.swift")
let resolveFlow = try Self.extract(
source,
from: "guard let attemptID = self.inboxStore.beginExecApprovalDecision(",
to: "onRefreshExecApprovalReview:")
let admission = try #require(
resolveFlow.range(of: "guard let attemptID = self.inboxStore.beginExecApprovalDecision("))
let send = try #require(
resolveFlow.range(of: "let result = await receiver.sendExecApprovalResolve("))
let completion = try #require(
resolveFlow.range(of: "self.inboxStore.completeExecApprovalDecision("))
#expect(admission.lowerBound < send.lowerBound)
#expect(send.lowerBound < completion.lowerBound)
#expect(storeSource.contains("!self.execApprovals[index].isResolving"))
#expect(storeSource.contains("approval.allowedDecisions.contains(decision)"))
#expect(storeSource.contains(
"WatchOpaqueUTF8Key(activeResolutionAttemptID) == WatchOpaqueUTF8Key(attemptID)"))
#expect(storeSource.contains("pendingDecision == decision"))
#expect(storeSource.contains("activeResolutionAttemptID = nil"))
#expect(resolveFlow.contains("attemptID: attemptID"))
let receiverSource = try Self.readWatchSource("WatchConnectivityReceiver.swift")
#expect(receiverSource.contains("enum WatchReplyDeliveryState"))
#expect(receiverSource.contains("delivery: .delivered"))
#expect(receiverSource.contains("delivery: .queued"))
#expect(receiverSource.contains("delivery: .notSent"))
#expect(receiverSource.contains("var requiresCanonicalReadback: Bool"))
#expect(receiverSource.contains("requiresCanonicalReadback = true"))
#expect(receiverSource.contains("requiresCanonicalReadback: requiresCanonicalReadback"))
#expect(receiverSource.contains("replyId: attemptID"))
#expect(resolveFlow.contains("if result.requiresCanonicalReadback"))
}
@Test func `forced watch refresh waits for its exact request and owner snapshot`() throws {
let appSource = try Self.readWatchSource("OpenClawWatchApp.swift")
let receiverSource = try Self.readWatchSource("WatchConnectivityReceiver.swift")
let storeSource = try Self.readWatchSource("WatchInboxStore.swift")
let refresh = try Self.extract(
appSource,
from: "private func refreshExecApprovalReview(force: Bool = false)",
to: "}\n}\n\n@MainActor")
let appSnapshotRequestEncoder = try Self.extract(
receiverSource,
from: "private static func encodeAppSnapshotRequestPayload(",
to: "private static func encodeAppCommandPayload(")
let approvalSnapshotRequestEncoder = try Self.extract(
receiverSource,
from: "private static func encodeSnapshotRequestPayload(",
to: "private static func encodeExecApprovalResolvePayload(")
#expect(refresh.contains("var requestTokens: [WatchExecApprovalSnapshotRequestToken] = []"))
#expect(refresh.contains("consumeCurrentOwnerAcknowledgment"))
#expect(refresh.contains("token.matchesGatewayStableID(currentGatewayStableID)"))
#expect(refresh.contains("discardExecApprovalSnapshotAcknowledgments("))
#expect(refresh.contains("requestTokens.append(token)"))
#expect(refresh.contains("receiver.consumeExecApprovalSnapshotAcknowledgment(for: token)"))
let checkBeforeSend = try #require(refresh.range(of: "let receivedBeforeRequest"))
let send = try #require(refresh.range(of: "await receiver.requestExecApprovalSnapshot("))
#expect(checkBeforeSend.lowerBound < send.lowerBound)
#expect(!refresh.contains("execApprovalSnapshotRevision"))
#expect(refresh.contains("let reviewAlreadyAvailable = !force"))
#expect(refresh.contains("!self.inboxStore.execApprovals.contains(where: \\.isResolving)"))
#expect(receiverSource.contains("struct WatchExecApprovalSnapshotRequestToken: Hashable"))
#expect(receiverSource.contains("self.requestKey = WatchOpaqueUTF8Key(requestId)"))
#expect(receiverSource.contains("self.gatewayKey = WatchOpaqueUTF8Key(gatewayStableID)"))
#expect(receiverSource.contains("func matchesGatewayStableID("))
#expect(approvalSnapshotRequestEncoder.contains(
"WatchGatewayID.exact(request.gatewayStableID)"))
#expect(approvalSnapshotRequestEncoder.contains("\"heldApprovals\": request.heldApprovals.map"))
#expect(approvalSnapshotRequestEncoder.contains("\"activeResolutionAttemptId\""))
#expect(storeSource.contains("func execApprovalSnapshotRequestItems("))
#expect(storeSource.contains(
"WatchGatewayID.key(record.approval.gatewayStableID) == gatewayKey"))
#expect(storeSource.contains("self.hasCompletedExecApprovalSnapshotRefreshInSession = false"))
#expect(refresh.contains(
"heldApprovals: self.inboxStore.execApprovalSnapshotRequestItems("))
#expect(receiverSource.components(
separatedBy: "discardExecApprovalSnapshotAcknowledgments(").count >= 4)
#expect(!appSnapshotRequestEncoder.contains("gatewayStableID"))
#expect(receiverSource.contains(
"WatchGatewayID.exact(payload[\"requestGatewayStableID\"] as? String)"))
#expect(receiverSource.contains("recordAcceptedExecApprovalSnapshot"))
#expect(receiverSource.contains(
"WatchGatewayID.key(snapshot.gatewayStableID) == WatchGatewayID.key(token.gatewayStableID)"))
}
@Test func `lost requested snapshot ignores unrelated accepted snapshots`() {
let requested = TestSnapshotCorrelation(requestID: "request-a", gatewayID: "gateway-a")
let unrelatedRequest = TestSnapshotCorrelation(requestID: "request-b", gatewayID: "gateway-a")
let unrelatedOwner = TestSnapshotCorrelation(requestID: "request-a", gatewayID: "gateway-b")
var accepted: Set<TestSnapshotCorrelation> = [unrelatedRequest, unrelatedOwner]
#expect(accepted.remove(requested) == nil)
accepted.insert(requested)
#expect(accepted.remove(requested) == requested)
}
@Test func `watch applies retry reset only to its exact active attempt`() throws {
let storeSource = try Self.readWatchSource("WatchInboxStore.swift")
let promptConsume = try Self.extract(
storeSource,
from: "func consume(\n execApprovalPrompt",
to: "func consume(\n execApprovalSnapshot")
let upsert = try Self.extract(
storeSource,
from: "private func upsertExecApproval(",
to: "private static func snapshotCanReplace(")
let guardedUpsert = try #require(promptConsume.range(of: "guard self.upsertExecApproval("))
let notificationOwnerCheck = try #require(promptConsume.range(of: "guard let approvalOwnerKey"))
#expect(guardedUpsert.lowerBound < notificationOwnerCheck.lowerBound)
let upsertAdmission = promptConsume[guardedUpsert.lowerBound..<notificationOwnerCheck.lowerBound]
#expect(upsertAdmission.contains("else { return }"))
#expect(upsert.contains("resetResolutionAttemptID: String? = nil) -> Bool"))
#expect(upsert.components(separatedBy: "return false").count >= 3)
#expect(upsert.contains("return true"))
#expect(promptConsume.contains(
"resetResolutionAttemptID: message.resetResolutionAttemptId"))
#expect(upsert.contains("let activeResolutionAttemptID ="))
#expect(upsert.contains(
"WatchOpaqueUTF8Key(resetResolutionAttemptID) == WatchOpaqueUTF8Key(activeResolutionAttemptID)"))
#expect(upsert.contains("activeResolutionAttemptID = resetResolvingState ? nil"))
#expect(!storeSource.contains("appliedResetDeliveryIDs"))
#expect(!storeSource.contains("deliveryId"))
#expect(!storeSource.contains("resetResolvingState: Bool?"))
}
@Test func `watch rejects partial or missing approval snapshot arrays`() throws {
let receiverSource = try Self.readWatchSource("WatchConnectivityReceiver.swift")
let parser = try Self.extract(
receiverSource,
from: "private static func parseExecApprovalSnapshotPayload(",
to: "private static func parseAppSnapshotPayload(")
#expect(parser.contains("guard let rawApprovals = payload[\"approvals\"] as? [Any]"))
#expect(parser.contains("guard let approval = Self.parseExecApprovalItem(item) else { return nil }"))
#expect(!parser.contains("compactMap"))
#expect(!parser.contains("?? []"))
}
@Test func `watch approval ids remain exact opaque values`() throws {
let receiverSource = try Self.readWatchSource("WatchConnectivityReceiver.swift")
let storeSource = try Self.readWatchSource("WatchInboxStore.swift")
let messagesSource = try Self.readWatchSource("WatchInboxMessages.swift")
let parser = try Self.extract(
receiverSource,
from: "private static func parseExecApprovalItem(",
to: "private static func parseExecApprovalPromptPayload(")
let ownerKey = try Self.extract(
storeSource,
from: "private static func execApprovalOwnerKey(",
to: "private func isExecApprovalTerminal(")
let snapshotConsume = try Self.extract(
storeSource,
from: "func consume(\n execApprovalSnapshot",
to: "func consume(appSnapshot")
let restore = try Self.extract(
storeSource,
from: "private func restorePersistedState()",
to: "private func persistState()")
// The identity validators live in WatchInboxMessages.swift since the
// message/model types were split out of WatchInboxStore.swift.
let approvalValidator = try Self.extract(
messagesSource,
from: "enum WatchApprovalID {",
to: "enum WatchGatewayID {")
let gatewayValidator = try Self.extract(
messagesSource,
from: "enum WatchGatewayID {",
to: "struct WatchExecApprovalIdentityKey:")
let prefixed = "\u{001C}approval"
#expect(prefixed != "approval")
#expect(Array(prefixed.utf8) != Array("approval".utf8))
#expect(parser.contains("WatchApprovalID.exact(payload[\"id\"] as? String)"))
#expect(!parser.contains("id = (payload[\"id\"] as? String)?.trimmingCharacters"))
#expect(ownerKey.contains("WatchApprovalID.key(approvalId)"))
#expect(!ownerKey.contains("approvalId.trimmingCharacters"))
#expect(snapshotConsume.contains("WatchApprovalID.exact(approval.id) != nil"))
#expect(snapshotConsume.contains("let hasCanonicalRequestCorrelation ="))
#expect(snapshotConsume.contains("guard hasCanonicalRequestCorrelation else { return true }"))
#expect(restore.contains("WatchApprovalID.exact(record.approvalID) != nil"))
#expect(approvalValidator.contains("!value.isEmpty"))
#expect(approvalValidator.contains("value != \".\","))
#expect(approvalValidator.contains("value != \"..\""))
#expect(!approvalValidator.contains("trimmingCharacters"))
#expect(!approvalValidator.contains("isECMAScriptTrimScalar"))
#expect(gatewayValidator.contains("guard let value, !value.isEmpty"))
#expect(!gatewayValidator.contains("WatchApprovalID.exact"))
#expect(!gatewayValidator.contains("value != \".\""))
#expect(!gatewayValidator.contains("trimmingCharacters"))
#expect(Array(" approval ".utf8) != Array("approval".utf8))
}
@Test func `watch canonical-equivalent approval IDs remain independently targetable`() throws {
let storeSource = try Self.readWatchSource("WatchInboxStore.swift")
let messagesSource = try Self.readWatchSource("WatchInboxMessages.swift")
let viewSource = try Self.readWatchSource("WatchInboxView.swift")
let composedID = "approval-\u{00E9}"
let decomposedID = "approval-e\u{0301}"
let composedKey = Data(composedID.utf8)
let decomposedKey = Data(decomposedID.utf8)
#expect(composedID == decomposedID)
#expect(composedKey != decomposedKey)
var pending = [composedKey: composedID, decomposedKey: decomposedID]
pending.removeValue(forKey: composedKey)
let remainingID = try #require(pending[decomposedKey])
#expect(Array(remainingID.utf8) == Array(decomposedID.utf8))
// Byte-exact identity types live in WatchInboxMessages.swift after the split.
#expect(messagesSource.contains("self.bytes = Array(rawValue.utf8)"))
#expect(messagesSource.contains("var id: WatchExecApprovalIdentityKey"))
#expect(messagesSource.contains("var approvalID: WatchApprovalID.Key"))
#expect(messagesSource.contains("var gatewayID: WatchGatewayID.Key"))
#expect(storeSource.contains("WatchApprovalID.key(tombstone.approvalId) == key.approvalID"))
#expect(storeSource.contains("approvalKey.notificationComponent"))
#expect(!storeSource.contains("record.id == approval.id"))
#expect(!messagesSource.contains("record.id == approval.id"))
#expect(!storeSource.contains("tombstone.approvalId == key.approvalId"))
#expect(viewSource.contains("record.approvalID"))
#expect(viewSource.contains("$0.id == self.record.id"))
}
@Test func `watch requires full accessible command review before allow`() throws {
let viewSource = try Self.readWatchSource("WatchInboxView.swift")
let typographySource = try Self.readWatchSource("WatchClawTypography.swift")
let approvalFace = try Self.extract(
viewSource,
from: "private var approvalsFace: some View",
to: "private var connectionFace: some View")
let commandReview = try Self.extract(
viewSource,
from: "private struct WatchApprovalCommandReview: View",
to: "private enum WatchExecApprovalDisplay")
let approvalDetail = try Self.extract(
viewSource,
from: "private struct WatchExecApprovalDetailView: View",
to: "private struct WatchDetailScroll")
let decisionButton = try Self.extract(
viewSource,
from: "private struct WatchDecisionButton: View",
to: "private struct WatchTinyStatus")
let detailScrollStart = try #require(
viewSource.range(of: "private struct WatchDetailScroll<Content: View>: View"))
let detailScroll = String(viewSource[detailScrollStart.lowerBound...])
#expect(approvalFace.contains("WatchSecondaryLabel(title: \"Review Command\")"))
#expect(approvalFace.contains(
".accessibilityHint(\"Opens the full command before decisions are available\")"))
#expect(!approvalFace.contains("WatchDecisionButton("))
#expect(commandReview.contains("Text(verbatim: self.commandText)"))
#expect(commandReview.contains(".font(WatchClawType.command)"))
#expect(commandReview.contains(".fixedSize(horizontal: false, vertical: true)"))
#expect(!commandReview.contains(".lineLimit("))
#expect(commandReview.contains(".accessibilityLabel(\"Command to review\")"))
#expect(commandReview.contains(".accessibilityValue(self.commandText)"))
#expect(typographySource.contains(
".custom(\"JetBrainsMono-Regular\", size: 11, relativeTo: .body)"))
#expect(approvalDetail.contains("WatchDetailScroll(title: \"Review Command\")"))
#expect(approvalDetail.contains("WatchApprovalCommandReview(commandText: self.commandText)"))
#expect(approvalDetail.contains("VStack(spacing: 8)"))
#expect(approvalDetail.contains("WatchDecisionButton(title: \"Allow Once\""))
#expect(approvalDetail.contains("WatchDecisionButton(title: \"Deny\""))
#expect(!approvalDetail.contains("WatchDecisionButton(title: \"Approve\""))
let fullCommand = try #require(
approvalDetail.range(of: "WatchApprovalCommandReview(commandText: self.commandText)"))
let allowAction = try #require(
approvalDetail.range(of: "WatchDecisionButton(title: \"Allow Once\""))
let denyAction = try #require(
approvalDetail.range(of: "WatchDecisionButton(title: \"Deny\""))
#expect(fullCommand.lowerBound < allowAction.lowerBound)
#expect(fullCommand.lowerBound < denyAction.lowerBound)
#expect(decisionButton.contains(".fixedSize(horizontal: false, vertical: true)"))
#expect(decisionButton.contains(".accessibilityLabel(self.title)"))
#expect(!decisionButton.contains(".lineLimit("))
#expect(detailScroll.contains("ScrollView {"))
#expect(detailScroll.contains("self.content"))
}
@Test func `watch compounds exact owner and approval identity`() throws {
let storeSource = try Self.readWatchSource("WatchInboxStore.swift")
let messagesSource = try Self.readWatchSource("WatchInboxMessages.swift")
let receiverSource = try Self.readWatchSource("WatchConnectivityReceiver.swift")
let sameApprovalID = Data("approval-same".utf8)
let composedOwner = Data("gateway-\u{00E9}".utf8)
let decomposedOwner = Data("gateway-e\u{0301}".utf8)
#expect(composedOwner != decomposedOwner)
#expect(Set([[composedOwner, sameApprovalID], [decomposedOwner, sameApprovalID]]).count == 2)
// The compound identity key type lives in WatchInboxMessages.swift after the split.
#expect(messagesSource.contains("struct WatchExecApprovalIdentityKey: Hashable"))
#expect(storeSource.contains("selectedExecApprovalGatewayStableID"))
#expect(storeSource.contains("gatewayKey.notificationComponent"))
#expect(storeSource.contains("WatchGatewayID.key(tombstone.gatewayStableID) == key.gatewayID"))
#expect(storeSource.contains("\"watch.execApproval.\\(record.approvalID)\""))
#expect(receiverSource.contains("WatchGatewayID.exact(payload[\"gatewayStableID\"] as? String)"))
#expect(!receiverSource.contains("gatewayStableID?.trimmingCharacters"))
#expect(!storeSource.contains("isECMAScriptTrimScalar"))
#expect(!messagesSource.contains("isECMAScriptTrimScalar"))
#expect(Array("\u{0085}gateway".utf8) != Array("gateway".utf8))
}
@Test func `watch notification identity frames dotted components`() throws {
let storeSource = try Self.readWatchSource("WatchInboxStore.swift")
let messagesSource = try Self.readWatchSource("WatchInboxMessages.swift")
let promptConsume = try Self.extract(
storeSource,
from: "func consume(\n execApprovalPrompt",
to: "func consume(\n execApprovalSnapshot")
let rawLeft = "a.b" + "." + "c"
let rawRight = "a" + "." + "b.c"
#expect(rawLeft == rawRight)
let framedLeft = Self.notificationComponent("a.b") + "." + Self.notificationComponent("c")
let framedRight = Self.notificationComponent("a") + "." + Self.notificationComponent("b.c")
#expect(framedLeft != framedRight)
// The notification-component percent encoder lives in WatchInboxMessages.swift.
#expect(messagesSource.contains("0x2D, 0x5F, 0x7E"))
#expect(!messagesSource.contains("0x2D, 0x2E, 0x5F, 0x7E"))
#expect(!storeSource.contains("0x2D, 0x2E, 0x5F, 0x7E"))
#expect(storeSource.contains("gatewayKey.notificationComponent).\\(approvalKey.notificationComponent"))
#expect(storeSource.contains("legacyExecApprovalNotificationIdentifier"))
#expect(storeSource.contains("hasLiveLegacyNotificationCollision"))
#expect(storeSource.contains("recordKey != excludedKey"))
let legacyCleanup = try #require(
promptConsume.range(of: "if let legacyNotificationIdentifier"))
let notificationSchedule = try #require(
promptConsume.range(of: "await self.postLocalNotification("))
#expect(legacyCleanup.lowerBound < notificationSchedule.lowerBound)
#expect(promptConsume.contains("!self.hasLiveLegacyNotificationCollision("))
#expect(promptConsume.contains(
"self.removeLocalNotifications(identifiers: [legacyNotificationIdentifier])"))
}
@Test func `watch snapshot acknowledgment advances only after store acceptance`() throws {
let storeSource = try Self.readWatchSource("WatchInboxStore.swift")
let receiverSource = try Self.readWatchSource("WatchConnectivityReceiver.swift")
let snapshotConsume = try Self.extract(
storeSource,
from: "func consume(\n execApprovalSnapshot",
to: "func consume(appSnapshot")
let replay = try Self.extract(
storeSource,
from: "func replayDeferredGatewayPayloads()",
to: "private func clearMessagePrompt()")
let correlation = try #require(snapshotConsume.range(of: "let hasCanonicalRequestCorrelation"))
let ownerValidation = try #require(snapshotConsume.range(of: "let allApprovalOwnersMatch"))
let existingRecords = try #require(snapshotConsume.range(of: "let existingRecords = self.execApprovals"))
#expect(snapshotConsume.contains("transport: String) -> Bool"))
#expect(snapshotConsume.components(separatedBy: "return false").count >= 4)
#expect(snapshotConsume.contains("self.persistState()\n return true"))
#expect(receiverSource.contains(
"if self.store.consume(execApprovalSnapshot: execApprovalSnapshot, transport: transport)"))
#expect(receiverSource.contains(
"if self.store.consume(execApprovalSnapshot: snapshot, transport: transport)"))
#expect(replay.contains(
"func replayDeferredGatewayPayloads() -> [WatchExecApprovalSnapshotMessage]"))
#expect(replay.contains(
"var appliedExecApprovalSnapshots: [WatchExecApprovalSnapshotMessage] = []"))
#expect(replay.contains("if self.consume(execApprovalSnapshot: message, transport: transport)"))
#expect(replay.contains("appliedExecApprovalSnapshots.append(message)"))
#expect(replay.contains("return appliedExecApprovalSnapshots"))
#expect(receiverSource.components(
separatedBy: "for snapshot in self.store.replayDeferredGatewayPayloads()").count == 3)
#expect(receiverSource.components(
separatedBy: "self.recordAcceptedExecApprovalSnapshot(snapshot)").count >= 3)
#expect(!receiverSource.contains("execApprovalSnapshotRevision"))
#expect(correlation.lowerBound < ownerValidation.lowerBound)
#expect(ownerValidation.lowerBound < existingRecords.lowerBound)
#expect(snapshotConsume.contains("guard allApprovalOwnersMatch else { return false }"))
#expect(snapshotConsume.contains(
"Self.gatewayIDsMatch(approval.gatewayStableID, snapshotGatewayID)"))
}
private static func notificationComponent(_ rawValue: String) -> String {
let hexDigits = Array("0123456789ABCDEF".utf8)
var encoded: [UInt8] = []
for byte in rawValue.utf8 {
switch byte {
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A, 0x2D, 0x5F, 0x7E:
encoded.append(byte)
default:
encoded.append(0x25)
encoded.append(hexDigits[Int(byte >> 4)])
encoded.append(hexDigits[Int(byte & 0x0F)])
}
}
return String(decoding: encoded, as: UTF8.self)
}
private static func readWatchSource(_ filename: String) throws -> String {
let url = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("WatchApp/Sources")
.appendingPathComponent(filename)
return try String(contentsOf: url, encoding: .utf8)
}
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
let startRange = try #require(source.range(of: start))
let tail = source[startRange.lowerBound...]
let endRange = try #require(tail.range(of: end))
return String(tail[..<endRange.lowerBound])
}
}
@@ -13,22 +13,27 @@ private final class WatchNotificationPresentationDelegate: NSObject, UNUserNotif
}
}
enum WatchScreenshotMode {
private static let defaultsKey = "openclaw.watch.screenshotMode"
static let approvals = ProcessInfo.processInfo.arguments.contains(
"--openclaw-watch-approval-screenshot-mode")
|| ProcessInfo.processInfo.environment["OPENCLAW_WATCH_APPROVAL_SCREENSHOT_MODE"] == "1"
static let enabled = ProcessInfo.processInfo.arguments.contains("--openclaw-watch-screenshot-mode")
|| ProcessInfo.processInfo.environment["OPENCLAW_WATCH_SCREENSHOT_MODE"] == "1"
|| UserDefaults.standard.bool(forKey: WatchScreenshotMode.defaultsKey)
|| WatchScreenshotMode.approvals
}
@main
struct OpenClawWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var inboxStore = WatchInboxStore(
requestNotificationAuthorization: !OpenClawWatchApp.isScreenshotMode)
requestNotificationAuthorization: !WatchScreenshotMode.enabled)
@State private var directNode = WatchDirectNode()
@State private var notificationDelegate = WatchNotificationPresentationDelegate()
@State private var receiver: WatchConnectivityReceiver?
@State private var execApprovalRefreshTask: Task<Void, Never>?
private static let screenshotModeDefaultsKey = "openclaw.watch.screenshotMode"
private static let isScreenshotMode = ProcessInfo.processInfo.arguments.contains(
"--openclaw-watch-screenshot-mode")
|| ProcessInfo.processInfo.environment["OPENCLAW_WATCH_SCREENSHOT_MODE"] == "1"
|| UserDefaults.standard.bool(forKey: OpenClawWatchApp.screenshotModeDefaultsKey)
var body: some Scene {
WindowGroup {
WatchInboxView(
@@ -45,16 +50,28 @@ struct OpenClawWatchApp: App {
},
onExecApprovalDecision: { approvalId, gatewayStableID, decision in
guard let receiver = self.receiver else { return }
self.inboxStore.markExecApprovalSending(approvalId: approvalId, decision: decision)
guard let attemptID = self.inboxStore.beginExecApprovalDecision(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
decision: decision)
else { return }
Task { @MainActor in
let result = await receiver.sendExecApprovalResolve(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
attemptID: attemptID,
decision: decision)
self.inboxStore.markExecApprovalSendResult(
self.inboxStore.completeExecApprovalDecision(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
attemptID: attemptID,
decision: decision,
result: result)
if result.requiresCanonicalReadback {
// WatchConnectivity errors can race successful delivery. Keep
// actions frozen while the iPhone reads canonical gateway state.
self.refreshExecApprovalReview(force: true)
}
}
},
onRefreshExecApprovalReview: {
@@ -71,8 +88,9 @@ struct OpenClawWatchApp: App {
})
.task {
UNUserNotificationCenter.current().delegate = self.notificationDelegate
if OpenClawWatchApp.isScreenshotMode {
self.inboxStore.configureScreenshotFixture()
if WatchScreenshotMode.enabled {
self.inboxStore.configureScreenshotFixture(
includeApproval: WatchScreenshotMode.approvals)
return
}
if self.receiver == nil {
@@ -150,12 +168,52 @@ struct OpenClawWatchApp: App {
self.execApprovalRefreshTask?.cancel()
self.execApprovalRefreshTask = Task { @MainActor in
var requestTokens: [WatchExecApprovalSnapshotRequestToken] = []
func consumeCurrentOwnerAcknowledgment(gatewayStableID: String?) -> Bool {
var received = false
var retainedTokens: [WatchExecApprovalSnapshotRequestToken] = []
for token in requestTokens where token.matchesGatewayStableID(gatewayStableID) {
if receiver.consumeExecApprovalSnapshotAcknowledgment(for: token) {
received = true
} else {
retainedTokens.append(token)
}
}
requestTokens = retainedTokens
return received
}
self.inboxStore.beginExecApprovalReviewLoading()
for attempt in 0..<5 {
if Task.isCancelled { return }
await receiver.requestExecApprovalSnapshot()
if !self.inboxStore.execApprovals.isEmpty
|| self.inboxStore.hasCompletedExecApprovalSnapshotRefresh
if Task.isCancelled {
return
}
let gatewayStableID = self.inboxStore.execApprovalReviewGatewayStableID
receiver.discardExecApprovalSnapshotAcknowledgments(
exceptGatewayStableID: gatewayStableID)
let receivedBeforeRequest = consumeCurrentOwnerAcknowledgment(
gatewayStableID: gatewayStableID)
let reviewAlreadyAvailable = !force
&& !self.inboxStore.execApprovals.contains(where: \.isResolving)
&& (!self.inboxStore.execApprovals.isEmpty
|| self.inboxStore.hasCompletedExecApprovalSnapshotRefresh)
if receivedBeforeRequest || reviewAlreadyAvailable {
self.inboxStore.markExecApprovalReviewLoaded()
return
}
if let token = await receiver.requestExecApprovalSnapshot(
gatewayStableID: gatewayStableID,
heldApprovals: self.inboxStore.execApprovalSnapshotRequestItems(
gatewayStableID: gatewayStableID))
{
let currentGatewayStableID = self.inboxStore.execApprovalReviewGatewayStableID
if token.matchesGatewayStableID(currentGatewayStableID) {
requestTokens.append(token)
}
}
if consumeCurrentOwnerAcknowledgment(
gatewayStableID: self.inboxStore.execApprovalReviewGatewayStableID)
{
self.inboxStore.markExecApprovalReviewLoaded()
return
@@ -174,15 +232,33 @@ struct OpenClawWatchApp: App {
@MainActor
extension WatchInboxStore {
fileprivate func configureScreenshotFixture() {
fileprivate func configureScreenshotFixture(includeApproval: Bool = false) {
let sentAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let approvals: [WatchExecApprovalItem] = if includeApproval {
[
WatchExecApprovalItem(
id: "watch-screenshot-approval",
gatewayStableID: "watch-screenshot-gateway",
commandText: "curl --request POST https://deploy.example.invalid/releases",
commandPreview: "Deploy the latest release",
warningText: "This command can change a production service.",
host: "deploy-runner",
nodeId: "release-node",
agentId: "main",
expiresAtMs: sentAtMs + 10 * 60 * 1000,
allowedDecisions: [.allowOnce, .deny],
risk: .high),
]
} else {
[]
}
greetingTextOverride = "Good morning"
self.consume(
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
approvals: [],
approvals: approvals,
gatewayStableID: "watch-screenshot-gateway",
sentAtMs: sentAtMs,
snapshotId: nil),
snapshotId: includeApproval ? "watch-screenshot-approval-face" : nil),
transport: "screenshot")
self.consume(
appSnapshot: WatchAppSnapshotMessage(
@@ -197,7 +273,7 @@ extension WatchInboxStore {
talkEnabled: true,
talkListening: false,
talkSpeaking: false,
pendingApprovalCount: 0,
pendingApprovalCount: approvals.count,
chatItems: [
WatchChatItem(
id: "watch-screenshot-user-chat",
@@ -37,6 +37,10 @@ enum WatchClawType {
body(size: 11, relativeTo: .caption2)
}
static var command: Font {
.custom("JetBrainsMono-Regular", size: 11, relativeTo: .body)
}
private static func display(size: CGFloat, weight: Font.Weight, relativeTo textStyle: Font.TextStyle) -> Font {
.custom("RedHatDisplay-Regular", size: size, relativeTo: textStyle).weight(weight)
}
@@ -12,19 +12,67 @@ struct WatchReplyDraft {
var sentAtMs: Int64
}
enum WatchReplyDeliveryState: Equatable {
case delivered
case queued
case notSent
}
struct WatchReplySendResult: Equatable {
var deliveredImmediately: Bool
var queuedForDelivery: Bool
var delivery: WatchReplyDeliveryState
var transport: String
var errorMessage: String?
var requiresCanonicalReadback: Bool
var deliveredImmediately: Bool {
self.delivery == .delivered
}
var queuedForDelivery: Bool {
self.delivery == .queued
}
}
struct WatchExecApprovalSnapshotRequestToken: Hashable, Sendable {
let requestId: String
let gatewayStableID: String
private let requestKey: WatchOpaqueUTF8Key
private let gatewayKey: WatchOpaqueUTF8Key
init?(requestId: String, gatewayStableID: String?) {
guard !requestId.isEmpty,
let gatewayStableID = WatchGatewayID.exact(gatewayStableID)
else { return nil }
self.requestId = requestId
self.gatewayStableID = gatewayStableID
self.requestKey = WatchOpaqueUTF8Key(requestId)
self.gatewayKey = WatchOpaqueUTF8Key(gatewayStableID)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.requestKey == rhs.requestKey && lhs.gatewayKey == rhs.gatewayKey
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.requestKey)
hasher.combine(self.gatewayKey)
}
func matchesGatewayStableID(_ gatewayStableID: String?) -> Bool {
WatchGatewayID.key(gatewayStableID) == self.gatewayKey
}
}
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
private typealias MessageSendContinuation = CheckedContinuation<Void, Error>
private static let maxAcceptedExecApprovalSnapshotRequests = 32
private let store: WatchInboxStore
private let session: WCSession?
private let activationGate = WatchSessionActivationGate()
private let execApprovalSnapshotAcknowledgmentLock = NSLock()
private var acceptedExecApprovalSnapshotRequests: Set<WatchExecApprovalSnapshotRequestToken> = []
private var acceptedExecApprovalSnapshotRequestOrder: [WatchExecApprovalSnapshotRequestToken] = []
private let directNodeSetupHandler: @MainActor @Sendable (String, Int64) -> Void
init(
@@ -69,21 +117,34 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
return session
}
func requestExecApprovalSnapshot() async {
guard let session = try? await self.activatedSession() else { return }
@discardableResult
func requestExecApprovalSnapshot(
gatewayStableID: String? = nil,
heldApprovals: [WatchExecApprovalSnapshotRequestItem] = []) async
-> WatchExecApprovalSnapshotRequestToken?
{
guard let session = try? await activatedSession() else { return nil }
let requestId = UUID().uuidString
let exactGatewayStableID = WatchGatewayID.exact(gatewayStableID)
let request = WatchExecApprovalSnapshotRequestMessage(
requestId: UUID().uuidString,
sentAtMs: Self.nowMs())
requestId: requestId,
sentAtMs: Self.nowMs(),
gatewayStableID: exactGatewayStableID,
heldApprovals: heldApprovals)
let token = WatchExecApprovalSnapshotRequestToken(
requestId: requestId,
gatewayStableID: exactGatewayStableID)
let payload = Self.encodeSnapshotRequestPayload(request)
if session.isReachable {
do {
try await Self.sendMessage(payload, through: session)
return
return token
} catch {
// Fall through to queued delivery.
}
}
_ = session.transferUserInfo(payload)
return token
}
func requestAppSnapshot() async -> WatchReplySendResult {
@@ -125,9 +186,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
{
payload["sessionKey"] = sessionKey
}
if let gatewayStableID = draft.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayStableID.isEmpty
{
if let gatewayStableID = WatchGatewayID.exact(draft.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
@@ -140,6 +199,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
func sendExecApprovalResolve(
approvalId: String,
gatewayStableID: String?,
attemptID: String,
decision: WatchExecApprovalDecision) async -> WatchReplySendResult
{
let session: WCSession
@@ -154,7 +214,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
approvalId: approvalId,
gatewayStableID: gatewayStableID,
decision: decision,
replyId: UUID().uuidString,
replyId: attemptID,
sentAtMs: Self.nowMs()))
return await self.sendPayload(payload, session: session)
}
@@ -170,25 +230,29 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
var requiresCanonicalReadback = false
if session.isReachable {
do {
try await Self.sendMessage(payload, through: session)
return WatchReplySendResult(
deliveredImmediately: true,
queuedForDelivery: false,
delivery: .delivered,
transport: "sendMessage",
errorMessage: nil)
errorMessage: nil,
requiresCanonicalReadback: false)
} catch {
// The immediate send may have reached the iPhone before its reply path
// failed. Queue a durable copy, but require canonical state readback.
requiresCanonicalReadback = true
// Fall through to queued delivery below.
}
}
_ = session.transferUserInfo(payload)
return WatchReplySendResult(
deliveredImmediately: false,
queuedForDelivery: true,
delivery: .queued,
transport: "transferUserInfo",
errorMessage: nil)
errorMessage: nil,
requiresCanonicalReadback: requiresCanonicalReadback)
}
private static func sendMessage(_ payload: [String: Any], through session: WCSession) async throws {
@@ -201,17 +265,58 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
private static func unavailableResult(_ error: any Error) -> WatchReplySendResult {
// Activation failed before a payload could be handed to WatchConnectivity.
// The closed notSent state lets callers safely offer an immediate retry.
WatchReplySendResult(
deliveredImmediately: false,
queuedForDelivery: false,
delivery: .notSent,
transport: "none",
errorMessage: error.localizedDescription)
errorMessage: error.localizedDescription,
requiresCanonicalReadback: false)
}
private static func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
func consumeExecApprovalSnapshotAcknowledgment(
for token: WatchExecApprovalSnapshotRequestToken) -> Bool
{
self.execApprovalSnapshotAcknowledgmentLock.withLock {
guard self.acceptedExecApprovalSnapshotRequests.remove(token) != nil else { return false }
self.acceptedExecApprovalSnapshotRequestOrder.removeAll { $0 == token }
return true
}
}
func discardExecApprovalSnapshotAcknowledgments(exceptGatewayStableID gatewayStableID: String?) {
self.execApprovalSnapshotAcknowledgmentLock.withLock {
self.acceptedExecApprovalSnapshotRequestOrder.removeAll { token in
!token.matchesGatewayStableID(gatewayStableID)
}
self.acceptedExecApprovalSnapshotRequests = Set(
self.acceptedExecApprovalSnapshotRequestOrder)
}
}
private func recordAcceptedExecApprovalSnapshot(_ snapshot: WatchExecApprovalSnapshotMessage) {
guard let requestId = snapshot.requestId,
let token = WatchExecApprovalSnapshotRequestToken(
requestId: requestId,
gatewayStableID: snapshot.requestGatewayStableID),
WatchGatewayID.key(snapshot.gatewayStableID) == WatchGatewayID.key(token.gatewayStableID)
else { return }
self.execApprovalSnapshotAcknowledgmentLock.withLock {
guard self.acceptedExecApprovalSnapshotRequests.insert(token).inserted else { return }
self.acceptedExecApprovalSnapshotRequestOrder.append(token)
// Responses can arrive after their refresh task is cancelled. Bound retained
// acknowledgments while keeping enough room for WatchConnectivity reordering.
if self.acceptedExecApprovalSnapshotRequestOrder.count > Self.maxAcceptedExecApprovalSnapshotRequests {
let evicted = self.acceptedExecApprovalSnapshotRequestOrder.removeFirst()
self.acceptedExecApprovalSnapshotRequests.remove(evicted)
}
}
}
private static func normalizeObject(_ value: Any) -> [String: Any]? {
if let object = value as? [String: Any] {
return object
@@ -271,8 +376,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let kind = (payload["kind"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let details = (payload["details"] as? String)?
@@ -306,19 +410,18 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
guard let payload = value.flatMap(normalizeObject) else {
return nil
}
let id = (payload["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let id = WatchApprovalID.exact(payload["id"] as? String) else { return nil }
let commandText = (payload["commandText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !id.isEmpty, !commandText.isEmpty else {
return nil
}
guard !commandText.isEmpty else { return nil }
let commandPreview = (payload["commandPreview"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let warningText = (payload["warningText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let host = (payload["host"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let nodeId = (payload["nodeId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let risk = WatchRiskLevel(rawValue: riskRaw)
@@ -327,9 +430,10 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
return WatchExecApprovalItem(
id: id,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
gatewayStableID: gatewayStableID,
commandText: commandText,
commandPreview: commandPreview,
warningText: warningText?.isEmpty == false ? warningText : nil,
host: host,
nodeId: nodeId,
agentId: agentId,
@@ -348,13 +452,12 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
return nil
}
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let deliveryId = (payload["deliveryId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let resetResolvingState = payload["resetResolvingState"] as? Bool
let resetResolutionAttemptId = (payload["resetResolutionAttemptId"] as? String)
.flatMap { $0.isEmpty ? nil : $0 }
return WatchExecApprovalPromptMessage(
approval: approval,
sentAtMs: sentAtMs,
deliveryId: deliveryId,
resetResolvingState: resetResolvingState)
resetResolutionAttemptId: resetResolutionAttemptId)
}
private static func parseExecApprovalResolvedPayload(
@@ -365,19 +468,20 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
else {
return nil
}
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !approvalId.isEmpty else { return nil }
guard let approvalId = WatchApprovalID.exact(payload["approvalId"] as? String) else { return nil }
let decision = Self.parseExecApprovalDecision(payload["decision"])
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let resolvedAtMs = (payload["resolvedAtMs"] as? NSNumber)?.int64Value
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let outcomeText = (payload["outcomeText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalResolvedMessage(
approvalId: approvalId,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
gatewayStableID: gatewayStableID,
decision: decision,
resolvedAtMs: resolvedAtMs,
source: source)
source: source,
outcomeText: outcomeText?.isEmpty == false ? outcomeText : nil)
}
private static func parseExecApprovalExpiredPayload(
@@ -388,19 +492,17 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
else {
return nil
}
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let approvalId = WatchApprovalID.exact(payload["approvalId"] as? String) else { return nil }
let rawReason = (payload["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !approvalId.isEmpty,
let reason = WatchExecApprovalCloseReason(rawValue: rawReason)
guard let reason = WatchExecApprovalCloseReason(rawValue: rawReason)
else {
return nil
}
let expiredAtMs = (payload["expiredAtMs"] as? NSNumber)?.int64Value
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
return WatchExecApprovalExpiredMessage(
approvalId: approvalId,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
gatewayStableID: gatewayStableID,
reason: reason,
expiredAtMs: expiredAtMs)
}
@@ -413,18 +515,25 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
else {
return nil
}
let approvals = (payload["approvals"] as? [Any] ?? []).compactMap { item in
Self.parseExecApprovalItem(item)
guard let rawApprovals = payload["approvals"] as? [Any] else { return nil }
var approvals: [WatchExecApprovalItem] = []
approvals.reserveCapacity(rawApprovals.count)
for item in rawApprovals {
guard let approval = Self.parseExecApprovalItem(item) else { return nil }
approvals.append(approval)
}
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestId = (payload["requestId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let requestGatewayStableID = WatchGatewayID.exact(payload["requestGatewayStableID"] as? String)
return WatchExecApprovalSnapshotMessage(
approvals: approvals,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
gatewayStableID: gatewayStableID,
sentAtMs: sentAtMs,
snapshotId: snapshotId)
snapshotId: snapshotId,
requestId: requestId,
requestGatewayStableID: requestGatewayStableID)
}
private static func parseAppSnapshotPayload(_ payload: [String: Any]) -> WatchAppSnapshotMessage? {
@@ -443,8 +552,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let talkStatusText = (payload["talkStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
@@ -462,7 +570,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
agentAvatarURL: agentAvatarURL?.isEmpty == false ? agentAvatarURL : nil,
agentAvatarText: agentAvatarText?.isEmpty == false ? agentAvatarText : nil,
sessionKey: sessionKey.isEmpty ? "main" : sessionKey,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
gatewayStableID: gatewayStableID,
talkStatusText: talkStatusText.isEmpty ? "Off" : talkStatusText,
talkEnabled: Self.boolValue(payload["talkEnabled"]),
talkListening: Self.boolValue(payload["talkListening"]),
@@ -544,9 +652,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
{
payload["sessionKey"] = sessionKey
}
if let gatewayStableID = message.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayStableID.isEmpty
{
if let gatewayStableID = WatchGatewayID.exact(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let text = message.text?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -566,10 +672,22 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
var payload: [String: Any] = [
"type": WatchPayloadType.execApprovalSnapshotRequest.rawValue,
"requestId": request.requestId,
"heldApprovals": request.heldApprovals.map { item in
var encoded: [String: Any] = [
"approvalId": item.approvalId,
]
if let attemptID = item.activeResolutionAttemptId, !attemptID.isEmpty {
encoded["activeResolutionAttemptId"] = attemptID
}
return encoded
},
]
if let sentAtMs = request.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
if let gatewayStableID = WatchGatewayID.exact(request.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
return payload
}
@@ -582,9 +700,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
"decision": message.decision.rawValue,
"replyId": message.replyId,
]
if let gatewayStableID = message.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayStableID.isEmpty
{
if let gatewayStableID = WatchGatewayID.exact(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let sentAtMs = message.sentAtMs {
@@ -608,8 +724,12 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
session.receivedApplicationContext,
transport: "receivedApplicationContext")
}
Task {
await self.requestExecApprovalSnapshot()
Task { @MainActor in
let gatewayStableID = self.store.execApprovalReviewGatewayStableID
await self.requestExecApprovalSnapshot(
gatewayStableID: gatewayStableID,
heldApprovals: self.store.execApprovalSnapshotRequestItems(
gatewayStableID: gatewayStableID))
}
}
@@ -655,12 +775,18 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
Task { @MainActor in
if let appSnapshot {
self.store.consume(appSnapshot: appSnapshot)
self.discardExecApprovalSnapshotAcknowledgments(
exceptGatewayStableID: appSnapshot.gatewayStableID)
}
if let execApprovalSnapshot {
self.store.consume(execApprovalSnapshot: execApprovalSnapshot, transport: transport)
if self.store.consume(execApprovalSnapshot: execApprovalSnapshot, transport: transport) {
self.recordAcceptedExecApprovalSnapshot(execApprovalSnapshot)
}
}
if appSnapshot != nil {
self.store.replayDeferredGatewayPayloads()
for snapshot in self.store.replayDeferredGatewayPayloads() {
self.recordAcceptedExecApprovalSnapshot(snapshot)
}
}
}
return
@@ -691,14 +817,20 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
}
if let snapshot = Self.parseExecApprovalSnapshotPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalSnapshot: snapshot, transport: transport)
if self.store.consume(execApprovalSnapshot: snapshot, transport: transport) {
self.recordAcceptedExecApprovalSnapshot(snapshot)
}
}
return
}
if let snapshot = Self.parseAppSnapshotPayload(payload) {
Task { @MainActor in
self.store.consume(appSnapshot: snapshot)
self.store.replayDeferredGatewayPayloads()
self.discardExecApprovalSnapshotAcknowledgments(
exceptGatewayStableID: snapshot.gatewayStableID)
for snapshot in self.store.replayDeferredGatewayPayloads() {
self.recordAcceptedExecApprovalSnapshot(snapshot)
}
}
return
}
@@ -0,0 +1,304 @@
import Foundation
enum WatchPayloadType: String, Codable, Equatable {
case notify = "watch.notify"
case directNodeSetup = "watch.node.setup"
case reply = "watch.reply"
case appSnapshot = "watch.app.snapshot"
case appSnapshotRequest = "watch.app.snapshotRequest"
case appCommand = "watch.app.command"
case chatCompletion = "watch.chat.completion"
case execApprovalPrompt = "watch.execApproval.prompt"
case execApprovalResolve = "watch.execApproval.resolve"
case execApprovalResolved = "watch.execApproval.resolved"
case execApprovalExpired = "watch.execApproval.expired"
case execApprovalSnapshot = "watch.execApproval.snapshot"
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
}
enum WatchRiskLevel: String, Codable, Equatable {
case low
case medium
case high
}
enum WatchExecApprovalDecision: String, Codable, Equatable {
case allowOnce = "allow-once"
case deny
}
enum WatchExecApprovalCloseReason: String, Codable, Equatable {
case expired
case notFound = "not-found"
case unavailable
case replaced
case resolved
}
struct WatchOpaqueUTF8Key: Hashable, Sendable {
fileprivate let bytes: [UInt8]
init(_ rawValue: String) {
self.bytes = Array(rawValue.utf8)
}
var notificationComponent: String {
let hexDigits = Array("0123456789ABCDEF".utf8)
var encoded: [UInt8] = []
encoded.reserveCapacity(self.bytes.count)
for byte in self.bytes {
switch byte {
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A, 0x2D, 0x5F, 0x7E:
encoded.append(byte)
default:
encoded.append(0x25)
encoded.append(hexDigits[Int(byte >> 4)])
encoded.append(hexDigits[Int(byte & 0x0F)])
}
}
guard let component = String(bytes: encoded, encoding: .utf8) else {
preconditionFailure("Percent-encoded approval ID must be UTF-8")
}
return component
}
}
enum WatchApprovalID {
typealias Key = WatchOpaqueUTF8Key
/// Approval IDs are opaque protocol values. Validate without trimming or normalization.
static func exact(_ value: String?) -> String? {
guard let value,
!value.isEmpty,
value != ".",
value != ".."
else { return nil }
let codeUnits = Array(value.utf16)
var index = 0
while index < codeUnits.count {
let codeUnit = codeUnits[index]
if (0xD800...0xDBFF).contains(codeUnit) {
guard index + 1 < codeUnits.count,
(0xDC00...0xDFFF).contains(codeUnits[index + 1])
else { return nil }
index += 2
continue
}
guard !(0xDC00...0xDFFF).contains(codeUnit) else { return nil }
index += 1
}
return value
}
static func key(_ value: String?) -> Key? {
self.exact(value).map(Key.init)
}
}
enum WatchGatewayID {
typealias Key = WatchOpaqueUTF8Key
static func exact(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value
}
static func key(_ value: String?) -> Key? {
self.exact(value).map(Key.init)
}
}
struct WatchExecApprovalIdentityKey: Hashable, Sendable {
var gatewayID: WatchGatewayID.Key
var approvalID: WatchApprovalID.Key
}
struct WatchExecApprovalItem: Codable, Equatable {
var id: String
var gatewayStableID: String?
var commandText: String
var commandPreview: String?
var warningText: String?
var host: String?
var nodeId: String?
var agentId: String?
var expiresAtMs: Int64?
var allowedDecisions: [WatchExecApprovalDecision]
var risk: WatchRiskLevel?
}
struct WatchExecApprovalPromptMessage: Codable, Equatable {
var approval: WatchExecApprovalItem
var sentAtMs: Int64?
var resetResolutionAttemptId: String?
}
struct WatchExecApprovalResolvedMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var decision: WatchExecApprovalDecision?
var resolvedAtMs: Int64?
var source: String?
var outcomeText: String?
}
struct WatchExecApprovalExpiredMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var reason: WatchExecApprovalCloseReason
var expiredAtMs: Int64?
}
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
var approvals: [WatchExecApprovalItem]
var gatewayStableID: String?
var sentAtMs: Int64?
var snapshotId: String?
var requestId: String?
var requestGatewayStableID: String?
init(
approvals: [WatchExecApprovalItem],
gatewayStableID: String? = nil,
sentAtMs: Int64? = nil,
snapshotId: String? = nil,
requestId: String? = nil,
requestGatewayStableID: String? = nil)
{
self.approvals = approvals
self.gatewayStableID = gatewayStableID
self.sentAtMs = sentAtMs
self.snapshotId = snapshotId
self.requestId = requestId
self.requestGatewayStableID = requestGatewayStableID
}
}
struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable, Sendable {
var requestId: String
var sentAtMs: Int64?
var gatewayStableID: String?
var heldApprovals: [WatchExecApprovalSnapshotRequestItem]
init(
requestId: String,
sentAtMs: Int64? = nil,
gatewayStableID: String? = nil,
heldApprovals: [WatchExecApprovalSnapshotRequestItem] = [])
{
self.requestId = requestId
self.sentAtMs = sentAtMs
self.gatewayStableID = gatewayStableID
self.heldApprovals = heldApprovals
}
}
struct WatchExecApprovalSnapshotRequestItem: Codable, Equatable, Sendable {
var approvalId: String
var activeResolutionAttemptId: String?
}
struct WatchExecApprovalResolveMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var decision: WatchExecApprovalDecision
var replyId: String
var sentAtMs: Int64?
}
struct WatchAppSnapshotMessage: Codable, Equatable {
var gatewayStatusText: String
var gatewayConnected: Bool
var agentName: String
var agentAvatarURL: String?
var agentAvatarText: String?
var sessionKey: String
var gatewayStableID: String?
var talkStatusText: String
var talkEnabled: Bool
var talkListening: Bool
var talkSpeaking: Bool
var pendingApprovalCount: Int
var chatItems: [WatchChatItem]?
var chatStatusText: String?
var sentAtMs: Int64?
var snapshotId: String?
}
struct WatchChatCompletionMessage: Codable, Equatable {
var commandId: String
var replyText: String
var sentAtMs: Int64?
}
struct WatchChatItem: Codable, Equatable, Identifiable {
var id: String
var role: String
var text: String
var timestampMs: Int64?
}
struct WatchAppSnapshotRequestMessage: Codable, Equatable {
var requestId: String
var sentAtMs: Int64?
}
enum WatchAppCommand: String, Codable, Equatable {
case refresh
case openChat = "open-chat"
case sendChat = "send-chat"
case startTalk = "start-talk"
case stopTalk = "stop-talk"
}
struct WatchAppCommandMessage: Codable, Equatable {
var command: WatchAppCommand
var commandId: String
var sessionKey: String?
var gatewayStableID: String?
var text: String?
var sentAtMs: Int64?
}
struct WatchPromptAction: Codable, Equatable, Identifiable {
var id: String
var label: String
var style: String?
}
struct WatchNotifyMessage: Codable {
var id: String?
var title: String
var body: String
var sentAtMs: Int64?
var promptId: String?
var sessionKey: String?
var gatewayStableID: String?
var kind: String?
var details: String?
var expiresAtMs: Int64?
var risk: String?
var actions: [WatchPromptAction]
}
struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var approval: WatchExecApprovalItem
var transport: String
var sourceSentAtMs: Int64?
var updatedAt: Date
var isResolving: Bool
var pendingDecision: WatchExecApprovalDecision?
var activeResolutionAttemptID: String?
var statusText: String?
var statusAt: Date?
var id: WatchExecApprovalIdentityKey {
WatchExecApprovalIdentityKey(
gatewayID: WatchOpaqueUTF8Key(self.approval.gatewayStableID ?? ""),
approvalID: WatchOpaqueUTF8Key(self.approval.id))
}
var approvalID: String {
self.approval.id
}
}
File diff suppressed because it is too large Load Diff
+121 -44
View File
@@ -36,7 +36,7 @@ private struct WatchControlSurfaceView: View {
var onRefreshAppSnapshot: (() -> Void)?
var onAppCommand: ((WatchAppCommand) -> Void)?
var onSendChatMessage: ((String) -> String?)?
@State private var selectedFace = 0
@State private var selectedFace = WatchScreenshotMode.approvals ? 2 : 0
var body: some View {
TabView(selection: self.$selectedFace) {
@@ -248,45 +248,48 @@ private struct WatchControlSurfaceView: View {
subtitle: self.approvalDecisionSubtitle(record),
accessory: self.approvalAccessory(record))
if record.isResolving {
WatchTinyStatus(text: record.statusText ?? "Sending decision...")
} else {
HStack(spacing: 8) {
if record.approval.allowedDecisions.contains(.allowOnce) {
WatchDecisionButton(title: "Approve", color: .green) {
self.onExecApprovalDecision?(
record.id,
record.approval.gatewayStableID,
.allowOnce)
}
}
if record.approval.allowedDecisions.contains(.deny) {
WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) {
self.onExecApprovalDecision?(
record.id,
record.approval.gatewayStableID,
.deny)
}
}
}
if let warningText = WatchExecApprovalDisplay.warningText(record.approval.warningText) {
WatchApprovalWarning(text: warningText)
}
if let statusText = record.statusText, !statusText.isEmpty, !record.isResolving {
if let statusText = WatchExecApprovalDisplay.statusText(for: record) {
WatchTinyStatus(text: statusText)
}
if !record.isResolving {
NavigationLink {
WatchExecApprovalDetailView(
store: self.store,
record: record,
onDecision: self.onExecApprovalDecision)
} label: {
WatchSecondaryLabel(title: "Review Command")
}
.buttonStyle(.plain)
.accessibilityHint("Opens the full command before decisions are available")
}
} else if self.store.isExecApprovalReviewLoading {
WatchHeroCard(
label: "Loading",
title: "Loading approval",
subtitle: self.store.execApprovalReviewStatusText ?? "Waiting for your iPhone",
accessory: "Syncing")
} else if self.approvalCount > 0 {
WatchHeroCard(
label: "Unavailable",
title: "Approval not loaded",
subtitle: self.store.execApprovalReviewStatusText ?? "Approval details have not loaded",
accessory: "Retry")
WatchSecondaryButton(title: "Review again") {
self.onRefreshExecApprovalReview?()
}
} else {
WatchHeroCard(
label: "Clear",
title: "No approvals waiting",
subtitle: self.store.lastExecApprovalOutcomeText ?? "You are caught up",
accessory: "Ready")
if self.store.shouldShowExecApprovalReviewStatus {
WatchSecondaryButton(title: "Review again") {
self.onRefreshExecApprovalReview?()
}
}
}
if self.approvalCount > 1 {
@@ -978,7 +981,8 @@ private struct WatchDecisionButton: View {
Button(action: self.action) {
Text(self.title)
.font(WatchClawType.captionBold)
.lineLimit(1)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background {
@@ -987,6 +991,7 @@ private struct WatchDecisionButton: View {
}
}
.buttonStyle(.plain)
.accessibilityLabel(self.title)
}
}
@@ -1002,6 +1007,61 @@ private struct WatchTinyStatus: View {
}
}
private struct WatchApprovalWarning: View {
let text: String
var body: some View {
Text(self.text)
.font(WatchClawType.body(size: 11))
.foregroundStyle(WatchClawStyle.accent)
.fixedSize(horizontal: false, vertical: true)
}
}
private struct WatchApprovalCommandReview: View {
let commandText: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Command")
.font(WatchClawType.label(size: 10, weight: .bold))
.foregroundStyle(.secondary)
Text(verbatim: self.commandText)
.font(WatchClawType.command)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 8)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.white.opacity(0.055))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(WatchClawStyle.border, lineWidth: 1)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Command to review")
.accessibilityValue(self.commandText)
}
}
private enum WatchExecApprovalDisplay {
static func warningText(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
static func statusText(for record: WatchExecApprovalRecord) -> String? {
let statusText = record.statusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !statusText.isEmpty {
return statusText
}
return record.isResolving ? "Sending decision..." : nil
}
}
private struct WatchChatBubble: View {
let item: WatchChatItem
var avatarImageSource: String?
@@ -1421,27 +1481,33 @@ private struct WatchExecApprovalDetailView: View {
var onDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
var body: some View {
WatchDetailScroll(title: "Approval") {
WatchDetailScroll(title: "Review Command") {
WatchHeroCard(
label: self.riskText(self.currentRecord?.approval.risk ?? self.record.approval.risk) ?? "Review",
title: self.currentRecord?.approval.commandText ?? self.record.approval.commandText,
title: "Command execution",
subtitle: self.metadataSummary,
accessory: Self
.expiresText(self.currentRecord?.approval.expiresAtMs ?? self.record.approval.expiresAtMs) ?? "Now")
if let statusText = self.currentRecord?.statusText, !statusText.isEmpty {
WatchTinyStatus(text: statusText)
WatchApprovalCommandReview(commandText: self.commandText)
if let warningText = WatchExecApprovalDisplay.warningText(
self.currentRecord?.approval.warningText ?? self.record.approval.warningText)
{
WatchApprovalWarning(text: warningText)
}
if let currentRecord {
if currentRecord.isResolving {
WatchTinyStatus(text: "Sending decision...")
} else {
HStack(spacing: 8) {
if let statusText = WatchExecApprovalDisplay.statusText(for: currentRecord) {
WatchTinyStatus(text: statusText)
}
if !currentRecord.isResolving {
VStack(spacing: 8) {
if currentRecord.approval.allowedDecisions.contains(.allowOnce) {
WatchDecisionButton(title: "Approve", color: .green) {
WatchDecisionButton(title: "Allow Once", color: .green) {
self.onDecision?(
currentRecord.id,
currentRecord.approvalID,
currentRecord.approval.gatewayStableID,
.allowOnce)
}
@@ -1450,17 +1516,24 @@ private struct WatchExecApprovalDetailView: View {
if currentRecord.approval.allowedDecisions.contains(.deny) {
WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) {
self.onDecision?(
currentRecord.id,
currentRecord.approvalID,
currentRecord.approval.gatewayStableID,
.deny)
}
}
}
}
} else if let terminalOutcomeText = self.store.terminalExecApprovalOutcomeText(
approvalId: self.record.approvalID,
gatewayStableID: self.record.approval.gatewayStableID)
{
WatchTinyStatus(text: terminalOutcomeText)
}
}
.onAppear {
self.store.selectExecApproval(id: self.record.id)
self.store.selectExecApproval(
id: self.record.approvalID,
gatewayStableID: self.record.approval.gatewayStableID)
}
}
@@ -1468,6 +1541,10 @@ private struct WatchExecApprovalDetailView: View {
self.store.execApprovals.first(where: { $0.id == self.record.id })
}
private var commandText: String {
self.currentRecord?.approval.commandText ?? self.record.approval.commandText
}
private var metadataSummary: String {
let approval = self.currentRecord?.approval ?? self.record.approval
var parts: [String] = []
@@ -1480,7 +1557,7 @@ private struct WatchExecApprovalDetailView: View {
if let agentId = approval.agentId, !agentId.isEmpty {
parts.append(agentId)
}
return parts.isEmpty ? "Tap to decide" : parts.joined(separator: " · ")
return parts.isEmpty ? "Review command below" : parts.joined(separator: " · ")
}
private func riskText(_ risk: WatchRiskLevel?) -> String? {
@@ -30,7 +30,8 @@ public enum DeviceAuthStore {
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
return store.tokens[self.tokenKey(role: role, gatewayID: gatewayID)]
guard let key = self.tokenKey(role: role, gatewayID: gatewayID) else { return nil }
return store.tokens[key]
}
public static func storeToken(
@@ -78,20 +79,24 @@ public enum DeviceAuthStore {
profile: GatewayDeviceIdentityProfile = .primary) -> (entry: DeviceAuthEntry, persisted: Bool)
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore(profile: profile)
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
let normalizedGatewayID = self.normalizeGatewayID(gatewayID)
let entry = DeviceAuthEntry(
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int64(Date().timeIntervalSince1970 * 1000),
gatewayID: self.normalizeGatewayID(gatewayID))
gatewayID: normalizedGatewayID)
guard gatewayID == nil || normalizedGatewayID != nil,
let key = self.tokenKey(role: normalizedRole, gatewayID: normalizedGatewayID)
else { return (entry, false) }
var next = self.readStore(profile: profile)
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
next?.tokens[self.tokenKey(role: normalizedRole, gatewayID: gatewayID)] = entry
next?.tokens[key] = entry
let persisted = next.map { self.writeStore($0, profile: profile) } ?? false
return (entry, persisted)
}
@@ -109,7 +114,8 @@ public enum DeviceAuthStore {
self.normalizeRole(entry.role) != normalizedRole
}
} else {
store.tokens.removeValue(forKey: self.tokenKey(role: normalizedRole, gatewayID: gatewayID))
guard let key = self.tokenKey(role: normalizedRole, gatewayID: gatewayID) else { return }
store.tokens.removeValue(forKey: key)
}
self.writeStore(store, profile: profile)
}
@@ -133,9 +139,10 @@ public enum DeviceAuthStore {
else { return false }
let normalizedRole = self.normalizeRole(role)
let legacyKey = self.tokenKey(role: normalizedRole, gatewayID: nil)
guard let legacyKey = self.tokenKey(role: normalizedRole, gatewayID: nil),
let scopedKey = self.tokenKey(role: normalizedRole, gatewayID: gatewayID)
else { return false }
guard let entry = store.tokens[legacyKey], entry.gatewayID == nil else { return false }
let scopedKey = self.tokenKey(role: normalizedRole, gatewayID: gatewayID)
if store.tokens[scopedKey] == nil {
store.tokens[scopedKey] = DeviceAuthEntry(
token: entry.token,
@@ -168,14 +175,25 @@ public enum DeviceAuthStore {
}
private static func normalizeGatewayID(_ gatewayID: String?) -> String? {
let trimmed = gatewayID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
guard let gatewayID, !gatewayID.isEmpty else { return nil }
return gatewayID
}
private static func tokenKey(role: String, gatewayID: String?) -> String {
private static func tokenKey(role: String, gatewayID: String?) -> String? {
let normalizedRole = self.normalizeRole(role)
guard let gatewayID = self.normalizeGatewayID(gatewayID) else { return normalizedRole }
return "\(gatewayID)\u{1F}\(normalizedRole)"
guard !normalizedRole.isEmpty else { return nil }
guard let gatewayID else { return normalizedRole }
guard let gatewayID = self.normalizeGatewayID(gatewayID) else { return nil }
// Swift String dictionary keys apply canonical equivalence. ASCII-encode both
// byte sequences so distinct gateway owners cannot address the same token.
return "v2.\(self.storageComponent(gatewayID)).\(self.storageComponent(normalizedRole))"
}
private static func storageComponent(_ value: String) -> String {
Data(value.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private static func normalizeScopes(_ scopes: [String]) -> [String] {
@@ -198,7 +216,27 @@ public enum DeviceAuthStore {
return nil
}
guard decoded.version == 1 else { return nil }
return decoded
// Entries carry their owner, so legacy raw keys can be safely reindexed on read.
// The next mutation persists only byte-stable v2 keys without changing file shape.
var tokens: [String: DeviceAuthEntry] = [:]
for entry in decoded.tokens.values {
let role = self.normalizeRole(entry.role)
let gatewayID = self.normalizeGatewayID(entry.gatewayID)
guard entry.gatewayID == nil || gatewayID != nil,
let key = self.tokenKey(role: role, gatewayID: gatewayID)
else { continue }
let normalized = DeviceAuthEntry(
token: entry.token,
role: role,
scopes: self.normalizeScopes(entry.scopes),
updatedAtMs: entry.updatedAtMs,
gatewayID: gatewayID)
if let existing = tokens[key], existing.updatedAtMs > normalized.updatedAtMs {
continue
}
tokens[key] = normalized
}
return DeviceAuthStoreFile(version: 1, deviceId: decoded.deviceId, tokens: tokens)
}
@discardableResult
@@ -114,6 +114,21 @@ public actor GatewayNodeSession {
var operationSettled: Bool
}
private struct ConnectOptionsKey: Equatable {
let normalizedInputs: String
let deviceAuthGatewayIDBytes: [UInt8]?
}
private struct ComputerInvokeReceiptKey: Hashable {
let receiptScopeBytes: [UInt8]
let idempotencyKeyBytes: [UInt8]
init(receiptScope: String, idempotencyKey: String) {
self.receiptScopeBytes = Array(receiptScope.utf8)
self.idempotencyKeyBytes = Array(idempotencyKey.utf8)
}
}
private struct ActiveInvoke {
let admissionGeneration: UInt64
let task: Task<BridgeInvokeResponse, Never>
@@ -133,7 +148,7 @@ public actor GatewayNodeSession {
private var channel: GatewayChannelActor?
private var activeURL: URL?
private var activeCredentials: GatewayNodeSessionCredentials?
private var activeConnectOptionsKey: String?
private var activeConnectOptionsKey: ConnectOptionsKey?
private var activeSessionIdentity: ObjectIdentifier?
private var channelGeneration: UInt64 = 0
private var admissionGeneration: UInt64 = 0
@@ -153,14 +168,15 @@ public actor GatewayNodeSession {
private var hasEverConnected = false
private var hasNotifiedConnected = false
private var snapshotReceived = false
private var serverMethods: Set<String>?
private var serverCapabilities: Set<GatewayServerCapability>?
private var snapshotWaiters: [CheckedContinuation<Bool, Never>] = []
// `computer.act` is not safe to repeat after a response is lost. Keep recent
// in-flight/results on the long-lived node session so a channel reconnect can
// replay the receipt without posting input twice. App restart intentionally
// remains a wider durable-storage boundary.
private var computerInvokeReceipts: [String: ComputerInvokeReceipt] = [:]
private var computerInvokeReceiptOrder: [String] = []
private var computerInvokeReceipts: [ComputerInvokeReceiptKey: ComputerInvokeReceipt] = [:]
private var computerInvokeReceiptOrder: [ComputerInvokeReceiptKey] = []
#if DEBUG
private var computerInvokeReceiptJoinCounts: [UUID: Int] = [:]
#endif
@@ -262,7 +278,7 @@ public actor GatewayNodeSession {
public init() {}
private func connectOptionsKey(_ options: GatewayConnectOptions) -> String {
private func connectOptionsKey(_ options: GatewayConnectOptions) -> ConnectOptionsKey {
func sorted(_ values: [String]) -> String {
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
@@ -279,8 +295,6 @@ public actor GatewayNodeSession {
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let allowStoredDeviceAuth = options.allowStoredDeviceAuth ? "1" : "0"
let deviceAuthGatewayID = options.deviceAuthGatewayID?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let permissions = options.permissions
.map { key, value in
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -289,7 +303,7 @@ public actor GatewayNodeSession {
.sorted()
.joined(separator: ",")
return [
let normalizedInputs = [
role,
scopes,
caps,
@@ -300,9 +314,11 @@ public actor GatewayNodeSession {
deviceIdentityProfile,
includeDeviceIdentity,
allowStoredDeviceAuth,
deviceAuthGatewayID,
permissions,
].joined(separator: "|")
return ConnectOptionsKey(
normalizedInputs: normalizedInputs,
deviceAuthGatewayIDBytes: options.deviceAuthGatewayID.map { Array($0.utf8) })
}
public func connect(
@@ -606,10 +622,10 @@ public actor GatewayNodeSession {
public func currentRoute(ifGatewayID expectedGatewayID: String? = nil) -> GatewayNodeSessionRoute? {
guard self.channel != nil else { return nil }
if let expectedGatewayID {
let expected = expectedGatewayID.trimmingCharacters(in: .whitespacesAndNewlines)
let current = self.connectOptions?.deviceAuthGatewayID?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !expected.isEmpty, current == expected else { return nil }
guard !expectedGatewayID.isEmpty,
let currentGatewayID = self.connectOptions?.deviceAuthGatewayID,
currentGatewayID.utf8.elementsEqual(expectedGatewayID.utf8)
else { return nil }
}
return GatewayNodeSessionRoute(
channelGeneration: self.channelGeneration,
@@ -627,6 +643,17 @@ public actor GatewayNodeSession {
return serverCapabilities.contains(capability)
}
public func supportsServerMethod(
_ method: String,
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute) -> Bool?
{
guard self.isCurrentRoute(expectedRoute),
self.channel != nil,
let serverMethods
else { return nil }
return serverMethods.contains(method)
}
@discardableResult
public func sendEvent(
event: String,
@@ -728,6 +755,7 @@ extension GatewayNodeSession {
case let .snapshot(ok):
let admissionGeneration = self.admissionGeneration
self.pluginSurfaceUrls = self.normalizePluginSurfaceUrls(ok.pluginsurfaceurls)
self.serverMethods = ok.advertisedServerMethods()
self.serverCapabilities = Set(
GatewayServerCapability.allCases.filter { ok.supportsServerCapability($0) })
if self.hasEverConnected {
@@ -754,6 +782,7 @@ extension GatewayNodeSession {
private func resetConnectionState() {
self.hasNotifiedConnected = false
self.snapshotReceived = false
self.serverMethods = nil
self.serverCapabilities = nil
self.drainSnapshotWaiters(returning: false)
}
@@ -831,9 +860,16 @@ extension GatewayNodeSession {
}
private func notifyConnectedIfNeeded(admissionGeneration: UInt64) async {
guard admissionGeneration == self.admissionGeneration,
!self.hasNotifiedConnected
else { return }
guard admissionGeneration == self.admissionGeneration else { return }
if self.hasNotifiedConnected {
// The snapshot delivery task can enqueue the callback before connect()
// reaches this method. Join that callback so connect never returns early.
let lifecycleCallback = self.lifecycleCallbackBarrier
if !self.isExecutingLifecycleCallback() {
await lifecycleCallback?.task.value
}
return
}
self.hasNotifiedConnected = true
guard let onConnected = self.onConnected else { return }
let lifecycleCallback = self.enqueueLifecycleCallback(final: onConnected)
@@ -1119,7 +1155,9 @@ extension GatewayNodeSession {
onInvoke: onInvoke)
}
let receiptKey = "\(receiptScope)\u{0}\(idempotencyKey)"
let receiptKey = ComputerInvokeReceiptKey(
receiptScope: receiptScope,
idempotencyKey: idempotencyKey)
let fingerprint = Self.computerInvokeFingerprint(requestPayload)
if let receipt = computerInvokeReceipts[receiptKey] {
guard receipt.fingerprint == fingerprint else {
@@ -1233,16 +1271,18 @@ extension GatewayNodeSession {
idempotencyKey: String,
receiptScope: String) -> Int
{
let receiptKey = "\(receiptScope)\u{0}\(idempotencyKey)"
let receiptKey = ComputerInvokeReceiptKey(
receiptScope: receiptScope,
idempotencyKey: idempotencyKey)
guard let receiptID = self.computerInvokeReceipts[receiptKey]?.id else { return 0 }
return self.computerInvokeReceiptJoinCounts[receiptID] ?? 0
}
#endif
private func computerInvokeReceiptScope() -> String {
let gatewayID = self.connectOptions?.deviceAuthGatewayID?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !gatewayID.isEmpty {
if let gatewayID = self.connectOptions?.deviceAuthGatewayID,
!gatewayID.isEmpty
{
return "gateway:\(gatewayID)"
}
return "url:\(self.activeURL?.absoluteString ?? "unknown")"
@@ -1274,7 +1314,7 @@ extension GatewayNodeSession {
}
private func discardRetryableComputerInvokeReceipt(
key: String,
key: ComputerInvokeReceiptKey,
receiptID: UUID,
fingerprint: String,
response: BridgeInvokeResponse)
@@ -1291,7 +1331,7 @@ extension GatewayNodeSession {
}
private func markComputerInvokeOperationSettled(
key: String,
key: ComputerInvokeReceiptKey,
receiptID: UUID,
fingerprint: String)
{
@@ -6,6 +6,11 @@ public enum GatewayServerCapability: String, CaseIterable, Sendable {
}
extension HelloOk {
func advertisedServerMethods() -> Set<String> {
let values = features["methods"]?.value as? [AnyCodable] ?? []
return Set(values.compactMap { $0.value as? String })
}
public func supportsServerCapability(_ capability: GatewayServerCapability) -> Bool {
let values = features["capabilities"]?.value as? [AnyCodable] ?? []
return values.contains { ($0.value as? String) == capability.rawValue }
@@ -88,39 +88,52 @@ enum GatewayTLSFirstUsePolicy {
public enum GatewayTLSStore {
private static let keychainService = "ai.openclaw.tls-pinning"
private static let keychainAccountPrefix = "fingerprint.v2."
// Legacy UserDefaults location used before Keychain migration.
private static let legacySuiteName = "ai.openclaw.shared"
private static let legacyKeyPrefix = "gateway.tls."
public static func loadFingerprint(stableID: String) -> String? {
self.migrateFromUserDefaultsIfNeeded(stableID: stableID)
let raw = GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID)?
guard let account = self.keychainAccount(stableID: stableID) else { return nil }
self.migrateLegacyFingerprintIfNeeded(stableID: stableID, account: account)
let raw = GenericPasswordKeychainStore.loadString(service: self.keychainService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if raw?.isEmpty == false { return raw }
return nil
}
public static func saveFingerprint(_ value: String, stableID: String) {
_ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
guard let account = self.keychainAccount(stableID: stableID),
GenericPasswordKeychainStore.saveString(
value,
service: self.keychainService,
account: account)
else { return }
_ = self.clearSafeLegacyFingerprint(stableID: stableID)
}
@discardableResult
public static func replaceFingerprint(_ value: String, stableID: String) -> Bool {
guard GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID) else {
guard let account = self.keychainAccount(stableID: stableID),
GenericPasswordKeychainStore.saveString(
value,
service: self.keychainService,
account: account)
else {
return false
}
self.clearLegacyFingerprint(stableID: stableID)
return true
return self.clearSafeLegacyFingerprint(stableID: stableID)
}
@discardableResult
public static func clearFingerprint(stableID: String) -> Bool {
let removedKeychain = GenericPasswordKeychainStore.delete(
guard let account = self.keychainAccount(stableID: stableID) else { return false }
let removedCanonical = GenericPasswordKeychainStore.delete(
service: self.keychainService,
account: stableID)
self.clearLegacyFingerprint(stableID: stableID)
return removedKeychain
account: account)
let removedLegacy = self.clearSafeLegacyFingerprint(stableID: stableID)
return removedCanonical && removedLegacy
}
@discardableResult
@@ -135,27 +148,62 @@ public enum GatewayTLSStore {
// MARK: - Migration
/// On first Keychain read for a given stableID, move any legacy UserDefaults
/// fingerprint into Keychain and remove the old entry.
private static func migrateFromUserDefaultsIfNeeded(stableID: String) {
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
let legacyKey = self.legacyKeyPrefix + stableID
guard let existing = defaults.string(forKey: legacyKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
else { return }
if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil {
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID)
else {
return
}
/// Legacy raw Keychain/UserDefaults keys can apply Unicode equivalence without
/// embedding their owner. Only ASCII owners are safe to attribute and migrate.
private static func migrateLegacyFingerprintIfNeeded(stableID: String, account: String) {
guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return }
let canonical = self.normalizedFingerprint(GenericPasswordKeychainStore.loadString(
service: self.keychainService,
account: account))
if canonical != nil {
_ = self.clearSafeLegacyFingerprint(stableID: stableID)
return
}
defaults.removeObject(forKey: legacyKey)
let legacyKeychain = self.normalizedFingerprint(GenericPasswordKeychainStore.loadString(
service: self.keychainService,
account: stableID))
let defaults = UserDefaults(suiteName: self.legacySuiteName)
let legacyDefaults = self.normalizedFingerprint(defaults?.string(
forKey: self.legacyKeyPrefix + stableID))
guard let existing = legacyKeychain ?? legacyDefaults,
GenericPasswordKeychainStore.saveString(
existing,
service: self.keychainService,
account: account)
else { return }
_ = self.clearSafeLegacyFingerprint(stableID: stableID)
}
private static func clearLegacyFingerprint(stableID: String) {
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
defaults.removeObject(forKey: self.legacyKeyPrefix + stableID)
private static func keychainAccount(stableID: String) -> String? {
guard !stableID.isEmpty else { return nil }
let component = Data(stableID.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return self.keychainAccountPrefix + component
}
private static func canSafelyReadLegacyRawStorageKey(_ stableID: String) -> Bool {
!stableID.isEmpty &&
!stableID.hasPrefix(self.keychainAccountPrefix) &&
stableID.unicodeScalars.allSatisfy(\.isASCII)
}
private static func normalizedFingerprint(_ value: String?) -> String? {
let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? nil : value
}
@discardableResult
private static func clearSafeLegacyFingerprint(stableID: String) -> Bool {
guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return true }
let removedKeychain = GenericPasswordKeychainStore.delete(
service: self.keychainService,
account: stableID)
UserDefaults(suiteName: self.legacySuiteName)?
.removeObject(forKey: self.legacyKeyPrefix + stableID)
return removedKeychain
}
private static func clearAllLegacyFingerprints() {
@@ -49,9 +49,7 @@ public enum ShareGatewayRelaySettings {
/// host can prove a stable ID, discard unscoped device auth and use explicit auth only.
public static func loadConfigDiscardingUnscopedDeviceAuth() -> ShareGatewayRelayConfig? {
guard let config = self.loadConfig() else { return nil }
if let gatewayID = config.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayID.isEmpty
{
if config.gatewayStableID?.isEmpty == false {
return config
}
let identity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
@@ -57,6 +57,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
public var gatewayStableID: String?
public var commandText: String
public var commandPreview: String?
public var warningText: String?
public var host: String?
public var nodeId: String?
public var agentId: String?
@@ -69,6 +70,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
gatewayStableID: String? = nil,
commandText: String,
commandPreview: String? = nil,
warningText: String? = nil,
host: String? = nil,
nodeId: String? = nil,
agentId: String? = nil,
@@ -80,6 +82,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
self.gatewayStableID = gatewayStableID
self.commandText = commandText
self.commandPreview = commandPreview
self.warningText = warningText
self.host = host
self.nodeId = nodeId
self.agentId = agentId
@@ -93,20 +96,17 @@ public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatab
public var type: OpenClawWatchPayloadType
public var approval: OpenClawWatchExecApprovalItem
public var sentAtMs: Int64?
public var deliveryId: String?
public var resetResolvingState: Bool?
public var resetResolutionAttemptId: String?
public init(
approval: OpenClawWatchExecApprovalItem,
sentAtMs: Int64? = nil,
deliveryId: String? = nil,
resetResolvingState: Bool? = nil)
resetResolutionAttemptId: String? = nil)
{
self.type = .execApprovalPrompt
self.approval = approval
self.sentAtMs = sentAtMs
self.deliveryId = deliveryId
self.resetResolvingState = resetResolvingState
self.resetResolutionAttemptId = resetResolutionAttemptId
}
}
@@ -141,13 +141,15 @@ public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equat
public var decision: OpenClawWatchExecApprovalDecision?
public var resolvedAtMs: Int64?
public var source: String?
public var outcomeText: String?
public init(
approvalId: String,
gatewayStableID: String? = nil,
decision: OpenClawWatchExecApprovalDecision? = nil,
resolvedAtMs: Int64? = nil,
source: String? = nil)
source: String? = nil,
outcomeText: String? = nil)
{
self.type = .execApprovalResolved
self.approvalId = approvalId
@@ -155,6 +157,7 @@ public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equat
self.decision = decision
self.resolvedAtMs = resolvedAtMs
self.source = source
self.outcomeText = outcomeText
}
}
@@ -185,18 +188,37 @@ public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equat
public var gatewayStableID: String?
public var sentAtMs: Int64?
public var snapshotId: String?
public var requestId: String?
public var requestGatewayStableID: String?
public init(
approvals: [OpenClawWatchExecApprovalItem],
gatewayStableID: String? = nil,
sentAtMs: Int64? = nil,
snapshotId: String? = nil)
snapshotId: String? = nil,
requestId: String? = nil,
requestGatewayStableID: String? = nil)
{
self.type = .execApprovalSnapshot
self.approvals = approvals
self.gatewayStableID = gatewayStableID
self.sentAtMs = sentAtMs
self.snapshotId = snapshotId
self.requestId = requestId
self.requestGatewayStableID = requestGatewayStableID
}
}
public struct OpenClawWatchExecApprovalSnapshotRequestItem: Codable, Sendable, Equatable {
public var approvalId: String
public var activeResolutionAttemptId: String?
public init(
approvalId: String,
activeResolutionAttemptId: String? = nil)
{
self.approvalId = approvalId
self.activeResolutionAttemptId = activeResolutionAttemptId
}
}
@@ -204,11 +226,20 @@ public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable
public var type: OpenClawWatchPayloadType
public var requestId: String
public var sentAtMs: Int64?
public var gatewayStableID: String?
public var heldApprovals: [OpenClawWatchExecApprovalSnapshotRequestItem]
public init(requestId: String, sentAtMs: Int64? = nil) {
public init(
requestId: String,
sentAtMs: Int64? = nil,
gatewayStableID: String? = nil,
heldApprovals: [OpenClawWatchExecApprovalSnapshotRequestItem] = [])
{
self.type = .execApprovalSnapshotRequest
self.requestId = requestId
self.sentAtMs = sentAtMs
self.gatewayStableID = gatewayStableID
self.heldApprovals = heldApprovals
}
}
@@ -107,6 +107,108 @@ struct DeviceIdentityStoreTests {
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a") == nil)
}
@Test(.stateDirectoryIsolated)
func `device auth owners preserve exact unicode bytes`() throws {
let deviceID = "exact-owner-device"
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let nextLineOwner = "\u{0085}gateway"
#expect(composedOwner == decomposedOwner)
#expect(!DeviceAuthStore.storeTokenPersisted(
deviceId: deviceID,
role: "node",
token: "must-not-become-unscoped",
gatewayID: ""))
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil)
for (owner, token) in [
(composedOwner, "composed-token"),
(decomposedOwner, "decomposed-token"),
(nextLineOwner, "next-line-token"),
] {
#expect(DeviceAuthStore.storeTokenPersisted(
deviceId: deviceID,
role: "node",
token: token,
gatewayID: owner))
}
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: composedOwner)?.token == "composed-token")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: decomposedOwner)?.token == "decomposed-token")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: nextLineOwner)?.token == "next-line-token")
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
let authURL = URL(fileURLWithPath: stateDirPath, isDirectory: true)
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent("device-auth.json", isDirectory: false)
let raw = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: authURL)) as? [String: Any])
let tokens = try #require(raw["tokens"] as? [String: Any])
#expect(tokens.count == 3)
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: decomposedOwner)
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: composedOwner)?.token == "composed-token")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: decomposedOwner) == nil)
}
@Test(.stateDirectoryIsolated)
func `legacy raw owner keys migrate without canonical aliasing`() throws {
let deviceID = "legacy-exact-owner-device"
let composedOwner = "gateway-\u{00E9}"
let decomposedOwner = "gateway-e\u{0301}"
let stateDirPath = try #require(getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) })
let identityURL = URL(fileURLWithPath: stateDirPath, isDirectory: true)
.appendingPathComponent("identity", isDirectory: true)
let authURL = identityURL.appendingPathComponent("device-auth.json", isDirectory: false)
try FileManager.default.createDirectory(at: identityURL, withIntermediateDirectories: true)
let legacy: [String: Any] = [
"version": 1,
"deviceId": deviceID,
"tokens": [
"\(composedOwner)\u{1F}node": [
"token": "legacy-composed-token",
"role": "node",
"scopes": [],
"updatedAtMs": 1,
"gatewayID": composedOwner,
],
],
]
try JSONSerialization.data(withJSONObject: legacy).write(to: authURL, options: [.atomic])
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: composedOwner)?.token == "legacy-composed-token")
#expect(DeviceAuthStore.storeTokenPersisted(
deviceId: deviceID,
role: "node",
token: "new-decomposed-token",
gatewayID: decomposedOwner))
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: composedOwner)?.token == "legacy-composed-token")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: decomposedOwner)?.token == "new-decomposed-token")
}
@Test(.stateDirectoryIsolated)
func `legacy device auth migration claims only the proven role`() {
let deviceID = "legacy-device"
@@ -100,6 +100,7 @@ private final class FirstCancelGate: @unchecked Sendable {
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
private let helloMethods: [String]
private let connectError: [String: Any]?
private let cancelGate: FirstCancelGate?
private var _state: URLSessionTask.State = .suspended
@@ -114,10 +115,12 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
init(
helloAuth: [String: Any]? = nil,
helloMethods: [String] = [],
connectError: [String: Any]? = nil,
cancelGate: FirstCancelGate? = nil)
{
self.helloAuth = helloAuth
self.helloMethods = helloMethods
self.connectError = connectError
self.cancelGate = cancelGate
}
@@ -213,14 +216,20 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
if let connectError {
return .data(Self.connectErrorData(id: id, error: connectError))
}
return .data(Self.connectOkData(id: id, auth: self.helloAuth))
return .data(Self.connectOkData(
id: id,
auth: self.helloAuth,
methods: self.helloMethods))
}
try await Task.sleep(nanoseconds: 1_000_000)
}
if let connectError {
return .data(Self.connectErrorData(id: "connect", error: connectError))
}
return .data(Self.connectOkData(id: "connect", auth: self.helloAuth))
return .data(Self.connectOkData(
id: "connect",
auth: self.helloAuth,
methods: self.helloMethods))
}
func receive(
@@ -278,7 +287,11 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
private static func connectOkData(id: String, auth: [String: Any]? = nil) -> Data {
private static func connectOkData(
id: String,
auth: [String: Any]? = nil,
methods: [String] = []) -> Data
{
var payload: [String: Any] = [
"type": "hello-ok",
"protocol": 2,
@@ -287,7 +300,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
"connId": "test",
],
"features": [
"methods": [],
"methods": methods,
"events": [],
],
"snapshot": [
@@ -355,6 +368,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
private let helloMethods: [String]
private let connectError: [String: Any]?
private let cancelGate: FirstCancelGate?
private var tasks: [FakeGatewayWebSocketTask] = []
@@ -363,10 +377,12 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
init(
helloAuth: [String: Any]? = nil,
helloMethods: [String] = [],
connectError: [String: Any]? = nil,
cancelGate: FirstCancelGate? = nil)
{
self.helloAuth = helloAuth
self.helloMethods = helloMethods
self.connectError = connectError
self.cancelGate = cancelGate
}
@@ -393,6 +409,7 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
self.requests.append(request)
let task = FakeGatewayWebSocketTask(
helloAuth: self.helloAuth,
helloMethods: self.helloMethods,
connectError: self.connectError,
cancelGate: self.cancelGate)
self.tasks.append(task)
@@ -526,6 +543,53 @@ private func nodeInvokePush(id: String, command: String) -> GatewayPush {
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
func `watch approval warning text is optional and round trips`() throws {
let legacy = try JSONDecoder().decode(
OpenClawWatchExecApprovalItem.self,
from: Data(#"{"id":"approval","commandText":"echo ok","allowedDecisions":["deny"]}"#.utf8))
#expect(legacy.warningText == nil)
var current = legacy
current.warningText = "Review shell expansion"
let decoded = try JSONDecoder().decode(
OpenClawWatchExecApprovalItem.self,
from: JSONEncoder().encode(current))
#expect(decoded.warningText == "Review shell expansion")
}
@Test
func `watch approval recovery schema carries exact resolution attempt identifiers`() throws {
let resetAttemptID = "\u{0085}reset-attempt\u{0085}"
let prompt = OpenClawWatchExecApprovalPromptMessage(
approval: OpenClawWatchExecApprovalItem(
id: "approval",
commandText: "echo ok"),
resetResolutionAttemptId: resetAttemptID)
let promptData = try JSONEncoder().encode(prompt)
let promptObject = try #require(
JSONSerialization.jsonObject(with: promptData) as? [String: Any])
#expect(try Array(#require(promptObject["resetResolutionAttemptId"] as? String).utf8) ==
Array(resetAttemptID.utf8))
#expect(promptObject["deliveryId"] == nil)
#expect(promptObject["resetResolvingState"] == nil)
let approvalID = "\u{0085}held-approval\u{0085}"
let activeAttemptID = "\u{0085}active-attempt\u{0085}"
let request = OpenClawWatchExecApprovalSnapshotRequestMessage(
requestId: "request",
heldApprovals: [OpenClawWatchExecApprovalSnapshotRequestItem(
approvalId: approvalID,
activeResolutionAttemptId: activeAttemptID)])
let decoded = try JSONDecoder().decode(
OpenClawWatchExecApprovalSnapshotRequestMessage.self,
from: JSONEncoder().encode(request))
#expect(decoded.heldApprovals.count == 1)
#expect(Array(decoded.heldApprovals[0].approvalId.utf8) == Array(approvalID.utf8))
#expect(try Array(#require(decoded.heldApprovals[0].activeResolutionAttemptId).utf8) ==
Array(activeAttemptID.utf8))
}
@Test
func `websocket ping ignores duplicate success callbacks`() async throws {
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
@@ -609,6 +673,62 @@ struct GatewayNodeSessionTests {
#expect(await invalidations.values() == ["same", "second"])
}
@Test
func `connect joins the snapshot dispatched connected callback`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let connectedGate = AsyncGate()
let lifecycle = DisconnectProbe()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: false)
let connect = Task {
try await gateway.connect(
url: #require(URL(string: "ws://first.example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {
await lifecycle.record("connected-start")
await connectedGate.wait()
await lifecycle.record("connected-end")
},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
await lifecycle.record("connect-returned")
}
defer { connect.cancel() }
try await waitUntil("connected callback suspended") {
await connectedGate.hasStarted()
}
for _ in 0..<20 {
await Task.yield()
}
#expect(await lifecycle.values() == ["connected-start"])
await connectedGate.release()
try await connect.value
#expect(await lifecycle.values() == [
"connected-start",
"connected-end",
"connect-returned",
])
await gateway.disconnect()
}
@Test
func `concurrent replacements wait for route invalidation before installing a channel`() async throws {
let session = FakeGatewayWebSocketSession()
@@ -654,6 +774,7 @@ struct GatewayNodeSessionTests {
try await waitUntil("route invalidation started") {
await invalidationGate.hasStarted()
}
let supersededAdmissionGeneration = await gateway._test_admissionGeneration()
let finalReplacement = Task {
try await gateway.connect(
url: #require(URL(string: "ws://third.example.invalid")),
@@ -668,8 +789,8 @@ struct GatewayNodeSessionTests {
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
}
for _ in 0..<20 {
await Task.yield()
try await waitUntil("final replacement revoked superseded admission") {
await gateway._test_admissionGeneration() != supersededAdmissionGeneration
}
#expect(await gateway.currentRoute() == nil)
@@ -1366,10 +1487,50 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func `server methods stay bound to the connected route`() async throws {
let session = FakeGatewayWebSocketSession(helloMethods: [
"approval.get",
"approval.resolve",
"exec.approval.get",
"exec.approval.resolve",
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "operator",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "operator",
clientDisplayName: "iOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://gateway.example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
let route = try #require(await gateway.currentRoute())
#expect(await gateway.supportsServerMethod("approval.get", ifCurrentRoute: route) == true)
#expect(await gateway.supportsServerMethod("missing", ifCurrentRoute: route) == false)
await gateway.disconnect()
#expect(await gateway.supportsServerMethod("approval.get", ifCurrentRoute: route) == nil)
}
@Test
func `captured route bound operations never use a replacement channel`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let composedGatewayID = "gw-\u{00E9}"
let decomposedGatewayID = "gw-e\u{0301}"
let options = GatewayConnectOptions(
role: "node",
scopes: [],
@@ -1380,7 +1541,7 @@ struct GatewayNodeSessionTests {
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: false,
deviceAuthGatewayID: "gw-a")
deviceAuthGatewayID: composedGatewayID)
try await gateway.connect(
url: #require(URL(string: "ws://first.example.invalid")),
@@ -1390,8 +1551,9 @@ struct GatewayNodeSessionTests {
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
let firstRoute = try #require(await gateway.currentRoute(ifGatewayID: "gw-a"))
#expect(await gateway.currentRoute(ifGatewayID: "GW-A") == nil)
let firstRoute = try #require(await gateway.currentRoute(ifGatewayID: composedGatewayID))
#expect(composedGatewayID == decomposedGatewayID)
#expect(await gateway.currentRoute(ifGatewayID: decomposedGatewayID) == nil)
let capturedFirstRouteSender: @Sendable (String, String?) async -> Bool = { event, payloadJSON in
await gateway.sendEvent(
event: event,
@@ -1399,20 +1561,25 @@ struct GatewayNodeSessionTests {
ifCurrentRoute: firstRoute)
}
var replacementOptions = options
replacementOptions.deviceAuthGatewayID = decomposedGatewayID
try await gateway.connect(
url: #require(URL(string: "ws://second.example.invalid")),
url: #require(URL(string: "ws://first.example.invalid")),
credentials: .init(),
connectOptions: options,
connectOptions: replacementOptions,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
#expect(await gateway.currentRoute(ifGatewayID: composedGatewayID) == nil)
#expect(await gateway.currentRoute(ifGatewayID: decomposedGatewayID) != nil)
let sent = await capturedFirstRouteSender("push.apns.register", "{}")
#expect(!sent)
do {
_ = try await gateway.request(
method: "exec.approval.get",
method: "approval.get",
paramsJSON: "{}",
ifCurrentRoute: firstRoute)
Issue.record("stale route request unexpectedly reached the replacement channel")
@@ -1421,7 +1588,7 @@ struct GatewayNodeSessionTests {
}
do {
_ = try await gateway.request(
method: "exec.approval.get",
method: "approval.get",
paramsJSON: "{}",
ifCurrentRoute: firstRoute,
distinguishPreDispatchRouteChange: true)
@@ -1431,7 +1598,7 @@ struct GatewayNodeSessionTests {
}
let replacementTask = try #require(session.latestTask())
#expect(replacementTask.sentRequestCount(method: "node.event") == 0)
#expect(replacementTask.sentRequestCount(method: "exec.approval.get") == 0)
#expect(replacementTask.sentRequestCount(method: "approval.get") == 0)
}
@Test
@@ -1730,6 +1897,33 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func `computer invoke receipts isolate canonically equivalent gateway owners`() async {
let gateway = GatewayNodeSession()
let probe = ComputerInvokeProbe()
await probe.release()
let paramsJSON = #"{"action":"type","text":"hello"}"#
let idempotencyKey = "computer.act:v1:exact-owner"
let composedScope = "gateway:gw-\u{00E9}"
let decomposedScope = "gateway:gw-e\u{0301}"
#expect(composedScope == decomposedScope)
_ = await gateway.invokeComputerWithReceiptForTesting(
requestId: "composed-owner",
paramsJSON: paramsJSON,
idempotencyKey: idempotencyKey,
receiptScope: composedScope,
onInvoke: { request in await probe.execute(request) })
_ = await gateway.invokeComputerWithReceiptForTesting(
requestId: "decomposed-owner",
paramsJSON: paramsJSON,
idempotencyKey: idempotencyKey,
receiptScope: decomposedScope,
onInvoke: { request in await probe.execute(request) })
#expect(await probe.count() == 2)
}
@Test
func `concurrent reconnect replays replace one stale receipt without duplicate input`() async throws {
let gateway = GatewayNodeSession()
+19
View File
@@ -328,6 +328,25 @@ Camera commands (foreground only; permission-gated): `camera.snap` (jpg), `camer
The Home overview includes a **Files** card that browses the active agent's workspace through the read-only `agents.workspace.list` / `agents.workspace.get` gateway RPCs: directory drill-down, text and image previews, and export through the Android share sheet. There are no write operations, and previews are size-capped by the gateway.
## Review command approvals
An operator connection with `operator.admin`, or a paired
`operator.approvals` connection explicitly targeted by the Gateway, can review
pending exec requests under **Settings -> Approvals**. The app loads the
Gateway's sanitized approval record before enabling its buttons, shows any
security warning and the exact decisions offered by that request, and submits
the approval ID and owner kind back to the Gateway.
Approval state is shared with the Control UI and supported chat surfaces. The
first committed answer wins; Android displays that canonical result even when
another surface answered first. If a resolve response is lost or the Gateway
disconnects, the app keeps the action locked and reads the approval again
before offering another decision.
Gateways that predate the unified approval methods fall back to the shipped
exec-specific methods. Pending review still works, but retained terminal state
and the richer cross-surface result require an updated Gateway.
## Assistant entrypoints
Android supports launching OpenClaw from the system assistant trigger (Google Assistant). Holding the home button (or another `ACTION_ASSIST` trigger) opens the app; saying "Hey Google, ask OpenClaw `<prompt>`" matches the app's declared App Actions query pattern and hands the prompt into the chat composer without auto-sending it.
+24
View File
@@ -93,6 +93,30 @@ does not need a separate Gateway pairing. Pair the Watch with the iPhone in
Apple's Watch app, install OpenClaw from **Watch app -> My Watch -> Available
Apps**, then open OpenClaw once on both devices.
## Review command approvals
An operator connection with `operator.admin`, or a paired
`operator.approvals` connection explicitly targeted by the Gateway, can review
pending exec requests on iPhone. The approval card shows the Gateway's
sanitized command preview, warning, host context, expiry, and only the
decisions offered by that request. The paired Apple Watch receives the same
reviewer-safe prompt through the existing iPhone relay and offers the compact
allow-once/deny decision subset. Direct Watch Gateway mode does not carry
approval prompts.
Approval state is shared with the Control UI and supported chat surfaces. The
first committed answer wins. iPhone and Watch fetch the Gateway's canonical
terminal record after another surface resolves the request, after a remote
resolved notification, and whenever a resolve acknowledgement may have been
lost. Actions stay unavailable until that readback confirms whether the
request remains pending.
Approval ownership is bound to the selected Gateway. Switching gateways cannot
apply an old prompt to the replacement connection. Gateways that predate the
unified approval methods fall back to the shipped exec-specific methods;
retained terminal state and richer cross-surface results require an updated
Gateway.
## Optional direct Apple Watch node
Direct mode gives the watch its own signed node identity and Gateway connection.
+17
View File
@@ -361,6 +361,23 @@ See:
- [Telegram](/channels/telegram)
- [QQ bot](/channels/qqbot)
### Official mobile operator apps
The official iOS and Android apps can also review Gateway-owned pending exec
approvals when an `operator.admin` connection is used, or when their paired
`operator.approvals` device was explicitly targeted by the request. They read
the same sanitized durable record used by the
Control UI, submit a kind-aware decision, and display the Gateway's canonical
first-answer result. The Apple Watch mirrors these approval prompts through
the paired iPhone, with allow-once and deny actions. Direct Watch Gateway mode
does not review approvals.
A lost resolve acknowledgement does not make the submitted choice authoritative:
the app disables the controls and reads the record again. If another surface
won, the app shows that recorded decision. Pending prompts remain bound to the
Gateway that issued them, so switching the active Gateway cannot redirect an
old approval ID.
### macOS IPC flow
```
+1 -1
View File
@@ -78,7 +78,7 @@ const CATALOGS: readonly AppleCatalogSpec[] = [
"apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift": ["Go to Chat"],
"apps/ios/Sources/RootTabs.swift": ["Agent", "Chat", "Control", "Settings", "Talk"],
"apps/ios/WatchApp/Sources/WatchInboxView.swift": [
"Approve",
"Allow Once",
"Chat",
"Continue on iPhone",
"Deny",
+30
View File
@@ -181,6 +181,29 @@ describe("native app i18n inventory", () => {
expect(entries.some((entry) => entry.source === "Open ${row.title}")).toBe(true);
expect(entries.some((entry) => entry.source === "Preview · $domain")).toBe(true);
expect(entries.some((entry) => entry.source === "Approval command copied")).toBe(true);
const androidSources = new Set(
entries.filter((entry) => entry.surface === "android").map((entry) => entry.source),
);
expect([...androidSources]).toEqual(
expect.arrayContaining([
"A prior response already allowed this command and saved the choice.",
"A prior response already allowed this command once.",
"A prior response already resolved this approval.",
"Approval allowed and saved.",
"Approval allowed once.",
"Gateway recorded approval and saved the choice.",
"Gateway recorded approval once.",
"Gateway recorded a denial.",
"This approval expired before it could be resolved.",
"This approval was cancelled before it could be resolved.",
"Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.",
"The Gateway still shows this approval as pending. Review it before trying again.",
"Could not load approval details. Refresh and try again.",
"Could not load approvals.",
"Could not resolve approval. Refresh and try again.",
"Command request",
]),
);
expect(entries.some((entry) => entry.source === "Save Profile")).toBe(true);
expect(entries.some((entry) => entry.source === "Mute")).toBe(true);
expect(entries.some((entry) => entry.source === "Creating...")).toBe(true);
@@ -290,6 +313,13 @@ describe("native app i18n inventory", () => {
).toBe(true);
expect(entries.some((entry) => entry.source === "Don't show this again")).toBe(true);
expect(entries.some((entry) => entry.source === "Use Manual Gateway")).toBe(true);
expect(
entries.some(
(entry) =>
entry.source ===
"Direct mode supports device info, status, and notifications. Chat, Talk, and approvals still use the iPhone.",
),
).toBe(true);
expect(entries.some((entry) => entry.source === "Session target")).toBe(true);
expect(
entries.some(