mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
9c33a5acfb
commit
ac89350327
+686
-454
File diff suppressed because it is too large
Load Diff
@@ -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
+40
-2
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user