diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index a6736b16daa6..9acf69cfcd05 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -33,6 +33,110 @@ "surface": "android", "id": "native.android.f59b83f58086eb89" }, + { + "kind": "conditional-branch", + "line": 116, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "Approval allowed and saved.", + "surface": "android", + "id": "native.android.d00110fa302427ec" + }, + { + "kind": "conditional-branch", + "line": 117, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "Approval allowed once.", + "surface": "android", + "id": "native.android.a2539552c0136bb6" + }, + { + "kind": "conditional-branch", + "line": 120, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "A prior response already allowed this command and saved the choice.", + "surface": "android", + "id": "native.android.5757ec300265ff2c" + }, + { + "kind": "conditional-branch", + "line": 121, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "A prior response already allowed this command once.", + "surface": "android", + "id": "native.android.3d2b0158e7e2a7ac" + }, + { + "kind": "conditional-branch", + "line": 123, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "Gateway recorded approval and saved the choice.", + "surface": "android", + "id": "native.android.db50b9d7ab7d78cd" + }, + { + "kind": "conditional-branch", + "line": 124, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "Gateway recorded approval once.", + "surface": "android", + "id": "native.android.342387ec384c891e" + }, + { + "kind": "conditional-branch", + "line": 129, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "Approval denied.", + "surface": "android", + "id": "native.android.85992377452e4155" + }, + { + "kind": "conditional-branch", + "line": 130, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "A prior response already denied this approval.", + "surface": "android", + "id": "native.android.52f0f741fa2ea221" + }, + { + "kind": "conditional-branch", + "line": 131, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "Gateway recorded a denial.", + "surface": "android", + "id": "native.android.67e7a7d8ef8b5ee3" + }, + { + "kind": "conditional-branch", + "line": 136, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "This approval expired before it could be resolved.", + "surface": "android", + "id": "native.android.6c52ab373984e933" + }, + { + "kind": "conditional-branch", + "line": 137, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "This approval was cancelled before it could be resolved.", + "surface": "android", + "id": "native.android.5aa316ff5982f079" + }, + { + "kind": "conditional-branch", + "line": 155, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "A prior response already resolved this approval.", + "surface": "android", + "id": "native.android.616b338c2081cd07" + }, + { + "kind": "conditional-branch", + "line": 249, + "path": "apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt", + "source": "Command request", + "surface": "android", + "id": "native.android.b2505b999166dffe" + }, { "kind": "conditional-branch", "line": 101, @@ -217,9 +321,49 @@ "surface": "android", "id": "native.android.7521acb4a7bc9a75" }, + { + "kind": "conditional-branch", + "line": 127, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.", + "surface": "android", + "id": "native.android.41090d74a07fa3ba" + }, + { + "kind": "conditional-branch", + "line": 129, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "The Gateway still shows this approval as pending. Review it before trying again.", + "surface": "android", + "id": "native.android.780d205fe49d942c" + }, + { + "kind": "conditional-branch", + "line": 131, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "Could not load approval details. Refresh and try again.", + "surface": "android", + "id": "native.android.d76e21aac82dea76" + }, + { + "kind": "conditional-branch", + "line": 133, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "Could not load approvals.", + "surface": "android", + "id": "native.android.2e982293714523e0" + }, + { + "kind": "conditional-branch", + "line": 135, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "Could not resolve approval. Refresh and try again.", + "surface": "android", + "id": "native.android.25808ff4b760bf48" + }, { "kind": "ui-state-text", - "line": 666, + "line": 699, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -227,7 +371,7 @@ }, { "kind": "conditional-branch", - "line": 1548, + "line": 1598, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron job started.", "surface": "android", @@ -235,7 +379,7 @@ }, { "kind": "conditional-branch", - "line": 1548, + "line": 1598, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron run queued.", "surface": "android", @@ -243,7 +387,7 @@ }, { "kind": "conditional-branch", - "line": 1592, + "line": 1642, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron job disabled.", "surface": "android", @@ -251,7 +395,7 @@ }, { "kind": "conditional-branch", - "line": 1592, + "line": 1642, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron job enabled.", "surface": "android", @@ -259,7 +403,7 @@ }, { "kind": "conditional-branch", - "line": 3195, + "line": 3252, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "android", @@ -267,7 +411,7 @@ }, { "kind": "conditional-branch", - "line": 3197, + "line": 3254, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -275,7 +419,7 @@ }, { "kind": "conditional-branch", - "line": 3199, + "line": 3256, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -443,7 +587,7 @@ }, { "kind": "conditional-branch", - "line": 1327, + "line": 1337, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Connecting…", "surface": "android", @@ -451,7 +595,7 @@ }, { "kind": "conditional-branch", - "line": 1327, + "line": 1337, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Reconnecting…", "surface": "android", @@ -3115,7 +3259,7 @@ }, { "kind": "ui-named-argument", - "line": 239, + "line": 242, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits and quota health.", "surface": "android", @@ -3123,7 +3267,7 @@ }, { "kind": "ui-named-argument", - "line": 239, + "line": 242, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Usage", "surface": "android", @@ -3131,7 +3275,7 @@ }, { "kind": "ui-named-argument", - "line": 259, + "line": 262, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load usage.", "surface": "android", @@ -3139,7 +3283,7 @@ }, { "kind": "ui-named-argument", - "line": 264, + "line": 267, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No usage data yet.", "surface": "android", @@ -3147,7 +3291,7 @@ }, { "kind": "ui-named-argument", - "line": 265, + "line": 268, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits will appear here when your gateway reports them.", "surface": "android", @@ -3155,7 +3299,7 @@ }, { "kind": "ui-named-argument", - "line": 300, + "line": 303, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron Jobs", "surface": "android", @@ -3163,7 +3307,7 @@ }, { "kind": "ui-named-argument", - "line": 300, + "line": 303, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled OpenClaw work from your gateway.", "surface": "android", @@ -3171,7 +3315,7 @@ }, { "kind": "ui-named-argument", - "line": 311, + "line": 314, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open a job to inspect its configuration and run history. Admin-scoped connections can also run, edit, enable, disable, or delete it.", "surface": "android", @@ -3179,7 +3323,7 @@ }, { "kind": "ui-named-argument", - "line": 321, + "line": 324, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load cron jobs.", "surface": "android", @@ -3187,7 +3331,7 @@ }, { "kind": "ui-named-argument", - "line": 326, + "line": 329, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No scheduled jobs.", "surface": "android", @@ -3195,7 +3339,7 @@ }, { "kind": "ui-named-argument", - "line": 327, + "line": 330, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled work created on the gateway will appear here.", "surface": "android", @@ -3203,7 +3347,7 @@ }, { "kind": "ui-named-argument", - "line": 428, + "line": 431, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Inspect scheduled gateway work.", "surface": "android", @@ -3211,7 +3355,7 @@ }, { "kind": "ui-named-argument", - "line": 449, + "line": 452, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to inspect cron jobs.", "surface": "android", @@ -3219,7 +3363,7 @@ }, { "kind": "conditional-branch", - "line": 457, + "line": 460, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron job not loaded.", "surface": "android", @@ -3227,7 +3371,7 @@ }, { "kind": "conditional-branch", - "line": 457, + "line": 460, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Loading cron job…", "surface": "android", @@ -3235,7 +3379,7 @@ }, { "kind": "ui-named-argument", - "line": 508, + "line": 511, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agents", "surface": "android", @@ -3243,7 +3387,7 @@ }, { "kind": "ui-named-argument", - "line": 508, + "line": 511, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose and inspect the assistants available on this gateway.", "surface": "android", @@ -3251,7 +3395,7 @@ }, { "kind": "ui-named-argument", - "line": 519, + "line": 522, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load agents.", "surface": "android", @@ -3259,7 +3403,7 @@ }, { "kind": "ui-named-argument", - "line": 523, + "line": 526, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No agents loaded yet.", "surface": "android", @@ -3267,7 +3411,7 @@ }, { "kind": "ui-named-argument", - "line": 549, + "line": 553, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Approvals", "surface": "android", @@ -3275,7 +3419,7 @@ }, { "kind": "ui-named-argument", - "line": 549, + "line": 553, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review actions that need your attention.", "surface": "android", @@ -3283,7 +3427,7 @@ }, { "kind": "conditional-branch", - "line": 560, + "line": 564, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refresh", "surface": "android", @@ -3291,7 +3435,7 @@ }, { "kind": "conditional-branch", - "line": 560, + "line": 564, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refreshing", "surface": "android", @@ -3299,7 +3443,7 @@ }, { "kind": "ui-named-argument", - "line": 573, + "line": 582, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway disconnected.", "surface": "android", @@ -3307,7 +3451,7 @@ }, { "kind": "ui-named-argument", - "line": 574, + "line": 583, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load approval requests in the app.", "surface": "android", @@ -3315,7 +3459,7 @@ }, { "kind": "ui-named-argument", - "line": 580, + "line": 589, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No gateway approvals.", "surface": "android", @@ -3323,7 +3467,7 @@ }, { "kind": "ui-named-argument", - "line": 581, + "line": 590, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Exec approval requests will appear here while this phone is connected.", "surface": "android", @@ -3331,7 +3475,7 @@ }, { "kind": "ui-named-argument", - "line": 588, + "line": 600, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Session activity", "surface": "android", @@ -3339,7 +3483,7 @@ }, { "kind": "ui-named-argument", - "line": 589, + "line": 601, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Chat tool calls waiting in the active session remain visible here.", "surface": "android", @@ -3347,7 +3491,7 @@ }, { "kind": "ui-named-argument", - "line": 603, + "line": 615, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "How this phone appears to OpenClaw.", "surface": "android", @@ -3355,7 +3499,7 @@ }, { "kind": "ui-named-argument", - "line": 603, + "line": 615, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Profile", "surface": "android", @@ -3363,7 +3507,7 @@ }, { "kind": "ui-named-argument", - "line": 606, + "line": 618, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Device name", "surface": "android", @@ -3371,7 +3515,7 @@ }, { "kind": "ui-named-argument", - "line": 607, + "line": 619, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save Profile", "surface": "android", @@ -3379,7 +3523,7 @@ }, { "kind": "ui-named-argument", - "line": 626, + "line": 638, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Configure voice, transport, and playback.", "surface": "android", @@ -3387,7 +3531,7 @@ }, { "kind": "ui-named-argument", - "line": 626, + "line": 638, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Talk Provider Setup", "surface": "android", @@ -3395,7 +3539,7 @@ }, { "kind": "ui-named-argument", - "line": 629, + "line": 641, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Audio Test", "surface": "android", @@ -3403,7 +3547,7 @@ }, { "kind": "ui-named-argument", - "line": 630, + "line": 642, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check that OpenClaw can speak clearly on this phone.", "surface": "android", @@ -3411,7 +3555,7 @@ }, { "kind": "conditional-branch", - "line": 633, + "line": 645, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enable speaker", "surface": "android", @@ -3419,7 +3563,7 @@ }, { "kind": "conditional-branch", - "line": 633, + "line": 645, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Mute speaker", "surface": "android", @@ -3427,7 +3571,7 @@ }, { "kind": "conditional-branch", - "line": 634, + "line": 646, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Assistant speech muted", "surface": "android", @@ -3435,7 +3579,7 @@ }, { "kind": "conditional-branch", - "line": 634, + "line": 646, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replies play aloud", "surface": "android", @@ -3443,7 +3587,7 @@ }, { "kind": "conditional-branch", - "line": 636, + "line": 648, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Muted", "surface": "android", @@ -3451,7 +3595,7 @@ }, { "kind": "conditional-branch", - "line": 636, + "line": 648, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "On", "surface": "android", @@ -3459,7 +3603,7 @@ }, { "kind": "ui-named-argument", - "line": 640, + "line": 652, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Done", "surface": "android", @@ -3467,7 +3611,7 @@ }, { "kind": "ui-named-argument", - "line": 650, + "line": 662, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Realtime Talk", "surface": "android", @@ -3475,7 +3619,7 @@ }, { "kind": "ui-named-argument", - "line": 651, + "line": 663, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Dictation", "surface": "android", @@ -3483,7 +3627,7 @@ }, { "kind": "conditional-branch", - "line": 776, + "line": 788, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowlist", "surface": "android", @@ -3491,7 +3635,7 @@ }, { "kind": "conditional-branch", - "line": 776, + "line": 788, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Blocklist", "surface": "android", @@ -3499,7 +3643,7 @@ }, { "kind": "ui-named-argument", - "line": 821, + "line": 833, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what reaches OpenClaw.", "surface": "android", @@ -3507,7 +3651,7 @@ }, { "kind": "ui-named-argument", - "line": 821, + "line": 833, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Notifications", "surface": "android", @@ -3515,7 +3659,7 @@ }, { "kind": "conditional-branch", - "line": 825, + "line": 837, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Alerts stay on this phone.", "surface": "android", @@ -3523,7 +3667,7 @@ }, { "kind": "conditional-branch", - "line": 825, + "line": 837, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can receive selected alerts.", "surface": "android", @@ -3531,7 +3675,7 @@ }, { "kind": "conditional-branch", - "line": 837, + "line": 849, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Granted", "surface": "android", @@ -3539,7 +3683,7 @@ }, { "kind": "conditional-branch", - "line": 837, + "line": 849, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup", "surface": "android", @@ -3547,7 +3691,7 @@ }, { "kind": "conditional-branch", - "line": 842, + "line": 854, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check Access", "surface": "android", @@ -3555,7 +3699,7 @@ }, { "kind": "conditional-branch", - "line": 842, + "line": 854, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open System Access", "surface": "android", @@ -3563,7 +3707,7 @@ }, { "kind": "ui-named-argument", - "line": 851, + "line": 863, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forwarding Mode", "surface": "android", @@ -3571,7 +3715,7 @@ }, { "kind": "ui-named-argument", - "line": 900, + "line": 912, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App Filter", "surface": "android", @@ -3579,7 +3723,7 @@ }, { "kind": "conditional-branch", - "line": 907, + "line": 919, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Close App Picker", "surface": "android", @@ -3587,7 +3731,7 @@ }, { "kind": "conditional-branch", - "line": 907, + "line": 919, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open App Picker", "surface": "android", @@ -3595,7 +3739,7 @@ }, { "kind": "ui-named-argument", - "line": 912, + "line": 924, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Search apps", "surface": "android", @@ -3603,7 +3747,7 @@ }, { "kind": "ui-named-argument", - "line": 915, + "line": 927, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Show System Apps", "surface": "android", @@ -3611,7 +3755,7 @@ }, { "kind": "ui-named-argument", - "line": 916, + "line": 928, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Include Android and background packages.", "surface": "android", @@ -3619,7 +3763,7 @@ }, { "kind": "ui-named-argument", - "line": 923, + "line": 935, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No matching apps.", "surface": "android", @@ -3627,7 +3771,7 @@ }, { "kind": "ui-named-argument", - "line": 934, + "line": 946, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.", "surface": "android", @@ -3635,7 +3779,7 @@ }, { "kind": "ui-named-argument", - "line": 1182, + "line": 1194, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what this phone can share.", "surface": "android", @@ -3643,7 +3787,7 @@ }, { "kind": "ui-named-argument", - "line": 1182, + "line": 1194, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Phone Capabilities", "surface": "android", @@ -3651,7 +3795,7 @@ }, { "kind": "conditional-branch", - "line": 1191, + "line": 1203, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow photo library access.", "surface": "android", @@ -3659,7 +3803,7 @@ }, { "kind": "conditional-branch", - "line": 1191, + "line": 1203, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected or full photo access granted.", "surface": "android", @@ -3667,7 +3811,7 @@ }, { "kind": "conditional-branch", - "line": 1201, + "line": 1213, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App list stays on this phone.", "surface": "android", @@ -3675,7 +3819,7 @@ }, { "kind": "conditional-branch", - "line": 1201, + "line": 1213, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can list launcher-visible apps.", "surface": "android", @@ -3683,7 +3827,7 @@ }, { "kind": "ui-named-argument", - "line": 1212, + "line": 1224, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Location", "surface": "android", @@ -3691,7 +3835,7 @@ }, { "kind": "ui-named-argument", - "line": 1220, + "line": 1232, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always allows requested location checks while OpenClaw is in the background; Android shows this in the persistent node notification.", "surface": "android", @@ -3699,7 +3843,7 @@ }, { "kind": "ui-call", - "line": 1255, + "line": 1267, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow background location?", "surface": "android", @@ -3707,7 +3851,7 @@ }, { "kind": "ui-call", - "line": 1257, + "line": 1269, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw only checks location when your paired Gateway requests it. ", "surface": "android", @@ -3715,7 +3859,7 @@ }, { "kind": "ui-call", - "line": 1270, + "line": 1282, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open Settings", "surface": "android", @@ -3723,7 +3867,7 @@ }, { "kind": "ui-call", - "line": 1289, + "line": 1301, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Share installed app information?", "surface": "android", @@ -3731,7 +3875,7 @@ }, { "kind": "ui-call", - "line": 1292, + "line": 1304, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw collects and sends the names, package IDs, and status of apps visible on this phone when your paired OpenClaw Gateway asks for them. This lets your assistant answer questions and take actions using installed apps.", "surface": "android", @@ -3739,7 +3883,7 @@ }, { "kind": "ui-call", - "line": 1295, + "line": 1307, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Your phone sends this information to your Gateway, not to a server run by OpenClaw. Your Gateway may include it in requests to the AI provider you chose.", "surface": "android", @@ -3747,7 +3891,7 @@ }, { "kind": "ui-call", - "line": 1302, + "line": 1314, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agree and Enable", "surface": "android", @@ -3755,7 +3899,7 @@ }, { "kind": "ui-call", - "line": 1307, + "line": 1319, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not Now", "surface": "android", @@ -3763,7 +3907,7 @@ }, { "kind": "ui-call", - "line": 1354, + "line": 1366, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace gateway setup?", "surface": "android", @@ -3771,7 +3915,7 @@ }, { "kind": "ui-call", - "line": 1369, + "line": 1381, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace setup", "surface": "android", @@ -3779,7 +3923,7 @@ }, { "kind": "ui-call", - "line": 1385, + "line": 1397, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget gateway?", "surface": "android", @@ -3787,7 +3931,7 @@ }, { "kind": "ui-call", - "line": 1398, + "line": 1410, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cancel", "surface": "android", @@ -3795,7 +3939,7 @@ }, { "kind": "ui-named-argument", - "line": 1404, + "line": 1416, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection between this phone and OpenClaw.", "surface": "android", @@ -3803,7 +3947,7 @@ }, { "kind": "conditional-branch", - "line": 1408, + "line": 1420, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connected", "surface": "android", @@ -3811,7 +3955,7 @@ }, { "kind": "conditional-branch", - "line": 1408, + "line": 1420, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Offline", "surface": "android", @@ -3819,7 +3963,7 @@ }, { "kind": "conditional-branch", - "line": 1409, + "line": 1421, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not paired", "surface": "android", @@ -3827,7 +3971,7 @@ }, { "kind": "conditional-branch", - "line": 1409, + "line": 1421, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Online", "surface": "android", @@ -3835,7 +3979,7 @@ }, { "kind": "ui-named-argument", - "line": 1419, + "line": 1431, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Reconnect", "surface": "android", @@ -3843,7 +3987,7 @@ }, { "kind": "ui-named-argument", - "line": 1420, + "line": 1432, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Disconnect", "surface": "android", @@ -3851,7 +3995,7 @@ }, { "kind": "ui-named-argument", - "line": 1424, + "line": 1436, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateways", "surface": "android", @@ -3859,7 +4003,7 @@ }, { "kind": "ui-named-argument", - "line": 1426, + "line": 1438, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No paired gateways.", "surface": "android", @@ -3867,7 +4011,7 @@ }, { "kind": "ui-call", - "line": 1446, + "line": 1458, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget", "surface": "android", @@ -3875,7 +4019,7 @@ }, { "kind": "ui-named-argument", - "line": 1468, + "line": 1480, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway setup", "surface": "android", @@ -3883,7 +4027,7 @@ }, { "kind": "ui-named-argument", - "line": 1470, + "line": 1482, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan or paste a setup code to add another gateway.", "surface": "android", @@ -3891,7 +4035,7 @@ }, { "kind": "ui-named-argument", - "line": 1477, + "line": 1489, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Add gateway", "surface": "android", @@ -3899,7 +4043,7 @@ }, { "kind": "ui-named-argument", - "line": 1478, + "line": 1490, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup Code", "surface": "android", @@ -3907,7 +4051,7 @@ }, { "kind": "ui-named-argument", - "line": 1482, + "line": 1494, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.", "surface": "android", @@ -3915,7 +4059,7 @@ }, { "kind": "ui-named-argument", - "line": 1491, + "line": 1503, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection Setup", "surface": "android", @@ -3923,7 +4067,7 @@ }, { "kind": "ui-named-argument", - "line": 1492, + "line": 1504, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup code", "surface": "android", @@ -3931,7 +4075,7 @@ }, { "kind": "ui-named-argument", - "line": 1494, + "line": 1506, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Host", "surface": "android", @@ -3939,7 +4083,7 @@ }, { "kind": "ui-named-argument", - "line": 1495, + "line": 1507, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Port", "surface": "android", @@ -3947,7 +4091,7 @@ }, { "kind": "ui-named-argument", - "line": 1497, + "line": 1509, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection security", "surface": "android", @@ -3955,7 +4099,7 @@ }, { "kind": "conditional-branch", - "line": 1501, + "line": 1513, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Secure (TLS)", "surface": "android", @@ -3963,7 +4107,7 @@ }, { "kind": "conditional-branch", - "line": 1501, + "line": 1513, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Unencrypted", "surface": "android", @@ -3971,7 +4115,7 @@ }, { "kind": "ui-named-argument", - "line": 1518, + "line": 1530, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Token", "surface": "android", @@ -3979,7 +4123,7 @@ }, { "kind": "ui-named-argument", - "line": 1519, + "line": 1531, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Bootstrap", "surface": "android", @@ -3987,7 +4131,7 @@ }, { "kind": "ui-named-argument", - "line": 1521, + "line": 1533, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Password", "surface": "android", @@ -3995,7 +4139,7 @@ }, { "kind": "ui-named-argument", - "line": 1526, + "line": 1538, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save & Connect", "surface": "android", @@ -4003,7 +4147,7 @@ }, { "kind": "ui-named-argument", - "line": 1543, + "line": 1555, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enter a valid setup code or gateway address.", "surface": "android", @@ -4011,7 +4155,7 @@ }, { "kind": "ui-named-argument", - "line": 1569, + "line": 1581, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Appearance", "surface": "android", @@ -4019,7 +4163,7 @@ }, { "kind": "ui-named-argument", - "line": 1569, + "line": 1581, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme and translated Android text.", "surface": "android", @@ -4027,7 +4171,7 @@ }, { "kind": "ui-named-argument", - "line": 1581, + "line": 1593, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme", "surface": "android", @@ -4035,7 +4179,7 @@ }, { "kind": "ui-named-argument", - "line": 1591, + "line": 1603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App language", "surface": "android", @@ -4043,7 +4187,7 @@ }, { "kind": "ui-named-argument", - "line": 1593, + "line": 1605, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Changes Android text that OpenClaw has translated. Screens with English-only copy stay unchanged.", "surface": "android", @@ -4051,7 +4195,7 @@ }, { "kind": "ui-named-argument", - "line": 1630, + "line": 1642, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected", "surface": "android", @@ -4059,7 +4203,7 @@ }, { "kind": "conditional-branch", - "line": 1676, + "line": 1688, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Ready", "surface": "android", @@ -4067,7 +4211,7 @@ }, { "kind": "ui-named-argument", - "line": 1706, + "line": 1718, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "About", "surface": "android", @@ -4075,7 +4219,7 @@ }, { "kind": "ui-named-argument", - "line": 1706, + "line": 1718, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw for Android.", "surface": "android", @@ -4083,7 +4227,7 @@ }, { "kind": "ui-named-argument", - "line": 1724, + "line": 1736, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway", "surface": "android", @@ -4091,7 +4235,7 @@ }, { "kind": "ui-named-argument", - "line": 1726, + "line": 1738, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Runtime", "surface": "android", @@ -4099,7 +4243,7 @@ }, { "kind": "ui-named-argument", - "line": 1729, + "line": 1741, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Update", "surface": "android", @@ -4107,7 +4251,7 @@ }, { "kind": "ui-named-argument", - "line": 1740, + "line": 1752, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "android", @@ -4115,7 +4259,7 @@ }, { "kind": "ui-named-argument", - "line": 1757, + "line": 1769, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw logo", "surface": "android", @@ -4123,7 +4267,7 @@ }, { "kind": "ui-named-argument", - "line": 1759, + "line": 1771, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw", "surface": "android", @@ -4131,7 +4275,7 @@ }, { "kind": "ui-named-argument", - "line": 1760, + "line": 1772, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Personal AI on your devices", "surface": "android", @@ -4139,7 +4283,7 @@ }, { "kind": "ui-named-argument", - "line": 1819, + "line": 1831, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Licenses", "surface": "android", @@ -4147,7 +4291,7 @@ }, { "kind": "conditional-branch", - "line": 1820, + "line": 1832, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "android", @@ -4155,7 +4299,7 @@ }, { "kind": "ui-named-argument", - "line": 1829, + "line": 1841, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No license notices are packaged in this build.", "surface": "android", @@ -4163,7 +4307,7 @@ }, { "kind": "ui-named-argument", - "line": 1855, + "line": 1867, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open ${license.title}", "surface": "android", @@ -4171,7 +4315,7 @@ }, { "kind": "conditional-branch", - "line": 1890, + "line": 1902, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check", "surface": "android", @@ -4179,71 +4323,47 @@ }, { "kind": "ui-named-argument", - "line": 1923, + "line": 1935, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Back", "surface": "android", "id": "native.android.877490cc2551c8b2" }, + { + "kind": "ui-named-argument", + "line": 2007, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Command approval", + "surface": "android", + "id": "native.android.8d8f3b067cf74034" + }, { "kind": "conditional-branch", - "line": 1997, + "line": 2012, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Sending", "surface": "android", "id": "native.android.6bbeb853f2a32dba" }, { - "kind": "conditional-branch", - "line": 2006, + "kind": "ui-named-argument", + "line": 2095, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", - "source": "Allow Once", + "source": "Approval ${notice.approvalId}", "surface": "android", - "id": "native.android.db394198a0c15dc6" + "id": "native.android.eaa50bee1f61e423" + }, + { + "kind": "ui-named-argument", + "line": 2102, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", + "source": "Dismiss approval notice", + "surface": "android", + "id": "native.android.0fb6fd448e378900" }, { "kind": "conditional-branch", - "line": 2006, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", - "source": "Allowing", - "surface": "android", - "id": "native.android.b67b2df8c644f96b" - }, - { - "kind": "conditional-branch", - "line": 2014, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", - "source": "Always", - "surface": "android", - "id": "native.android.09fc3fe9b711771c" - }, - { - "kind": "conditional-branch", - "line": 2014, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", - "source": "Saving", - "surface": "android", - "id": "native.android.4ced4ce2002b025d" - }, - { - "kind": "conditional-branch", - "line": 2022, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", - "source": "Deny", - "surface": "android", - "id": "native.android.00907f438ed4228e" - }, - { - "kind": "conditional-branch", - "line": 2022, - "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", - "source": "Denying", - "surface": "android", - "id": "native.android.403afe272ef0d19e" - }, - { - "kind": "conditional-branch", - "line": 2047, + "line": 2121, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review", "surface": "android", @@ -4251,7 +4371,7 @@ }, { "kind": "conditional-branch", - "line": 2075, + "line": 2149, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Issue", "surface": "android", @@ -4259,7 +4379,7 @@ }, { "kind": "conditional-branch", - "line": 2130, + "line": 2204, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enabled", "surface": "android", @@ -4267,7 +4387,7 @@ }, { "kind": "conditional-branch", - "line": 2144, + "line": 2218, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No", "surface": "android", @@ -4275,7 +4395,7 @@ }, { "kind": "conditional-branch", - "line": 2144, + "line": 2218, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Yes", "surface": "android", @@ -4283,7 +4403,7 @@ }, { "kind": "ui-named-argument", - "line": 2162, + "line": 2236, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Error", "surface": "android", @@ -4291,7 +4411,7 @@ }, { "kind": "ui-named-argument", - "line": 2165, + "line": 2239, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery Error", "surface": "android", @@ -4299,7 +4419,7 @@ }, { "kind": "ui-named-argument", - "line": 2204, + "line": 2278, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Tap to copy", "surface": "android", @@ -4307,7 +4427,7 @@ }, { "kind": "ui-toast", - "line": 2219, + "line": 2293, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$title copied", "surface": "android", @@ -4315,7 +4435,7 @@ }, { "kind": "conditional-branch", - "line": 2257, + "line": 2331, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default assistant", "surface": "android", @@ -4323,7 +4443,7 @@ }, { "kind": "conditional-branch", - "line": 2263, + "line": 2337, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default", "surface": "android", @@ -4331,7 +4451,7 @@ }, { "kind": "conditional-branch", - "line": 2319, + "line": 2393, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Needs attention", "surface": "android", @@ -4339,7 +4459,7 @@ }, { "kind": "conditional-branch", - "line": 2322, + "line": 2396, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting ${minutes}m", "surface": "android", @@ -4347,7 +4467,7 @@ }, { "kind": "conditional-branch", - "line": 2322, + "line": 2396, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting for review", "surface": "android", @@ -4355,7 +4475,7 @@ }, { "kind": "conditional-branch", - "line": 2350, + "line": 2424, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}", "surface": "android", @@ -4363,7 +4483,7 @@ }, { "kind": "conditional-branch", - "line": 2357, + "line": 2431, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No limits reported", "surface": "android", @@ -4371,7 +4491,7 @@ }, { "kind": "conditional-branch", - "line": 2388, + "line": 2462, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Off", "surface": "android", @@ -4379,7 +4499,7 @@ }, { "kind": "conditional-branch", - "line": 2409, + "line": 2483, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "System Event Text", "surface": "android", @@ -4387,7 +4507,7 @@ }, { "kind": "conditional-branch", - "line": 2410, + "line": 2484, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agent Prompt", "surface": "android", @@ -4395,7 +4515,7 @@ }, { "kind": "conditional-branch", - "line": 2411, + "line": 2485, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Command", "surface": "android", @@ -4403,7 +4523,7 @@ }, { "kind": "conditional-branch", - "line": 2412, + "line": 2486, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Payload Text", "surface": "android", @@ -10019,7 +10139,7 @@ }, { "kind": "ui-modifier", - "line": 147, + "line": 156, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Settings", "surface": "apple", @@ -10027,7 +10147,7 @@ }, { "kind": "ui-modifier", - "line": 252, + "line": 261, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Scan QR Code", "surface": "apple", @@ -10035,7 +10155,7 @@ }, { "kind": "ui-modifier", - "line": 274, + "line": 283, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset Onboarding?", "surface": "apple", @@ -10043,7 +10163,7 @@ }, { "kind": "ui-call", - "line": 278, + "line": 287, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset", "surface": "apple", @@ -10051,7 +10171,7 @@ }, { "kind": "ui-call", - "line": 286, + "line": 295, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This disconnects, clears saved gateway credentials, and reopens onboarding.", "surface": "apple", @@ -10059,7 +10179,7 @@ }, { "kind": "ui-modifier", - "line": 289, + "line": 298, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -10067,7 +10187,7 @@ }, { "kind": "ui-call", - "line": 299, + "line": 308, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "OK", "surface": "apple", @@ -10075,7 +10195,7 @@ }, { "kind": "ui-modifier", - "line": 306, + "line": 315, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Access Level", "surface": "apple", @@ -10083,7 +10203,7 @@ }, { "kind": "ui-call", - "line": 314, + "line": 323, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "While Using the App", "surface": "apple", @@ -10091,7 +10211,7 @@ }, { "kind": "ui-call", - "line": 320, + "line": 329, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Always", "surface": "apple", @@ -10099,7 +10219,7 @@ }, { "kind": "ui-call", - "line": 328, + "line": 337, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Choose when OpenClaw may share this iPhone's location with gateway tools.", "surface": "apple", @@ -10107,7 +10227,7 @@ }, { "kind": "ui-call", - "line": 345, + "line": 354, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Forget Gateway", "surface": "apple", @@ -10115,7 +10235,7 @@ }, { "kind": "ui-call", - "line": 351, + "line": 360, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Cancel", "surface": "apple", @@ -10123,7 +10243,7 @@ }, { "kind": "ui-call-concatenated", - "line": 355, + "line": 364, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This removes saved credentials, device access, TLS trust, and cached chats for this gateway.", "surface": "apple", @@ -10131,7 +10251,7 @@ }, { "kind": "ui-call", - "line": 408, + "line": 420, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Enable OpenClaw Hosted Push Relay?", "surface": "apple", @@ -10139,7 +10259,7 @@ }, { "kind": "ui-call", - "line": 422, + "line": 434, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Continue", "surface": "apple", @@ -10147,7 +10267,7 @@ }, { "kind": "ui-call", - "line": 430, + "line": 442, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Not Now", "surface": "apple", @@ -10251,7 +10371,7 @@ }, { "kind": "conditional-branch", - "line": 914, + "line": 922, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup queued for the watch. Open OpenClaw before the code expires.", "surface": "apple", @@ -10259,7 +10379,7 @@ }, { "kind": "conditional-branch", - "line": 914, + "line": 922, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup sent. Open OpenClaw on the watch to connect.", "surface": "apple", @@ -10267,7 +10387,7 @@ }, { "kind": "conditional-branch", - "line": 1070, + "line": 1080, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Configured", "surface": "apple", @@ -10275,7 +10395,7 @@ }, { "kind": "conditional-branch", - "line": 1070, + "line": 1080, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not configured", "surface": "apple", @@ -10283,7 +10403,7 @@ }, { "kind": "conditional-branch", - "line": 1135, + "line": 1145, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connect to the gateway.", "surface": "apple", @@ -10291,7 +10411,7 @@ }, { "kind": "conditional-branch", - "line": 1135, + "line": 1145, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Gateway requests will appear here.", "surface": "apple", @@ -10299,7 +10419,23 @@ }, { "kind": "conditional-branch", - "line": 1177, + "line": 1180, + "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", + "source": "1 waiting", + "surface": "apple", + "id": "native.apple.ebd38d32acb5373c" + }, + { + "kind": "conditional-branch", + "line": 1180, + "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", + "source": "\\(self.pendingApprovalCount) waiting", + "surface": "apple", + "id": "native.apple.651249e2a5fc8852" + }, + { + "kind": "conditional-branch", + "line": 1195, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "High", "surface": "apple", @@ -10307,7 +10443,7 @@ }, { "kind": "conditional-branch", - "line": 1177, + "line": 1195, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Resolving", "surface": "apple", @@ -10315,7 +10451,7 @@ }, { "kind": "conditional-branch", - "line": 1182, + "line": 1200, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "One-time approval", "surface": "apple", @@ -10323,7 +10459,7 @@ }, { "kind": "conditional-branch", - "line": 1182, + "line": 1200, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Permission can be saved", "surface": "apple", @@ -10331,7 +10467,7 @@ }, { "kind": "conditional-branch", - "line": 1184, + "line": 1202, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Medium", "surface": "apple", @@ -10339,7 +10475,7 @@ }, { "kind": "conditional-branch", - "line": 1184, + "line": 1202, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Review", "surface": "apple", @@ -10347,7 +10483,7 @@ }, { "kind": "conditional-branch", - "line": 1205, + "line": 1223, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "\\(diagnosticsIssueCount)", "surface": "apple", @@ -10483,7 +10619,7 @@ }, { "kind": "ui-modifier", - "line": 343, + "line": 347, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Switch Gateway", "surface": "apple", @@ -10491,7 +10627,7 @@ }, { "kind": "ui-named-argument", - "line": 350, + "line": 354, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Approvals", "surface": "apple", @@ -10499,31 +10635,23 @@ }, { "kind": "conditional-branch", - "line": 353, + "line": 357, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No gateway actions are waiting for review.", "surface": "apple", "id": "native.apple.bbc85e1645e8ccf1" }, - { - "kind": "conditional-branch", - "line": 353, - "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Review the pending gateway action.", - "surface": "apple", - "id": "native.apple.2f164497392d8357" - }, { "kind": "conditional-branch", "line": 357, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "1 waiting", + "source": "Review pending gateway actions.", "surface": "apple", - "id": "native.apple.6156b1e3a8cec56c" + "id": "native.apple.cd9730a7cb964be7" }, { "kind": "ui-named-argument", - "line": 374, + "line": 378, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Apple Watch", "surface": "apple", @@ -10531,7 +10659,7 @@ }, { "kind": "conditional-branch", - "line": 376, + "line": 380, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Install the OpenClaw watch app before enabling direct mode.", "surface": "apple", @@ -10539,7 +10667,7 @@ }, { "kind": "conditional-branch", - "line": 376, + "line": 380, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Relay remains available; direct mode adds an independent Gateway node.", "surface": "apple", @@ -10547,7 +10675,7 @@ }, { "kind": "conditional-branch", - "line": 378, + "line": 382, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Installed", "surface": "apple", @@ -10555,7 +10683,7 @@ }, { "kind": "conditional-branch", - "line": 378, + "line": 382, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Unavailable", "surface": "apple", @@ -10563,7 +10691,7 @@ }, { "kind": "ui-call", - "line": 385, + "line": 389, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Enable Direct Gateway Connection", "surface": "apple", @@ -10571,7 +10699,7 @@ }, { "kind": "ui-call-multiline", - "line": 401, + "line": 405, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "The watch receives a one-time pairing code and stores its own device token. A reachable secure Gateway URL is required away from the iPhone.", "surface": "apple", @@ -10579,7 +10707,7 @@ }, { "kind": "ui-call", - "line": 409, + "line": 413, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Direct node features", "surface": "apple", @@ -10587,7 +10715,7 @@ }, { "kind": "ui-call", - "line": 419, + "line": 423, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Notifications are off", "surface": "apple", @@ -10595,7 +10723,7 @@ }, { "kind": "ui-call", - "line": 421, + "line": 425, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Enable Notifications to receive approval alerts while OpenClaw is not open.", "surface": "apple", @@ -10603,7 +10731,7 @@ }, { "kind": "ui-call", - "line": 430, + "line": 434, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Open Notifications", "surface": "apple", @@ -10611,31 +10739,63 @@ }, { "kind": "ui-call", - "line": 452, + "line": 444, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Allow", + "source": "Pending approvals", "surface": "apple", - "id": "native.apple.34b198d5caa2f6de" + "id": "native.apple.8a6df64f48087b74" }, { - "kind": "ui-call", - "line": 460, + "kind": "ui-modifier", + "line": 461, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Always Allow", + "source": "Review exec approval", "surface": "apple", - "id": "native.apple.53c2c08c522ceb97" + "id": "native.apple.e70b7e1453061f75" }, { "kind": "ui-call", "line": 468, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Reviewing", + "surface": "apple", + "id": "native.apple.2bdc4dfaddf2236d" + }, + { + "kind": "ui-call", + "line": 502, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Allow Once", + "surface": "apple", + "id": "native.apple.77d924dde15c505e" + }, + { + "kind": "ui-call", + "line": 511, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Allow Always", + "surface": "apple", + "id": "native.apple.d002a80c43148553" + }, + { + "kind": "ui-call", + "line": 520, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Deny", "surface": "apple", "id": "native.apple.47aa34a39a9e05e5" }, { "kind": "ui-call", - "line": 477, + "line": 531, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Dismiss", + "surface": "apple", + "id": "native.apple.eb833f81b7901d39" + }, + { + "kind": "ui-call", + "line": 541, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No approvals waiting", "surface": "apple", @@ -10643,7 +10803,7 @@ }, { "kind": "ui-named-argument", - "line": 494, + "line": 571, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Camera", "surface": "apple", @@ -10651,7 +10811,7 @@ }, { "kind": "ui-named-argument", - "line": 500, + "line": 577, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Keep Awake", "surface": "apple", @@ -10659,7 +10819,7 @@ }, { "kind": "ui-named-argument", - "line": 511, + "line": 588, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice & Talk", "surface": "apple", @@ -10667,7 +10827,7 @@ }, { "kind": "ui-named-argument", - "line": 526, + "line": 603, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Health Check", "surface": "apple", @@ -10675,7 +10835,7 @@ }, { "kind": "ui-named-argument", - "line": 527, + "line": 604, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run app, permission, and gateway-adjacent checks without editing setup.", "surface": "apple", @@ -10683,7 +10843,7 @@ }, { "kind": "ui-call", - "line": 535, + "line": 612, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Diagnostics", "surface": "apple", @@ -10691,7 +10851,7 @@ }, { "kind": "ui-call", - "line": 545, + "line": 622, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Platform", "surface": "apple", @@ -10699,7 +10859,7 @@ }, { "kind": "ui-call", - "line": 546, + "line": 623, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "App", "surface": "apple", @@ -10707,7 +10867,7 @@ }, { "kind": "ui-call", - "line": 547, + "line": 624, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Model", "surface": "apple", @@ -10715,7 +10875,7 @@ }, { "kind": "ui-named-argument", - "line": 559, + "line": 636, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Camera Access", "surface": "apple", @@ -10723,7 +10883,7 @@ }, { "kind": "ui-named-argument", - "line": 565, + "line": 642, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Background Listening", "surface": "apple", @@ -10731,7 +10891,7 @@ }, { "kind": "ui-call", - "line": 590, + "line": 667, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Notifications", "surface": "apple", @@ -10739,7 +10899,7 @@ }, { "kind": "ui-modifier", - "line": 597, + "line": 674, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Turns OpenClaw notification delivery on or off", "surface": "apple", @@ -10747,7 +10907,7 @@ }, { "kind": "ui-named-argument", - "line": 617, + "line": 694, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reconnect", "surface": "apple", @@ -10755,7 +10915,7 @@ }, { "kind": "ui-named-argument", - "line": 627, + "line": 704, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Diagnose", "surface": "apple", @@ -10763,7 +10923,7 @@ }, { "kind": "ui-call", - "line": 643, + "line": 720, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "License files are not available in this build.", "surface": "apple", @@ -10771,7 +10931,7 @@ }, { "kind": "ui-call", - "line": 660, + "line": 737, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "apple", @@ -10779,7 +10939,7 @@ }, { "kind": "ui-call", - "line": 700, + "line": 777, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "OpenClaw", "surface": "apple", @@ -10787,7 +10947,7 @@ }, { "kind": "ui-call", - "line": 702, + "line": 779, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Personal AI on your devices", "surface": "apple", @@ -10795,7 +10955,7 @@ }, { "kind": "ui-call", - "line": 719, + "line": 796, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "iOS", "surface": "apple", @@ -10803,7 +10963,7 @@ }, { "kind": "ui-named-argument", - "line": 724, + "line": 801, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Website", "surface": "apple", @@ -10811,7 +10971,7 @@ }, { "kind": "ui-named-argument", - "line": 729, + "line": 806, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Docs", "surface": "apple", @@ -10819,7 +10979,7 @@ }, { "kind": "ui-named-argument", - "line": 734, + "line": 811, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "GitHub", "surface": "apple", @@ -10827,7 +10987,7 @@ }, { "kind": "ui-named-argument", - "line": 739, + "line": 816, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discord", "surface": "apple", @@ -10835,7 +10995,7 @@ }, { "kind": "ui-call", - "line": 744, + "line": 821, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "apple", @@ -10843,7 +11003,7 @@ }, { "kind": "ui-call", - "line": 750, + "line": 827, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Title", "surface": "apple", @@ -10851,7 +11011,7 @@ }, { "kind": "ui-call", - "line": 785, + "line": 862, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Location", "surface": "apple", @@ -10859,7 +11019,7 @@ }, { "kind": "ui-modifier", - "line": 803, + "line": 880, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Location Sharing", "surface": "apple", @@ -10867,7 +11027,7 @@ }, { "kind": "ui-call", - "line": 814, + "line": 891, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Access Level", "surface": "apple", @@ -10875,7 +11035,7 @@ }, { "kind": "ui-modifier", - "line": 836, + "line": 913, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Chooses While Using the App or Always", "surface": "apple", @@ -10883,7 +11043,7 @@ }, { "kind": "ui-call", - "line": 856, + "line": 933, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Agent", "surface": "apple", @@ -10891,7 +11051,7 @@ }, { "kind": "ui-call", - "line": 857, + "line": 934, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default", "surface": "apple", @@ -10899,7 +11059,7 @@ }, { "kind": "ui-call", - "line": 867, + "line": 944, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Used for new Chat and Talk sessions.", "surface": "apple", @@ -10907,7 +11067,7 @@ }, { "kind": "ui-call", - "line": 874, + "line": 951, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Paste setup code", "surface": "apple", @@ -10915,7 +11075,7 @@ }, { "kind": "ui-named-argument", - "line": 880, + "line": 957, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Scan QR", "surface": "apple", @@ -10923,7 +11083,7 @@ }, { "kind": "ui-named-argument", - "line": 890, + "line": 967, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect", "surface": "apple", @@ -10931,7 +11091,7 @@ }, { "kind": "ui-call", - "line": 899, + "line": 976, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Setup Code", "surface": "apple", @@ -10939,7 +11099,7 @@ }, { "kind": "ui-call", - "line": 912, + "line": 989, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovered Gateways", "surface": "apple", @@ -10947,7 +11107,7 @@ }, { "kind": "ui-call", - "line": 914, + "line": 991, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No gateways found yet. Use manual setup if Bonjour is blocked.", "surface": "apple", @@ -10955,7 +11115,7 @@ }, { "kind": "ui-call", - "line": 928, + "line": 1005, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Pair a gateway to make it available here.", "surface": "apple", @@ -10963,7 +11123,7 @@ }, { "kind": "ui-call", - "line": 937, + "line": 1014, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Paired Gateways", "surface": "apple", @@ -10971,7 +11131,7 @@ }, { "kind": "ui-call", - "line": 940, + "line": 1017, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Switch gateways without pairing again.", "surface": "apple", @@ -10979,7 +11139,7 @@ }, { "kind": "ui-modifier", - "line": 968, + "line": 1047, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Gateway", "surface": "apple", @@ -10987,7 +11147,7 @@ }, { "kind": "ui-call", - "line": 980, + "line": 1059, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget", "surface": "apple", @@ -10995,7 +11155,7 @@ }, { "kind": "ui-call", - "line": 992, + "line": 1071, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget Gateway", "surface": "apple", @@ -11003,7 +11163,7 @@ }, { "kind": "ui-call", - "line": 1045, + "line": 1124, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Manual Gateway", "surface": "apple", @@ -11011,7 +11171,7 @@ }, { "kind": "ui-call", - "line": 1046, + "line": 1125, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Use Manual Gateway", "surface": "apple", @@ -11019,7 +11179,7 @@ }, { "kind": "ui-call", - "line": 1047, + "line": 1126, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Host", "surface": "apple", @@ -11027,7 +11187,7 @@ }, { "kind": "ui-call", - "line": 1051, + "line": 1130, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Port", "surface": "apple", @@ -11035,7 +11195,7 @@ }, { "kind": "ui-call", - "line": 1055, + "line": 1134, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Unencrypted", "surface": "apple", @@ -11043,7 +11203,7 @@ }, { "kind": "ui-call", - "line": 1058, + "line": 1137, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Secure (TLS)", "surface": "apple", @@ -11051,7 +11211,7 @@ }, { "kind": "ui-call", - "line": 1062, + "line": 1141, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connection security", "surface": "apple", @@ -11059,7 +11219,7 @@ }, { "kind": "ui-named-argument", - "line": 1073, + "line": 1152, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect Manual", "surface": "apple", @@ -11067,7 +11227,7 @@ }, { "kind": "ui-call", - "line": 1103, + "line": 1182, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Auto-connect on launch", "surface": "apple", @@ -11075,7 +11235,7 @@ }, { "kind": "ui-call", - "line": 1104, + "line": 1183, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -11083,7 +11243,7 @@ }, { "kind": "ui-call", - "line": 1105, + "line": 1184, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Password", "surface": "apple", @@ -11091,7 +11251,7 @@ }, { "kind": "ui-call", - "line": 1110, + "line": 1189, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Custom Headers", "surface": "apple", @@ -11099,7 +11259,7 @@ }, { "kind": "ui-call", - "line": 1117, + "line": 1196, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reset Onboarding", "surface": "apple", @@ -11107,7 +11267,7 @@ }, { "kind": "ui-call", - "line": 1144, + "line": 1223, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Wake", "surface": "apple", @@ -11115,7 +11275,7 @@ }, { "kind": "ui-call", - "line": 1147, + "line": 1226, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Talk Mode", "surface": "apple", @@ -11123,7 +11283,7 @@ }, { "kind": "ui-call", - "line": 1155, + "line": 1234, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speech Language", "surface": "apple", @@ -11131,7 +11291,7 @@ }, { "kind": "ui-call", - "line": 1162, + "line": 1241, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speakerphone", "surface": "apple", @@ -11139,7 +11299,7 @@ }, { "kind": "ui-call", - "line": 1166, + "line": 1245, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Wake Words", "surface": "apple", @@ -11147,7 +11307,7 @@ }, { "kind": "ui-call", - "line": 1187, + "line": 1266, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice", "surface": "apple", @@ -11155,7 +11315,7 @@ }, { "kind": "ui-call", - "line": 1188, + "line": 1267, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Provider", "surface": "apple", @@ -11163,7 +11323,7 @@ }, { "kind": "ui-call", - "line": 1195, + "line": 1274, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Realtime Voice", "surface": "apple", @@ -11171,7 +11331,7 @@ }, { "kind": "ui-call", - "line": 1196, + "line": 1275, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Default", "surface": "apple", @@ -11179,7 +11339,7 @@ }, { "kind": "ui-call", - "line": 1203, + "line": 1282, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Mode", "surface": "apple", @@ -11187,7 +11347,7 @@ }, { "kind": "ui-call", - "line": 1204, + "line": 1283, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Voice", "surface": "apple", @@ -11195,7 +11355,7 @@ }, { "kind": "ui-call", - "line": 1206, + "line": 1285, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Last Voice Issue", "surface": "apple", @@ -11203,7 +11363,7 @@ }, { "kind": "ui-call", - "line": 1208, + "line": 1287, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Transport", "surface": "apple", @@ -11211,7 +11371,7 @@ }, { "kind": "ui-call", - "line": 1209, + "line": 1288, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "API Key", "surface": "apple", @@ -11219,7 +11379,7 @@ }, { "kind": "ui-call", - "line": 1216, + "line": 1295, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Show Talk Control", "surface": "apple", @@ -11227,7 +11387,7 @@ }, { "kind": "ui-call", - "line": 1217, + "line": 1296, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Share Instruction", "surface": "apple", @@ -11235,7 +11395,7 @@ }, { "kind": "ui-call", - "line": 1224, + "line": 1303, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Share Self-Test", "surface": "apple", @@ -11243,7 +11403,7 @@ }, { "kind": "ui-call", - "line": 1241, + "line": 1320, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Debug Logs", "surface": "apple", @@ -11251,7 +11411,7 @@ }, { "kind": "ui-call", - "line": 1244, + "line": 1323, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Debug Screen Status", "surface": "apple", @@ -11259,7 +11419,7 @@ }, { "kind": "ui-call", - "line": 1248, + "line": 1327, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Logs", "surface": "apple", @@ -11267,7 +11427,7 @@ }, { "kind": "ui-call", - "line": 1254, + "line": 1333, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device", "surface": "apple", @@ -11275,7 +11435,7 @@ }, { "kind": "ui-call", - "line": 1255, + "line": 1334, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device Name", "surface": "apple", @@ -11283,7 +11443,7 @@ }, { "kind": "ui-call", - "line": 1257, + "line": 1336, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Instance ID", "surface": "apple", @@ -11291,7 +11451,7 @@ }, { "kind": "conditional-branch", - "line": 1280, + "line": 1359, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Off", "surface": "apple", @@ -11299,7 +11459,7 @@ }, { "kind": "conditional-branch", - "line": 1280, + "line": 1359, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "On", "surface": "apple", @@ -11971,7 +12131,7 @@ }, { "kind": "ui-call", - "line": 62, + "line": 110, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Exec approval required", "surface": "apple", @@ -11979,7 +12139,7 @@ }, { "kind": "ui-call", - "line": 64, + "line": 112, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Review this exec request before continuing. Your decision will be sent back to the gateway.", "surface": "apple", @@ -11987,7 +12147,7 @@ }, { "kind": "ui-named-argument", - "line": 79, + "line": 138, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Host", "surface": "apple", @@ -11995,7 +12155,7 @@ }, { "kind": "ui-named-argument", - "line": 82, + "line": 141, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Node", "surface": "apple", @@ -12003,7 +12163,7 @@ }, { "kind": "ui-named-argument", - "line": 85, + "line": 144, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Agent", "surface": "apple", @@ -12011,7 +12171,7 @@ }, { "kind": "ui-named-argument", - "line": 88, + "line": 147, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Expires", "surface": "apple", @@ -12019,7 +12179,7 @@ }, { "kind": "ui-call", - "line": 102, + "line": 167, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Resolving…", "surface": "apple", @@ -12027,7 +12187,7 @@ }, { "kind": "ui-call", - "line": 112, + "line": 182, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Allow Once", "surface": "apple", @@ -12035,7 +12195,7 @@ }, { "kind": "ui-call", - "line": 123, + "line": 194, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Allow Always", "surface": "apple", @@ -12043,7 +12203,15 @@ }, { "kind": "ui-call", - "line": 135, + "line": 221, + "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", + "source": "Dismiss", + "surface": "apple", + "id": "native.apple.528a2fce8714d569" + }, + { + "kind": "ui-call", + "line": 236, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Deny", "surface": "apple", @@ -12051,7 +12219,7 @@ }, { "kind": "ui-call", - "line": 145, + "line": 248, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "Cancel", "surface": "apple", @@ -12059,7 +12227,7 @@ }, { "kind": "conditional-branch", - "line": 176, + "line": 285, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "about 1 minute", "surface": "apple", @@ -12067,7 +12235,7 @@ }, { "kind": "conditional-branch", - "line": 176, + "line": 285, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "about \\(minutes) minutes", "surface": "apple", @@ -12075,7 +12243,7 @@ }, { "kind": "conditional-branch", - "line": 179, + "line": 288, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "about 1 hour", "surface": "apple", @@ -12083,7 +12251,7 @@ }, { "kind": "conditional-branch", - "line": 179, + "line": 288, "path": "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift", "source": "about \\(hours) hours", "surface": "apple", @@ -12115,7 +12283,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1264, + "line": 1284, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Can't reach gateway at \\(host):\\(port). Verify Tailscale Serve is enabled and publishes this Gateway.", "surface": "apple", @@ -12123,7 +12291,7 @@ }, { "kind": "ui-localized-call", - "line": 1269, + "line": 1289, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Can't reach gateway at \\(host):\\(port). Check Tailscale or LAN.", "surface": "apple", @@ -12827,7 +12995,7 @@ }, { "kind": "conditional-branch", - "line": 3237, + "line": 3755, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -12835,7 +13003,7 @@ }, { "kind": "conditional-branch", - "line": 3237, + "line": 3755, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -12843,7 +13011,7 @@ }, { "kind": "conditional-branch", - "line": 3884, + "line": 4416, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -12851,7 +13019,7 @@ }, { "kind": "conditional-branch", - "line": 3884, + "line": 4416, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -12859,7 +13027,7 @@ }, { "kind": "conditional-branch", - "line": 3888, + "line": 4420, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -12867,7 +13035,7 @@ }, { "kind": "conditional-branch", - "line": 3888, + "line": 4420, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -12875,7 +13043,7 @@ }, { "kind": "conditional-branch", - "line": 4207, + "line": 4739, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -12883,12 +13051,44 @@ }, { "kind": "conditional-branch", - "line": 4207, + "line": 4739, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", "id": "native.apple.ae33908b61c6d70b" }, + { + "kind": "conditional-branch", + "line": 8216, + "path": "apps/ios/Sources/Model/NodeAppModel.swift", + "source": "Approval", + "surface": "apple", + "id": "native.apple.0b1eff808df04e2b" + }, + { + "kind": "conditional-branch", + "line": 8216, + "path": "apps/ios/Sources/Model/NodeAppModel.swift", + "source": "This approval was already", + "surface": "apple", + "id": "native.apple.c74dccc1c5d71750" + }, + { + "kind": "conditional-branch", + "line": 8222, + "path": "apps/ios/Sources/Model/NodeAppModel.swift", + "source": "Approval set to Always Allow.", + "surface": "apple", + "id": "native.apple.4864b3b8c6ed7158" + }, + { + "kind": "conditional-branch", + "line": 8222, + "path": "apps/ios/Sources/Model/NodeAppModel.swift", + "source": "This approval was already set to Always Allow.", + "surface": "apple", + "id": "native.apple.4e3a9dc13016600b" + }, { "kind": "conditional-branch", "line": 10, @@ -13595,7 +13795,7 @@ }, { "kind": "ui-call", - "line": 178, + "line": 179, "path": "apps/ios/Sources/RootTabs.swift", "source": "Talk", "surface": "apple", @@ -13603,7 +13803,7 @@ }, { "kind": "ui-call", - "line": 191, + "line": 192, "path": "apps/ios/Sources/RootTabs.swift", "source": "Control", "surface": "apple", @@ -13611,7 +13811,7 @@ }, { "kind": "ui-call", - "line": 200, + "line": 201, "path": "apps/ios/Sources/RootTabs.swift", "source": "Agent", "surface": "apple", @@ -13619,7 +13819,7 @@ }, { "kind": "ui-call", - "line": 212, + "line": 214, "path": "apps/ios/Sources/RootTabs.swift", "source": "Settings", "surface": "apple", @@ -13627,7 +13827,7 @@ }, { "kind": "ui-call", - "line": 311, + "line": 313, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw", "surface": "apple", @@ -13635,7 +13835,7 @@ }, { "kind": "ui-modifier", - "line": 341, + "line": 343, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw \\(self.sidebarGatewayStatusTitle)", "surface": "apple", @@ -13643,7 +13843,7 @@ }, { "kind": "conditional-branch", - "line": 346, + "line": 348, "path": "apps/ios/Sources/RootTabs.swift", "source": "Online", "surface": "apple", @@ -13651,7 +13851,7 @@ }, { "kind": "conditional-branch", - "line": 348, + "line": 350, "path": "apps/ios/Sources/RootTabs.swift", "source": "Connecting", "surface": "apple", @@ -13659,7 +13859,7 @@ }, { "kind": "conditional-branch", - "line": 350, + "line": 352, "path": "apps/ios/Sources/RootTabs.swift", "source": "Needs attention", "surface": "apple", @@ -13667,7 +13867,7 @@ }, { "kind": "conditional-branch", - "line": 352, + "line": 354, "path": "apps/ios/Sources/RootTabs.swift", "source": "Offline", "surface": "apple", @@ -13675,7 +13875,7 @@ }, { "kind": "ui-named-argument", - "line": 432, + "line": 434, "path": "apps/ios/Sources/RootTabs.swift", "source": "Chat", "surface": "apple", @@ -13683,7 +13883,7 @@ }, { "kind": "ui-named-argument", - "line": 445, + "line": 447, "path": "apps/ios/Sources/RootTabs.swift", "source": "Overview", "surface": "apple", @@ -13691,7 +13891,7 @@ }, { "kind": "ui-named-argument", - "line": 469, + "line": 471, "path": "apps/ios/Sources/RootTabs.swift", "source": "Agents", "surface": "apple", @@ -13699,7 +13899,7 @@ }, { "kind": "ui-named-argument", - "line": 476, + "line": 478, "path": "apps/ios/Sources/RootTabs.swift", "source": "Instances", "surface": "apple", @@ -13707,7 +13907,7 @@ }, { "kind": "ui-named-argument", - "line": 487, + "line": 489, "path": "apps/ios/Sources/RootTabs.swift", "source": "Files", "surface": "apple", @@ -13715,7 +13915,7 @@ }, { "kind": "ui-named-argument", - "line": 494, + "line": 496, "path": "apps/ios/Sources/RootTabs.swift", "source": "Dreaming", "surface": "apple", @@ -13723,7 +13923,7 @@ }, { "kind": "ui-named-argument", - "line": 501, + "line": 503, "path": "apps/ios/Sources/RootTabs.swift", "source": "Usage", "surface": "apple", @@ -13731,7 +13931,7 @@ }, { "kind": "ui-named-argument", - "line": 508, + "line": 510, "path": "apps/ios/Sources/RootTabs.swift", "source": "Cron Jobs", "surface": "apple", @@ -13739,7 +13939,7 @@ }, { "kind": "ui-modifier", - "line": 641, + "line": 656, "path": "apps/ios/Sources/RootTabs.swift", "source": "Hide Sidebar", "surface": "apple", @@ -13747,7 +13947,7 @@ }, { "kind": "ui-modifier", - "line": 768, + "line": 783, "path": "apps/ios/Sources/RootTabs.swift", "source": "Close canvas", "surface": "apple", @@ -13755,7 +13955,7 @@ }, { "kind": "conditional-branch", - "line": 1022, + "line": 1037, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway needs attention", "surface": "apple", @@ -13763,7 +13963,7 @@ }, { "kind": "conditional-branch", - "line": 1022, + "line": 1037, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw iOS", "surface": "apple", @@ -13771,7 +13971,7 @@ }, { "kind": "conditional-branch", - "line": 1058, + "line": 1073, "path": "apps/ios/Sources/RootTabs.swift", "source": "Available", "surface": "apple", @@ -13779,7 +13979,7 @@ }, { "kind": "conditional-branch", - "line": 1058, + "line": 1073, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway default", "surface": "apple", @@ -14593,9 +14793,17 @@ "surface": "apple", "id": "native.apple.d740d67943737c80" }, + { + "kind": "ui-modifier", + "line": 269, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Opens the full command before decisions are available", + "surface": "apple", + "id": "native.apple.3472ab6e7b81a746" + }, { "kind": "ui-named-argument", - "line": 286, + "line": 279, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Review again", "surface": "apple", @@ -14603,7 +14811,7 @@ }, { "kind": "ui-named-argument", - "line": 296, + "line": 289, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Open all approvals", "surface": "apple", @@ -14611,7 +14819,7 @@ }, { "kind": "conditional-branch", - "line": 308, + "line": 301, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Direct Gateway", "surface": "apple", @@ -14619,7 +14827,7 @@ }, { "kind": "conditional-branch", - "line": 308, + "line": 301, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Watch node online", "surface": "apple", @@ -14627,7 +14835,7 @@ }, { "kind": "conditional-branch", - "line": 315, + "line": 308, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Direct", "surface": "apple", @@ -14635,7 +14843,7 @@ }, { "kind": "conditional-branch", - "line": 315, + "line": 308, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Setup", "surface": "apple", @@ -14643,7 +14851,7 @@ }, { "kind": "conditional-branch", - "line": 318, + "line": 311, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Open iPhone Settings → Apple Watch", "surface": "apple", @@ -14651,7 +14859,7 @@ }, { "kind": "conditional-branch", - "line": 318, + "line": 311, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Uses Wi-Fi or cellular while OpenClaw is active", "surface": "apple", @@ -14659,7 +14867,7 @@ }, { "kind": "conditional-branch", - "line": 320, + "line": 313, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Offline", "surface": "apple", @@ -14667,7 +14875,7 @@ }, { "kind": "conditional-branch", - "line": 320, + "line": 313, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Online", "surface": "apple", @@ -14675,7 +14883,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 323, + "line": 316, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Direct mode supports device info, status, and notifications. Chat, Talk, and approvals still use the iPhone.", "surface": "apple", @@ -14683,7 +14891,7 @@ }, { "kind": "ui-call", - "line": 333, + "line": 326, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Direct connection", "surface": "apple", @@ -14691,7 +14899,7 @@ }, { "kind": "ui-named-argument", - "line": 339, + "line": 332, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Forget direct setup", "surface": "apple", @@ -14699,7 +14907,7 @@ }, { "kind": "ui-named-argument", - "line": 344, + "line": 337, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "The iPhone securely sends a one-time setup code. Existing relay features stay available.", "surface": "apple", @@ -14707,7 +14915,7 @@ }, { "kind": "conditional-branch", - "line": 386, + "line": 379, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.chatCount)", "surface": "apple", @@ -14715,7 +14923,7 @@ }, { "kind": "conditional-branch", - "line": 390, + "line": 383, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.approvalCount)", "surface": "apple", @@ -14723,7 +14931,7 @@ }, { "kind": "conditional-branch", - "line": 395, + "line": 388, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "AI agent online", "surface": "apple", @@ -14731,7 +14939,7 @@ }, { "kind": "conditional-branch", - "line": 395, + "line": 388, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Reconnect on iPhone", "surface": "apple", @@ -14739,7 +14947,7 @@ }, { "kind": "conditional-branch", - "line": 402, + "line": 395, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Pairing", "surface": "apple", @@ -14747,7 +14955,7 @@ }, { "kind": "conditional-branch", - "line": 402, + "line": 395, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Running", "surface": "apple", @@ -14755,7 +14963,7 @@ }, { "kind": "conditional-branch", - "line": 422, + "line": 415, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Ready for quick actions", "surface": "apple", @@ -14763,7 +14971,7 @@ }, { "kind": "conditional-branch", - "line": 422, + "line": 415, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Waiting for iPhone sync", "surface": "apple", @@ -14771,7 +14979,7 @@ }, { "kind": "conditional-branch", - "line": 426, + "line": 419, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "1 approval waiting", "surface": "apple", @@ -14779,7 +14987,7 @@ }, { "kind": "conditional-branch", - "line": 426, + "line": 419, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.approvalCount) approvals", "surface": "apple", @@ -14787,7 +14995,7 @@ }, { "kind": "conditional-branch", - "line": 435, + "line": 428, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Decide from watch", "surface": "apple", @@ -14795,7 +15003,7 @@ }, { "kind": "conditional-branch", - "line": 435, + "line": 428, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No approvals", "surface": "apple", @@ -14803,7 +15011,7 @@ }, { "kind": "conditional-branch", - "line": 489, + "line": 482, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "1 recent message", "surface": "apple", @@ -14811,7 +15019,7 @@ }, { "kind": "conditional-branch", - "line": 489, + "line": 482, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "\\(self.chatCount) recent messages", "surface": "apple", @@ -14819,7 +15027,7 @@ }, { "kind": "conditional-branch", - "line": 491, + "line": 484, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No messages synced", "surface": "apple", @@ -14827,7 +15035,7 @@ }, { "kind": "conditional-branch", - "line": 521, + "line": 514, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Synced", "surface": "apple", @@ -14835,7 +15043,7 @@ }, { "kind": "conditional-branch", - "line": 521, + "line": 514, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Waiting for iPhone", "surface": "apple", @@ -14843,7 +15051,7 @@ }, { "kind": "ui-named-argument", - "line": 787, + "line": 780, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Inbox", "surface": "apple", @@ -14851,15 +15059,31 @@ }, { "kind": "ui-named-argument", - "line": 963, + "line": 956, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "OpenClaw", "surface": "apple", "id": "native.apple.3fdc651e507de84b" }, + { + "kind": "ui-call", + "line": 1016, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Command", + "surface": "apple", + "id": "native.apple.a4e70d96cf65b2b2" + }, + { + "kind": "ui-modifier", + "line": 1035, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Command to review", + "surface": "apple", + "id": "native.apple.5aeafce9c079fcb8" + }, { "kind": "conditional-branch", - "line": 1052, + "line": 1102, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "You", "surface": "apple", @@ -14867,7 +15091,7 @@ }, { "kind": "conditional-branch", - "line": 1054, + "line": 1104, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "System", "surface": "apple", @@ -14875,7 +15099,7 @@ }, { "kind": "ui-named-argument", - "line": 1107, + "line": 1157, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Refresh", "surface": "apple", @@ -14883,7 +15107,7 @@ }, { "kind": "ui-call", - "line": 1202, + "line": 1252, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No chat synced", "surface": "apple", @@ -14891,7 +15115,7 @@ }, { "kind": "ui-call", - "line": 1209, + "line": 1259, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Tap the message pill below to start from your watch.", "surface": "apple", @@ -14899,7 +15123,7 @@ }, { "kind": "ui-call", - "line": 1257, + "line": 1307, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Message OpenClaw", "surface": "apple", @@ -14907,7 +15131,7 @@ }, { "kind": "ui-named-argument", - "line": 1360, + "line": 1410, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approvals", "surface": "apple", @@ -14915,7 +15139,7 @@ }, { "kind": "ui-named-argument", - "line": 1363, + "line": 1413, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Clear", "surface": "apple", @@ -14923,7 +15147,7 @@ }, { "kind": "ui-named-argument", - "line": 1364, + "line": 1414, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No approvals waiting", "surface": "apple", @@ -14931,7 +15155,7 @@ }, { "kind": "ui-named-argument", - "line": 1424, + "line": 1426, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approval", "surface": "apple", @@ -14939,23 +15163,31 @@ }, { "kind": "ui-named-argument", - "line": 1438, + "line": 1474, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", - "source": "Sending decision...", + "source": "Review Command", "surface": "apple", - "id": "native.apple.24aa28fc8c954704" + "id": "native.apple.507b63347d559665" }, { "kind": "ui-named-argument", - "line": 1442, + "line": 1477, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", - "source": "Approve", + "source": "Command execution", "surface": "apple", - "id": "native.apple.b7b88bee2f79f10e" + "id": "native.apple.cd22b958c904f66f" }, { "kind": "ui-named-argument", - "line": 1451, + "line": 1498, + "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", + "source": "Allow Once", + "surface": "apple", + "id": "native.apple.82ee6f7c58b18b55" + }, + { + "kind": "ui-named-argument", + "line": 1507, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Deny", "surface": "apple", diff --git a/apps/android/CHANGELOG.md b/apps/android/CHANGELOG.md index 58786bdc1f62..a782cbbe900f 100644 --- a/apps/android/CHANGELOG.md +++ b/apps/android/CHANGELOG.md @@ -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. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt b/apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt index 3d497813d7a6..f1397f79ed9d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt @@ -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, 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): 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?, +): 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? { + 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 { - 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 = - 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): Boolean = keys == expected + +private fun JsonObject.hasOnlyKeys(allowed: Set): 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", + ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index c3750ede9e17..058c9c0419c2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -395,6 +395,7 @@ class MainViewModel( val execApprovals: StateFlow> = runtimeState(initial = emptyList()) { it.execApprovals } val execApprovalsRefreshing: StateFlow = runtimeState(initial = false) { it.execApprovalsRefreshing } val execApprovalsErrorText: StateFlow = runtimeState(initial = null) { it.execApprovalsErrorText } + val execApprovalsNotice: StateFlow = 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() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 6f66d68a6b60..c079f184498f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -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 = _execApprovalsRefreshing.asStateFlow() private val _execApprovalsErrorText = MutableStateFlow(null) val execApprovalsErrorText: StateFlow = _execApprovalsErrorText.asStateFlow() + private val _execApprovalsNotice = MutableStateFlow(null) + val execApprovalsNotice: StateFlow = _execApprovalsNotice.asStateFlow() private val execApprovalsRefreshSeq = AtomicLong(0) private val execApprovalsStateLock = Any() private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap()) + private val pendingExecApprovalWrites = mutableMapOf() + + // 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 = _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() 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) { + 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, + terminalApprovals: List, ) { 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) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 55bb6956bfb0..f7dd0e367628 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -110,6 +110,7 @@ data class GatewayHelloSummary( val updateAvailable: GatewayUpdateAvailableSummary?, val authRole: String? = null, val authScopes: List = emptyList(), + val methods: Set = 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, ), ) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index ad27c1eae16d..08d97b82803e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -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): List = + 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) { ClawListPanel(items = toolCalls) { toolCall -> diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt index 132714dea502..5fcd1bcdf7b2 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalParsingTest.kt @@ -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(), 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> = + 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() } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt new file mode 100644 index 000000000000..edc3692e77d3 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayExecApprovalRuntimeTest.kt @@ -0,0 +1,1321 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayConnectErrorDetails +import ai.openclaw.app.gateway.GatewayEndpoint +import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GatewayExecApprovalRuntimeTest { + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun anotherSurfaceWinnerClosesLocalCardFromCanonicalResolveResult() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val requests = mutableListOf>() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + requests += method to params + check(method == "approval.resolve") + unifiedResolve(applied = false, status = "denied", decision = "deny") + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals(listOf("approval.resolve"), requests.map { it.first }) + assertEquals( + """{"id":"approval-1","kind":"exec","decision":"allow-once"}""", + requests.single().second, + ) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun exactApprovalIdsCannotCrossTargetThroughKotlinWhitespaceNormalization() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + val controlPrefixedId = "\u001Capproval-1" + seedApprovals( + runtime, + listOf( + approvalSummary(id = controlPrefixedId, commandText = "echo selected"), + approvalSummary(id = "approval-1", commandText = "echo other"), + ), + ) + val requestParams = CompletableDeferred() + val requestCount = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + check(method == "approval.resolve") + requestCount.incrementAndGet() + requestParams.complete(requireNotNull(params)) + unifiedResolve( + applied = true, + status = "denied", + decision = "deny", + id = controlPrefixedId, + ) + } + + runtime.resolveExecApproval(".", "deny") + delay(50) + assertFalse(requestParams.isCompleted) + + runtime.resolveExecApproval(controlPrefixedId, "deny") + val params = Json.parseToJsonElement(withTimeout(2_000) { requestParams.await() }).jsonObject + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-1") } + + assertEquals(1, requestCount.get()) + assertEquals(controlPrefixedId, params["id"]?.jsonPrimitive?.content) + assertEquals( + "approval-1", + runtime.execApprovals.value + .single() + .id, + ) + } + + @Test + fun approvalEventsPreserveExactStringIdsAndRejectNonStrings() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + val controlPrefixedId = "\u001Capproval-1" + val requestedIds = mutableListOf() + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + methods += method + when (method) { + "approval.get" -> { + val parsed = Json.parseToJsonElement(requireNotNull(params)).jsonObject + requestedIds += requireNotNull(parsed["id"]?.jsonPrimitive?.content) + unifiedGet(status = "pending", decision = null, id = controlPrefixedId) + } + "exec.approval.list" -> "[]" + else -> error("unexpected method $method") + } + } + + invokeApprovalEvent( + runtime, + "exec.approval.requested", + """{"id":${JsonPrimitive(controlPrefixedId)}}""", + ) + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.id == controlPrefixedId + } + invokeApprovalEvent(runtime, "exec.approval.requested", """{"id":123}""") + waitUntil { methods.contains("exec.approval.list") } + + assertEquals(listOf(controlPrefixedId), requestedIds) + } + + @Test + fun malformedOrMismatchedWriteResultFreezesThenUsesCanonicalReadback() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + // `applied=true` cannot claim a different decision than this phone sent. + "approval.resolve" -> unifiedResolve(applied = true, status = "allowed", decision = "allow-always") + "approval.get" -> unifiedGet(status = "allowed", decision = "allow-always") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals(listOf("approval.resolve", "approval.get"), methods) + assertEquals( + "A prior response already allowed this command and saved the choice.", + runtime.execApprovalsNotice.value?.message, + ) + } + + @Test + fun unknownWriteOutcomeStaysFrozenAndReconcilesAfterReconnect() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve", "approval.get" -> throw GatewayRequestOutcomeUnknown("disconnected") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText + ?.startsWith("Resolution outcome unknown") == true + } + val frozen = runtime.execApprovals.value.single() + assertEquals("deny", frozen.resolvingDecision) + + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, unifiedMethods) + val reconnectMethods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + reconnectMethods += method + when (method) { + "exec.approval.list" -> "[]" + "approval.get" -> unifiedGet(status = "denied", decision = "deny") + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { reconnectMethods.contains("approval.get") && runtime.execApprovalsNotice.value != null } + + assertTrue(runtime.execApprovals.value.isEmpty()) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun reconnectListHydrationPublishesTerminalForRetainedUnknownWrite() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve", "approval.get" -> throw GatewayRequestOutcomeUnknown("disconnected") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText + ?.startsWith("Resolution outcome unknown") == true + } + + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, unifiedMethods) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> unifiedGet(status = "denied", decision = "deny") + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { runtime.execApprovalsNotice.value != null } + + assertTrue(runtime.execApprovals.value.isEmpty()) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun reconnectKeepsInFlightWriteDisabledBeforeRetiredWaiterFails() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val resolveStarted = CompletableDeferred() + val releaseUnknownOutcome = CompletableDeferred() + val pendingReadCompleted = CompletableDeferred() + val winnerReadStarted = CompletableDeferred() + val releaseWinnerRead = CompletableDeferred() + val approvalReads = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> { + resolveStarted.complete(Unit) + releaseUnknownOutcome.await() + throw GatewayRequestOutcomeUnknown("disconnected before response") + } + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> { + if (approvalReads.incrementAndGet() == 1) { + pendingReadCompleted.complete(Unit) + unifiedGet(status = "pending", decision = null) + } else { + winnerReadStarted.complete(Unit) + releaseWinnerRead.await() + unifiedGet(status = "denied", decision = "deny") + } + } + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(2_000) { resolveStarted.await() } + + // GatewaySession runs onDisconnected before failing the retired socket's + // request waiters. Recreate that production ordering on the same stable ID. + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, unifiedMethods) + runtime.refreshExecApprovals() + withTimeout(2_000) { pendingReadCompleted.await() } + waitUntil { !runtime.execApprovalsRefreshing.value } + + val reconnected = runtime.execApprovals.value.single() + assertEquals("deny", reconnected.resolvingDecision) + assertTrue(reconnected.errorText?.startsWith("Resolution outcome unknown") == true) + assertFalse(releaseUnknownOutcome.isCompleted) + assertFalse(winnerReadStarted.isCompleted) + + releaseUnknownOutcome.complete(Unit) + withTimeout(2_000) { winnerReadStarted.await() } + releaseWinnerRead.complete(Unit) + + waitUntil { runtime.execApprovals.value.isEmpty() } + assertTrue(approvalReads.get() >= 2) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun fullRefreshCannotUnlockApprovalWhileResolveRequestIsInFlight() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + val refreshReadCompleted = CompletableDeferred() + val approvalReads = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + unifiedResolve(applied = true, status = "denied", decision = "deny") + } + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> { + approvalReads.incrementAndGet() + refreshReadCompleted.complete(Unit) + unifiedGet(status = "pending", decision = null) + } + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(2_000) { resolveStarted.await() } + runtime.refreshExecApprovals() + withTimeout(2_000) { refreshReadCompleted.await() } + waitUntil { !runtime.execApprovalsRefreshing.value } + delay(100) + + val inFlight = runtime.execApprovals.value.single() + assertEquals("deny", inFlight.resolvingDecision) + assertNull(inFlight.errorText) + assertEquals(1, approvalReads.get()) + + releaseResolve.complete(Unit) + waitUntil { runtime.execApprovals.value.isEmpty() } + assertEquals("Approval denied.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun unknownWriteInvalidatesRefreshSnapshotBuiltWhileRequestWasInFlight() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo selected"), + approvalSummary(id = "approval-2", commandText = "echo retained"), + ), + ) + val resolveStarted = CompletableDeferred() + val releaseUnknownOutcome = CompletableDeferred() + val retainedReadStarted = CompletableDeferred() + val releaseRetainedRead = CompletableDeferred() + val retainedReadReturning = CompletableDeferred() + val selectedReads = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + when (method) { + "approval.resolve" -> { + resolveStarted.complete(Unit) + releaseUnknownOutcome.await() + throw GatewayRequestOutcomeUnknown("response lost") + } + "exec.approval.list" -> + """ + [ + {"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}, + {"id":"approval-2","createdAtMs":101,"expiresAtMs":4000000000000} + ] + """.trimIndent() + "approval.get" -> { + val id = + Json + .parseToJsonElement(requireNotNull(params)) + .jsonObject["id"] + ?.jsonPrimitive + ?.content + ?: error("missing approval id") + if (id == "approval-2") { + retainedReadStarted.complete(Unit) + releaseRetainedRead.await() + retainedReadReturning.complete(Unit) + unifiedGet(status = "pending", decision = null, id = id) + } else if (selectedReads.incrementAndGet() == 1) { + unifiedGet(status = "pending", decision = null, id = id) + } else { + throw GatewayRequestOutcomeUnknown("readback unavailable") + } + } + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(2_000) { resolveStarted.await() } + runtime.refreshExecApprovals() + withTimeout(2_000) { retainedReadStarted.await() } + + releaseUnknownOutcome.complete(Unit) + waitUntil { + runtime.execApprovals.value + .firstOrNull { it.id == "approval-1" } + ?.errorText + ?.startsWith("Resolution outcome unknown") == true + } + + releaseRetainedRead.complete(Unit) + withTimeout(2_000) { retainedReadReturning.await() } + delay(100) + + val selected = runtime.execApprovals.value.first { it.id == "approval-1" } + assertEquals("deny", selected.resolvingDecision) + assertTrue(selected.errorText?.startsWith("Resolution outcome unknown") == true) + assertTrue(runtime.execApprovals.value.any { it.id == "approval-2" }) + assertTrue(selectedReads.get() >= 2) + } + + @Test + fun canonicalPendingReadbackInvalidatesConcurrentStaleRefresh() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val pendingReadStarted = CompletableDeferred() + val staleRefreshReadStarted = CompletableDeferred() + val releasePendingRead = CompletableDeferred() + val releaseStaleRefreshRead = CompletableDeferred() + val staleRefreshResponseReturning = CompletableDeferred() + val approvalReads = AtomicInteger() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> throw GatewayRequestOutcomeUnknown("response lost") + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> + when (approvalReads.incrementAndGet()) { + 1 -> { + pendingReadStarted.complete(Unit) + releasePendingRead.await() + unifiedGet(status = "pending", decision = null) + } + 2 -> { + staleRefreshReadStarted.complete(Unit) + releaseStaleRefreshRead.await() + staleRefreshResponseReturning.complete(Unit) + unifiedGet(status = "pending", decision = null) + } + else -> error("unexpected extra approval.get") + } + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(2_000) { pendingReadStarted.await() } + runtime.refreshExecApprovals() + withTimeout(2_000) { staleRefreshReadStarted.await() } + + releasePendingRead.complete(Unit) + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.resolvingDecision == null && + row.errorText == "The Gateway still shows this approval as pending. Review it before trying again." + } == true + } + + releaseStaleRefreshRead.complete(Unit) + withTimeout(2_000) { staleRefreshResponseReturning.await() } + delay(100) + + val finalRow = runtime.execApprovals.value.single() + assertNull(finalRow.resolvingDecision) + assertEquals( + "The Gateway still shows this approval as pending. Review it before trying again.", + finalRow.errorText, + ) + assertEquals(2, approvalReads.get()) + } + + @Test + fun legacyUnknownWriteUnlocksAfterReconnectProvesApprovalStillPending() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "exec.approval.resolve", "exec.approval.get" -> throw GatewayRequestOutcomeUnknown("disconnected") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText + ?.startsWith("Resolution outcome unknown") == true + } + assertEquals( + "deny", + runtime.execApprovals.value + .single() + .resolvingDecision, + ) + + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, legacyMethods) + val reconnectMethods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + reconnectMethods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "exec.approval.get" -> legacyGet() + "exec.approval.resolve" -> """{"ok":true}""" + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.resolvingDecision == null && + row.errorText == "The Gateway still shows this approval as pending. Review it before trying again." + } == true + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals( + listOf( + "exec.approval.list", + "exec.approval.get", + "exec.approval.get", + "exec.approval.resolve", + ), + reconnectMethods, + ) + } + + @Test + fun legacySuccessUsesNeutralWinnerAttribution() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + check(method == "exec.approval.resolve") + """{"ok":true}""" + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals("Gateway recorded approval once.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun resolutionEventWinsRaceAgainstLateLocalResponse() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + unifiedResolve(applied = true, status = "allowed", decision = "allow-once") + } + "approval.get" -> unifiedGet(status = "denied", decision = "deny") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + withTimeout(2_000) { resolveStarted.await() } + invokeApprovalEvent( + runtime, + "exec.approval.resolved", + """{"id":"approval-1","decision":"deny","resolvedBy":"other","ts":150}""", + ) + waitUntil { runtime.execApprovals.value.isEmpty() } + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + + releaseResolve.complete(Unit) + delay(100) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun legacyResolutionEventWinsBeforeLateResponseAndListFailure() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo selected"), + approvalSummary(id = "approval-2", commandText = "echo retained"), + ), + ) + val methods = mutableListOf() + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + """{"ok":true}""" + } + "exec.approval.list", "exec.approval.get" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "UNAVAILABLE", + message = "$method failed", + ), + ) + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + withTimeout(2_000) { resolveStarted.await() } + invokeApprovalEvent( + runtime, + "exec.approval.resolved", + """{"id":"approval-1","decision":"deny","resolvedBy":"other","ts":150,"request":{}}""", + ) + + assertEquals(listOf("approval-2"), runtime.execApprovals.value.map { it.id }) + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + assertEquals(listOf("exec.approval.resolve"), methods) + + releaseResolve.complete(Unit) + delay(100) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + + runtime.refreshExecApprovals() + waitUntil { runtime.execApprovalsErrorText.value != null && !runtime.execApprovalsRefreshing.value } + + assertEquals(listOf("exec.approval.resolve", "exec.approval.list"), methods) + assertEquals(listOf("approval-2"), runtime.execApprovals.value.map { it.id }) + } + + @Test + fun legacyAlreadyResolvedRejectionRetiresExactCardWithoutEvent() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo selected"), + approvalSummary(id = "approval-2", commandText = "echo retryable"), + ), + ) + val resolvedIds = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + check(method == "exec.approval.resolve") + val request = Json.parseToJsonElement(requireNotNull(params)).jsonObject + val id = + request["id"] + ?.jsonPrimitive + ?.content + ?: error("missing approval id") + resolvedIds += id + val reason = if (id == "approval-1") "APPROVAL_ALREADY_RESOLVED" else "OTHER_REJECTION" + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "approval rejected", + details = gatewayErrorDetails(reason), + ), + ) + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-2") } + + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + assertEquals("A prior response already resolved this approval.", runtime.execApprovalsNotice.value?.message) + assertTrue(runtime.execApprovalsNotice.value?.warning == true) + + runtime.resolveExecApproval("approval-2", "deny") + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.id == "approval-2" && + row.resolvingDecision == null && + row.errorText == "Could not resolve approval. Refresh and try again." + } == true + } + + assertEquals(listOf("approval-1", "approval-2"), resolvedIds) + } + + @Test + fun legacyAlreadyResolvedRacingMethodsEpochBumpReconcilesWrite() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, legacyMethods) + seedApproval(runtime) + val resolveStarted = CompletableDeferred() + val releaseResolve = CompletableDeferred() + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.resolve" -> { + resolveStarted.complete(Unit) + releaseResolve.await() + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "approval rejected", + details = gatewayErrorDetails("APPROVAL_ALREADY_RESOLVED"), + ), + ) + } + "exec.approval.get" -> legacyGet() + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + withTimeout(2_000) { resolveStarted.await() } + // Replacement hello on the same stable endpoint: the epoch bump makes the + // already-resolved publish a no-op, leaving only the pending-write record. + invokeReplaceGatewayMethods(runtime, legacyMethods) + releaseResolve.complete(Unit) + + // The settled rejection must reconcile through current canonical state instead + // of freezing until a perfectly timed manual refresh. + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.resolvingDecision == null && + row.errorText == "The Gateway still shows this approval as pending. Review it before trying again." + } == true + } + + assertEquals(listOf("exec.approval.resolve", "exec.approval.get"), methods) + } + + @Test + fun terminalNoticeSurvivesRefreshUntilUserDismissal() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo losing"), + approvalSummary(id = "approval-2", commandText = "echo retained"), + ), + ) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + check(method == "approval.resolve") + unifiedResolve(applied = false, status = "denied", decision = "deny") + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-2") } + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + + val refreshMethods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + refreshMethods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-2","createdAtMs":101,"expiresAtMs":4000000000000}]""" + "approval.get" -> unifiedGet(status = "pending", decision = null, id = "approval-2") + else -> error("unexpected method $method") + } + } + runtime.refreshExecApprovals() + waitUntil { refreshMethods.contains("approval.get") && !runtime.execApprovalsRefreshing.value } + assertEquals(listOf("approval-2"), runtime.execApprovals.value.map { it.id }) + + // A refresh must not wipe an unacknowledged losing outcome; only the user (or a + // replacement terminal notice) clears the banner. + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + + runtime.dismissExecApprovalsNotice(requireNotNull(runtime.execApprovalsNotice.value)) + assertNull(runtime.execApprovalsNotice.value) + } + + @Test + fun unrelatedApprovalWriteKeepsUnacknowledgedTerminalNotice() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo losing"), + approvalSummary(id = "approval-2", commandText = "echo unrelated"), + ), + ) + runtime.gatewayDataRequestOverrideForTests = { _, method, params -> + check(method == "approval.resolve") + val request = Json.parseToJsonElement(requireNotNull(params)).jsonObject + when (val id = request["id"]?.jsonPrimitive?.content) { + "approval-1" -> unifiedResolve(applied = false, status = "denied", decision = "deny") + "approval-2" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape(code = "UNAVAILABLE", message = "resolve failed"), + ) + else -> error("unexpected approval id $id") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-2") } + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + + runtime.resolveExecApproval("approval-2", "deny") + waitUntil { + runtime.execApprovals.value.singleOrNull()?.let { row -> + row.id == "approval-2" && + row.resolvingDecision == null && + row.errorText == "Could not resolve approval. Refresh and try again." + } == true + } + + // Starting (and failing) a write for approval-2 must not clear the unacknowledged + // losing outcome for approval-1; only the user or a replacement terminal clears it. + assertEquals("approval-1", runtime.execApprovalsNotice.value?.approvalId) + assertEquals("A prior response already denied this approval.", runtime.execApprovalsNotice.value?.message) + } + + @Test + fun staleDismissLeavesReplacementNoticeVisible() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApprovals( + runtime, + listOf( + approvalSummary(id = "approval-1", commandText = "echo first"), + approvalSummary(id = "approval-2", commandText = "echo second"), + ), + ) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> unifiedResolve(applied = false, status = "denied", decision = "deny") + // Readback for the approval-2 resolved event: this terminal-notice publisher + // does not hold execApprovalsStateLock, the exact writer the atomic dismiss + // must not race. + "approval.get" -> unifiedGet(status = "denied", decision = "deny", id = "approval-2") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-2") } + val staleNotice = requireNotNull(runtime.execApprovalsNotice.value) + assertEquals("approval-1", staleNotice.approvalId) + + invokeApprovalEvent(runtime, "exec.approval.resolved", """{"id":"approval-2"}""") + waitUntil { runtime.execApprovals.value.isEmpty() } + val replacement = requireNotNull(runtime.execApprovalsNotice.value) + assertEquals("approval-2", replacement.approvalId) + + // compareAndSet semantics: a close tap captured for the first notice must leave + // the replacement untouched; only dismissing the rendered notice clears it. + runtime.dismissExecApprovalsNotice(staleNotice) + assertEquals(replacement, runtime.execApprovalsNotice.value) + + runtime.dismissExecApprovalsNotice(replacement) + assertNull(runtime.execApprovalsNotice.value) + } + + @Test + fun staleDismissCannotClearStructurallyEqualReplacementNotice() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + when (method) { + "approval.resolve" -> unifiedResolve(applied = false, status = "denied", decision = "deny") + "approval.get" -> unifiedGet(status = "pending", decision = null) + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + val staleNotice = requireNotNull(runtime.execApprovalsNotice.value) + + // The same approval id is re-requested and loses again: the replacement notice + // carries identical id/message/warning but is a distinct publication. + invokeApprovalEvent(runtime, "exec.approval.requested", """{"id":"approval-1"}""") + waitUntil { runtime.execApprovals.value.map { it.id } == listOf("approval-1") } + runtime.resolveExecApproval("approval-1", "allow-once") + waitUntil { runtime.execApprovals.value.isEmpty() } + val replacement = requireNotNull(runtime.execApprovalsNotice.value) + assertEquals(staleNotice.approvalId, replacement.approvalId) + assertEquals(staleNotice.message, replacement.message) + assertEquals(staleNotice.warning, replacement.warning) + assertNotEquals(staleNotice, replacement) + + // A close tap captured for the first banner must not clear the equal-looking + // replacement outcome the user has not acknowledged yet. + runtime.dismissExecApprovalsNotice(staleNotice) + assertEquals(replacement, runtime.execApprovalsNotice.value) + + runtime.dismissExecApprovalsNotice(replacement) + assertNull(runtime.execApprovalsNotice.value) + } + + @Test + fun oldGatewayUsesOnlyShippedExecMethods() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime( + runtime, + setOf("exec.approval.list", "exec.approval.get", "exec.approval.resolve"), + ) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "exec.approval.get" -> legacyGet() + "exec.approval.resolve" -> """{"ok":true}""" + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.allowedDecisions == listOf("allow-once", "deny") + } + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals( + listOf("exec.approval.list", "exec.approval.get", "exec.approval.resolve"), + methods, + ) + assertFalse(methods.any { it == "approval.get" || it == "approval.resolve" }) + } + + @Test + fun partialCanonicalCatalogCannotMixWithLegacyApprovalMethods() = + runBlocking { + val runtime = createTestRuntime() + val mixedMethods = legacyMethods + "approval.get" + seedConnectedRuntime(runtime, mixedMethods) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + else -> error("an inconsistent hello must not select approval RPC $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText == + "Could not load approval details. Refresh and try again." + } + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.let { row -> + row.resolvingDecision == null && row.errorText == "Could not resolve approval. Refresh and try again." + } == true + } + + assertEquals(listOf("exec.approval.list"), methods) + } + + @Test + fun canonicalUnknownReadFailsClosedWithoutLegacyDowngrade() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, allApprovalMethods) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "exec.approval.list" -> + """[{"id":"approval-1","createdAtMs":100,"expiresAtMs":4000000000000}]""" + "approval.get" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "unknown method: approval.get", + ), + ) + "exec.approval.get" -> error("canonical hello must never downgrade") + else -> error("unexpected method $method") + } + } + + runtime.refreshExecApprovals() + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.errorText == + "Could not load approval details. Refresh and try again." + } + + assertEquals(listOf("exec.approval.list", "approval.get"), methods) + } + + @Test + fun canonicalUnknownResolveFailsClosedWithoutLegacyDowngrade() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, allApprovalMethods) + seedApproval(runtime) + val methods = mutableListOf() + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "approval.resolve" -> + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "unknown method: approval.resolve", + ), + ) + "exec.approval.resolve" -> error("canonical hello must never downgrade") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { + runtime.execApprovals.value + .singleOrNull() + ?.let { row -> + row.resolvingDecision == null && row.errorText == "Could not resolve approval. Refresh and try again." + } == true + } + + assertEquals(listOf("approval.resolve"), methods) + } + + @Test + fun staleCanonicalRejectionFromRetiredSocketCannotAffectReplacementCatalog() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + val firstResolveStarted = CompletableDeferred() + val releaseFirstResolve = CompletableDeferred() + val methods = mutableListOf() + var unifiedResolveCalls = 0 + runtime.gatewayDataRequestOverrideForTests = { _, method, _ -> + methods += method + when (method) { + "approval.resolve" -> { + unifiedResolveCalls += 1 + if (unifiedResolveCalls == 1) { + firstResolveStarted.complete(Unit) + releaseFirstResolve.await() + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "unknown method: approval.resolve", + ), + ) + } + unifiedResolve(applied = true, status = "denied", decision = "deny") + } + "exec.approval.resolve" -> error("stale rejection must not trigger legacy fallback") + else -> error("unexpected method $method") + } + } + + runtime.resolveExecApproval("approval-1", "deny") + withTimeout(2_000) { firstResolveStarted.await() } + + invokeClearOperatorState(runtime, retirePendingRuns = false) + seedConnectedRuntime(runtime, unifiedMethods) + seedApproval(runtime) + releaseFirstResolve.complete(Unit) + delay(100) + + runtime.resolveExecApproval("approval-1", "deny") + waitUntil { runtime.execApprovals.value.isEmpty() } + + assertEquals(listOf("approval.resolve", "approval.resolve"), methods) + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.approval.runtime.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedRuntime( + runtime: NodeRuntime, + methods: Set, + ) { + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789)) + writeField(runtime, "operatorConnected", true) + invokeReplaceGatewayMethods(runtime, methods) + } + + private fun seedApproval(runtime: NodeRuntime) { + seedApprovals(runtime, listOf(approvalSummary())) + } + + private fun seedApprovals( + runtime: NodeRuntime, + approvals: List, + ) { + readField>>(runtime, "_execApprovals").value = + approvals + } + + private fun approvalSummary( + id: String = "approval-1", + commandText: String = "echo ok", + ): GatewayExecApprovalSummary = + GatewayExecApprovalSummary( + id = id, + commandText = commandText, + commandPreview = "echo", + warningText = null, + allowedDecisions = listOf("allow-once", "allow-always", "deny"), + host = "gateway", + nodeId = null, + agentId = "main", + createdAtMs = 100, + expiresAtMs = 4_000_000_000_000, + ) + + private suspend fun waitUntil(condition: () -> Boolean) { + withTimeout(3_000) { + while (!condition()) delay(10) + } + } + + private fun invokeApprovalEvent( + runtime: NodeRuntime, + event: String, + payloadJson: String, + ) { + runtime.javaClass + .getDeclaredMethod("handleExecApprovalGatewayEvent", String::class.java, String::class.java) + .apply { isAccessible = true } + .invoke(runtime, event, payloadJson) + } + + private fun invokeClearOperatorState( + runtime: NodeRuntime, + retirePendingRuns: Boolean, + ) { + runtime.javaClass + .getDeclaredMethod("clearOperatorGatewayState", java.lang.Boolean.TYPE) + .apply { isAccessible = true } + .invoke(runtime, retirePendingRuns) + writeField(runtime, "operatorConnected", false) + } + + private fun invokeReplaceGatewayMethods( + runtime: NodeRuntime, + methods: Set, + ) { + runtime.javaClass + .getDeclaredMethod("replaceGatewayMethods", Set::class.java) + .apply { isAccessible = true } + .invoke(runtime, methods) + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + findField(target, name).set(target, value) + } + + private fun readField( + target: Any, + name: String, + ): T { + @Suppress("UNCHECKED_CAST") + return findField(target, name).get(target) as T + } + + private fun findField( + target: Any, + name: String, + ): Field { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + return type.getDeclaredField(name).apply { isAccessible = true } + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } + + private fun unifiedResolve( + applied: Boolean, + status: String, + decision: String?, + id: String = "approval-1", + ): String = """{"applied":$applied,"approval":${approval(status, decision, id)}}""" + + private fun unifiedGet( + status: String, + decision: String?, + id: String = "approval-1", + ): String = """{"approval":${approval(status, decision, id)}}""" + + private fun approval( + status: String, + decision: String?, + id: String, + ): String { + val terminalFields = + if (status == "pending") { + "" + } else { + val reason = if (status == "expired") "timeout" else "user" + val decisionField = decision?.let { ",\"decision\":\"$it\"" }.orEmpty() + ",\"resolvedAtMs\":150,\"reason\":\"$reason\"$decisionField" + } + return """ + { + "id":${JsonPrimitive(id)}, + "urlPath":"/approve/approval-1", + "status":"$status", + "createdAtMs":100, + "expiresAtMs":4000000000000, + "presentation":{ + "kind":"exec", + "commandText":"echo ok", + "commandPreview":"echo", + "warningText":null, + "host":"gateway", + "nodeId":null, + "agentId":"main", + "allowedDecisions":["allow-once","allow-always","deny"] + }$terminalFields + } + """.trimIndent() + } + + private fun legacyGet(): String = + """ + { + "id":"approval-1", + "commandText":"echo ok", + "commandPreview":"echo", + "allowedDecisions":["allow-once","deny"], + "host":"gateway", + "nodeId":null, + "agentId":"main", + "expiresAtMs":4000000000000 + } + """.trimIndent() + + private fun gatewayErrorDetails(reason: String): GatewayConnectErrorDetails = + GatewayConnectErrorDetails( + code = null, + canRetryWithDeviceToken = false, + recommendedNextStep = null, + reason = reason, + ) + + private val unifiedMethods = setOf("approval.get", "approval.resolve", "exec.approval.list") + private val legacyMethods = setOf("exec.approval.list", "exec.approval.get", "exec.approval.resolve") + private val allApprovalMethods = unifiedMethods + legacyMethods +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt index 2a582b06c65c..e65bb16f44a8 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt @@ -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() + 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 = 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, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt index 4c5cabb09d3c..df6c99b48447 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt @@ -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, diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md index 20f3c9807e29..a0904eb39ce4 100644 --- a/apps/ios/CHANGELOG.md +++ b/apps/ios/CHANGELOG.md @@ -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. diff --git a/apps/ios/Resources/Localizable.xcstrings b/apps/ios/Resources/Localizable.xcstrings index dd9e8789888b..9c2a9907a31f 100644 --- a/apps/ios/Resources/Localizable.xcstrings +++ b/apps/ios/Resources/Localizable.xcstrings @@ -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" } } } diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index 5e15bcd90c10..f05a7b127102 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -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 diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index 242ded974ed0..10d3fbb64058 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -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 } diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 1497e0d6a5cc..f3c83952d7a9 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -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) { diff --git a/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift b/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift index fed874f0de99..17192bb53a4b 100644 --- a/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift +++ b/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift @@ -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)) } } diff --git a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift index b864103f6170..52055ab2c0a9 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift @@ -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 + } + } } diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift b/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift index e351765a673a..0de000d5e9b7 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift @@ -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 { diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController+ManualAuth.swift b/apps/ios/Sources/Gateway/GatewayConnectionController+ManualAuth.swift index fbccaf5f0721..17aa29e28c56 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController+ManualAuth.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController+ManualAuth.swift @@ -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, diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 3d952ae617dc..e93ea0df97d0 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -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) + GatewayStableIdentifier.Key: (id: UUID, task: Task) ] = [:] 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 } } diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift index fc81e7686e5c..a522fe9b4304 100644 --- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift +++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift @@ -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() + private var lastStableIDs = Set() 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 { diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index ef63daa5a3ea..d10fb175517e 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -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() + var seen = Set() 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) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 645f963659f6..1dbd05b0bc47 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -103,18 +103,50 @@ final class NodeAppModel { struct ExecApprovalPrompt: Identifiable, Equatable, Codable { let id: String + let kind: String? let gatewayStableID: String let commandText: String let commandPreview: String? + let warningText: String? let allowedDecisions: [String] let host: String? let nodeId: String? let agentId: String? let expiresAtMs: Int64? - var allowsAllowAlways: Bool { - self.allowedDecisions.contains("allow-always") + var allowsAllowOnce: Bool { + self.allowedDecisions.contains(ApprovalDecision.allowOnce.rawValue) } + + var allowsAllowAlways: Bool { + self.allowedDecisions.contains(ApprovalDecision.allowAlways.rawValue) + } + + var allowsDeny: Bool { + self.allowedDecisions.contains(ApprovalDecision.deny.rawValue) + } + } + + struct ExecApprovalInboxKey: Hashable, Sendable { + let approvalID: ExecApprovalIdentifier.Key + let gatewayID: GatewayStableIdentifier.Key + } + + struct ExecApprovalInboxItem: Identifiable, Equatable { + let id: ExecApprovalInboxKey + let prompt: ExecApprovalPrompt + } + + enum ExecApprovalOutcomeTone: Equatable { + case success + case danger + case warning + case neutral + } + + struct ExecApprovalOutcome: Equatable { + let text: String + let tone: ExecApprovalOutcomeTone } struct NotificationPermissionGuidancePrompt: Identifiable, Equatable { @@ -122,13 +154,126 @@ final class NodeAppModel { let approvalId: String } + private struct ExecApprovalPushKey: Hashable { + let approvalID: ExecApprovalIdentifier.Key + let gatewayDeviceID: GatewayStableIdentifier.Key? + } + + private struct PersistedExecApprovalReadback: Codable, Equatable { + let approvalId: String + let gatewayStableID: String + } + + private struct PersistedExecApprovalReadbackKey: Hashable { + let approvalID: ExecApprovalIdentifier.Key + let gatewayID: GatewayStableIdentifier.Key + } + + private struct PersistedExecApprovalUncertainty: Codable, Equatable { + let approvalId: String + let gatewayStableID: String + let message: String + } + + private typealias ExecApprovalResolutionKey = ExecApprovalInboxKey + + private struct ExecApprovalResolutionAttempt: Equatable { + let key: ExecApprovalResolutionKey + let token: UUID + } + + private struct ExecApprovalResolutionAttemptState { + let token: UUID + var writeInFlight: Bool + } + + private struct ExecApprovalUncertaintyState { + let token: UUID + let message: String + } + + private struct ExecApprovalReadbackFence { + let key: ExecApprovalResolutionKey + let uncertaintyToken: UUID? + } + private enum ExecApprovalResolutionOutcome { - case resolved + case resolved(ExecApprovalTerminalResult, applied: Bool) + case pendingRetry(message: String) case stale - case unavailable + case uncertain(message: String) case failed(message: String) } + private struct LegacyExecApprovalGetResult: Decodable { + let id: String + let commandText: String + let commandPreview: String? + let warningText: String? + let allowedDecisions: [String] + let host: String? + let nodeId: String? + let agentId: String? + let expiresAtMs: Int64? + } + + private enum ExecApprovalRPCFamily: Equatable { + case unified + case legacy + case unavailable + } + + private struct ExecApprovalTerminalResult { + let id: String + let verdict: ExecApprovalTerminalVerdict + let resolvedAtMs: Int64 + + var status: String { + self.verdict.status + } + + var decision: String? { + self.verdict.decision + } + } + + private enum ExecApprovalTerminalVerdict { + case allowOnce + case allowAlways + case deny + case expired + case cancelled + case resolvedUnknown + + var status: String { + switch self { + case .allowOnce, .allowAlways: + "allowed" + case .deny: + "denied" + case .expired: + "expired" + case .cancelled: + "cancelled" + case .resolvedUnknown: + "resolved" + } + } + + var decision: String? { + switch self { + case .allowOnce: + ApprovalDecision.allowOnce.rawValue + case .allowAlways: + ApprovalDecision.allowAlways.rawValue + case .deny: + ApprovalDecision.deny.rawValue + case .expired, .cancelled, .resolvedUnknown: + nil + } + } + } + private struct GatewaySessionRouteContext { let route: GatewayNodeSessionRoute let gatewayStableID: String @@ -180,6 +325,8 @@ final class NodeAppModel { private struct PersistedWatchExecApprovalBridgeState: Codable { var approvals: [ExecApprovalPrompt] + var pendingApprovalReadbacks: [PersistedExecApprovalReadback]? + var approvalUncertainties: [PersistedExecApprovalUncertainty]? var pendingApprovalPushes: [ExecApprovalNotificationPrompt]? var pendingResolvedPushes: [ExecApprovalNotificationPrompt]? var pendingResolutions: [WatchExecApprovalResolveEvent]? @@ -265,7 +412,43 @@ final class NodeAppModel { private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt? private(set) var pendingExecApprovalPromptResolving: Bool = false private(set) var pendingExecApprovalPromptErrorText: String? + // A canonical applied:false winner keeps the prompt visible but freezes its actions. + private(set) var pendingExecApprovalPromptOutcome: ExecApprovalOutcome? + var pendingExecApprovalPromptResolvedText: String? { + self.pendingExecApprovalPromptOutcome?.text + } + + var pendingExecApprovalInboxItems: [ExecApprovalInboxItem] { + self.execApprovalInboxPromptsByKey.compactMap { key, prompt in + guard !self.terminalExecApprovalKeys.contains(key) else { return nil } + return ExecApprovalInboxItem(id: key, prompt: prompt) + }.sorted { lhs, rhs in + let lhsExpires = lhs.prompt.expiresAtMs ?? Int64.max + let rhsExpires = rhs.prompt.expiresAtMs ?? Int64.max + if lhsExpires != rhsExpires { + return lhsExpires < rhsExpires + } + if lhs.id.gatewayID != rhs.id.gatewayID { + return Self.exactStringSortsBefore( + lhs.prompt.gatewayStableID, + rhs.prompt.gatewayStableID) + } + return Self.approvalIDSortsBefore(lhs.prompt.id, rhs.prompt.id) + } + } + + var pendingExecApprovalCount: Int { + self.pendingExecApprovalInboxItems.count + } + + /// An uncertain resolution keeps approval actions frozen, but must not trap the + /// reviewer in a modal or Settings detail while canonical readback is unavailable. + var pendingExecApprovalPromptCanDismiss: Bool { + !self.pendingExecApprovalPromptResolving || self.pendingExecApprovalPromptErrorText != nil + } + private var pendingExecApprovalPromptRequestGeneration: Int = 0 + private var pendingExecApprovalPromptSurfaceGeneration: UInt64 = 0 private(set) var pendingNotificationPermissionGuidancePrompt: NotificationPermissionGuidancePrompt? private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt? private var lastAgentDeepLinkPromptAt: Date = .distantPast @@ -308,6 +491,11 @@ final class NodeAppModel { @ObservationIgnored private var testTalkCapturePreparationHandler: (() async -> Void)? @ObservationIgnored private var testTalkCaptureStartedHandler: (() async -> Void)? @ObservationIgnored private var testChatSessionRoutingRestoreHandler: (() async -> Void)? + @ObservationIgnored private var testExecApprovalPromptFetchHandler: + ((String, String) async -> ExecApprovalPromptFetchOutcome)? + @ObservationIgnored private var testExecApprovalResolutionHandler: + ((String, String, String) async -> ExecApprovalResolutionOutcome)? + @ObservationIgnored private var testExecApprovalResolutionReconcilesUnknownAck = false #endif private var pttVoiceWakeLeaseCaptureId: String? private var talkPttCommandEpoch: UInt64 = 0 @@ -331,7 +519,19 @@ final class NodeAppModel { @ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport() @ObservationIgnored private var chatTranscriptCachesByGatewayID: [String: OpenClawChatSQLiteTranscriptCache] = [:] @ObservationIgnored private var chatSessionRoutingRestoreTask: Task? - private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:] + private var watchExecApprovalPromptsByID: [ExecApprovalIdentifier.Key: ExecApprovalPrompt] = [:] + private var execApprovalInboxPromptsByKey: [ExecApprovalInboxKey: ExecApprovalPrompt] = [:] + private var dismissedExecApprovalPresentationKeys: Set = [] + private var terminalExecApprovalKeys: Set = [] + @ObservationIgnored private var terminalExecApprovalKeyOrder: [ExecApprovalInboxKey] = [] + @ObservationIgnored private var resettableWatchResolutionAttempts: + [ExecApprovalInboxKey: [ExactOpaqueIdentifierKey: String]] = [:] + private var pendingPersistedExecApprovalReadbacks: [PersistedExecApprovalReadback] = [] + @ObservationIgnored private var activeExecApprovalResolutionAttempts: + [ExecApprovalResolutionKey: ExecApprovalResolutionAttemptState] = [:] + @ObservationIgnored private var execApprovalUncertainties: + [ExecApprovalResolutionKey: ExecApprovalUncertaintyState] = [:] + @ObservationIgnored private var pendingWatchExecApprovalResolutionFlushInFlight = false private var pendingWatchExecApprovalRecoveryPushes: [ExecApprovalNotificationPrompt] = [] private var pendingExecApprovalResolvedPushes: [ExecApprovalNotificationPrompt] = [] private var pendingWatchExecApprovalResolutions: [WatchExecApprovalResolveEvent] = [] @@ -625,13 +825,13 @@ final class NodeAppModel { guard let self else { return } GatewayDiagnostics.log( "node app model: watch snapshot request id=\(event.requestId) backgrounded=\(self.isBackgrounded)") - guard self.isBackgrounded else { - self.watchExecApprovalLogger.debug( - "watch exec approval snapshot skipped reason=watch_request_foreground") - GatewayDiagnostics.log("node app model: watch snapshot request skipped in foreground") - return - } - await self.refreshWatchExecApprovalSnapshotOnDemand(reason: "watch_request") + // A correlated reply is an acknowledgment of canonical readback, not + // merely receipt. Always reconcile before echoing the Watch request. + await self.refreshWatchExecApprovalSnapshotOnDemand( + reason: "watch_request", + requestId: event.requestId, + requestGatewayStableID: event.gatewayStableID, + heldApprovals: event.heldApprovals) } } self.watchMessagingService.setAppSnapshotRequestHandler { [weak self] event in @@ -1226,12 +1426,14 @@ final class NodeAppModel { let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines) let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId - let stableID = (connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if stableID.isEmpty { + let stableID = GatewayStableIdentifier.exact(self.connectedGatewayID) + if let stableID { self.selectedAgentId = nextSelectedAgentId + GatewaySettingsStore.saveGatewaySelectedAgentId( + stableID: stableID, + agentId: self.selectedAgentId) } else { self.selectedAgentId = nextSelectedAgentId - GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) } if selectedAgentChanged { self.focusedChatSessionKey = nil @@ -1314,6 +1516,11 @@ final class NodeAppModel { self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase) case ExecApprovalNotificationBridge.requestedKind: guard let approvalId = Self.execApprovalEventID(from: payload) else { return } + if let gatewayStableID = self.currentExecApprovalGatewayStableID() { + self.appendPendingPersistedExecApprovalReadback( + approvalId: approvalId, + gatewayStableID: gatewayStableID) + } await self.presentNotificationPermissionGuidanceForExecApprovalIfNeeded( approvalId: approvalId, shouldApply: shouldContinue) @@ -1324,9 +1531,25 @@ final class NodeAppModel { shouldContinue: shouldContinue) case ExecApprovalNotificationBridge.resolvedKind: guard let approvalId = Self.execApprovalEventID(from: payload) else { return } - await handleExecApprovalResolvedForCurrentGateway( - approvalId: approvalId, + guard let context = await self.operatorRouteForExecApproval( + sourceReason: "resolved_event", + expectedOperatorRoute: expectedOperatorRoute, shouldContinue: shouldContinue) + else { + self.appendPendingExecApprovalResolvedPush(ExecApprovalNotificationPrompt( + approvalId: approvalId, + gatewayDeviceId: nil)) + return + } + let reconciled = await handleExecApprovalResolvedForCurrentGateway( + approvalId: approvalId, + routeContext: context, + shouldContinue: shouldContinue) + if !reconciled, shouldContinue() { + self.appendPendingExecApprovalResolvedPush(ExecApprovalNotificationPrompt( + approvalId: approvalId, + gatewayDeviceId: nil)) + } default: return } @@ -1339,8 +1562,303 @@ final class NodeAppModel { else { return nil } - let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines) - return approvalId.isEmpty ? nil : approvalId + return Self.validatedApprovalID(decoded.id) + } + + private nonisolated static func validatedApprovalID(_ id: String) -> String? { + ExecApprovalIdentifier.exact(id) + } + + private nonisolated static func execApprovalIDKey(_ id: String) -> ExecApprovalIdentifier.Key? { + ExecApprovalIdentifier.key(id) + } + + private nonisolated static func approvalIDsMatch(_ lhs: String, _ rhs: String) -> Bool { + ExecApprovalIdentifier.matches(lhs, rhs) + } + + private nonisolated static func approvalIDSortsBefore(_ lhs: String, _ rhs: String) -> Bool { + ExecApprovalIdentifier.sortsBefore(lhs, rhs) + } + + private nonisolated static func execApprovalResolutionKey( + approvalID: String, + gatewayStableID: String) -> ExecApprovalResolutionKey? + { + guard let approvalID = ExecApprovalIdentifier.key(approvalID), + let gatewayID = GatewayStableIdentifier.key(gatewayStableID) + else { return nil } + return ExecApprovalResolutionKey( + approvalID: approvalID, + gatewayID: gatewayID) + } + + static func execApprovalInboxKey( + approvalID: String, + gatewayStableID: String?) -> ExecApprovalInboxKey? + { + guard let gatewayStableID else { return nil } + return self.execApprovalResolutionKey( + approvalID: approvalID, + gatewayStableID: gatewayStableID) + } + + static func execApprovalInboxKey(_ prompt: ExecApprovalPrompt?) -> ExecApprovalInboxKey? { + guard let prompt else { return nil } + return self.execApprovalInboxKey( + approvalID: prompt.id, + gatewayStableID: prompt.gatewayStableID) + } + + private func beginExecApprovalResolutionAttempt( + approvalID: String, + gatewayStableID: String) -> ExecApprovalResolutionAttempt? + { + guard let key = Self.execApprovalResolutionKey( + approvalID: approvalID, + gatewayStableID: gatewayStableID), + self.activeExecApprovalResolutionAttempts[key] == nil, + self.execApprovalUncertainties[key] == nil, + !self.terminalExecApprovalKeys.contains(key) + else { return nil } + let attempt = ExecApprovalResolutionAttempt(key: key, token: UUID()) + self.activeExecApprovalResolutionAttempts[key] = ExecApprovalResolutionAttemptState( + token: attempt.token, + writeInFlight: true) + return attempt + } + + private func isActiveExecApprovalResolutionAttempt( + _ attempt: ExecApprovalResolutionAttempt) -> Bool + { + self.activeExecApprovalResolutionAttempts[attempt.key]?.token == attempt.token + } + + private func markExecApprovalResolutionWriteSettled( + _ attempt: ExecApprovalResolutionAttempt) + { + guard var state = self.activeExecApprovalResolutionAttempts[attempt.key], + state.token == attempt.token + else { return } + state.writeInFlight = false + self.activeExecApprovalResolutionAttempts[attempt.key] = state + } + + private func finishExecApprovalResolutionAttempt( + _ attempt: ExecApprovalResolutionAttempt) + { + guard self.isActiveExecApprovalResolutionAttempt(attempt) else { return } + self.activeExecApprovalResolutionAttempts.removeValue(forKey: attempt.key) + guard !self.pendingWatchExecApprovalResolutions.isEmpty else { return } + Task { @MainActor [weak self] in + await Task.yield() + await self?.flushPendingWatchExecApprovalResolutions() + } + } + + private func markExecApprovalResolutionUncertain( + approvalID: String, + gatewayStableID: String, + message: String) + { + guard let key = Self.execApprovalResolutionKey( + approvalID: approvalID, + gatewayStableID: gatewayStableID), + !self.terminalExecApprovalKeys.contains(key) + else { return } + // A lost write response is neither pending nor terminal truth. Keep this exact + // owner frozen across dismissal until approval.get classifies it canonically. + self.execApprovalUncertainties[key] = ExecApprovalUncertaintyState( + token: UUID(), + message: message) + let readback = PersistedExecApprovalReadback( + approvalId: approvalID, + gatewayStableID: gatewayStableID) + if !self.pendingPersistedExecApprovalReadbacks.contains(where: { + Self.persistedExecApprovalReadbackKey($0) == PersistedExecApprovalReadbackKey( + approvalID: key.approvalID, + gatewayID: key.gatewayID) + }) { + self.pendingPersistedExecApprovalReadbacks.append(readback) + self.pendingPersistedExecApprovalReadbacks.sort( + by: Self.persistedExecApprovalReadbackSortsBefore) + } + self.persistWatchExecApprovalBridgeState() + guard Self.execApprovalInboxKey(self.pendingExecApprovalPrompt) == key else { return } + self.pendingExecApprovalPromptResolving = true + self.pendingExecApprovalPromptErrorText = message + self.pendingExecApprovalPromptOutcome = nil + } + + private func recordCanonicalExecApprovalFetchOutcome( + _ outcome: ExecApprovalPromptFetchOutcome, + fence: ExecApprovalReadbackFence?) -> ExecApprovalPromptFetchOutcome + { + guard case let .loaded(prompt) = outcome, + let promptKey = Self.execApprovalInboxKey(prompt), + let fence, + promptKey == fence.key, + let uncertaintyToken = fence.uncertaintyToken, + self.execApprovalUncertainties[promptKey]?.token == uncertaintyToken + else { return outcome } + self.execApprovalUncertainties.removeValue(forKey: promptKey) + self.pendingPersistedExecApprovalReadbacks.removeAll { + Self.persistedExecApprovalReadbackKey($0) == PersistedExecApprovalReadbackKey( + approvalID: promptKey.approvalID, + gatewayID: promptKey.gatewayID) + } + self.persistWatchExecApprovalBridgeState() + self.schedulePendingWatchExecApprovalResolutionFlush() + return outcome + } + + private func execApprovalReadbackFence(approvalID: String) -> ExecApprovalReadbackFence? { + guard let gatewayStableID = self.currentExecApprovalGatewayStableID(), + let key = Self.execApprovalResolutionKey( + approvalID: approvalID, + gatewayStableID: gatewayStableID) + else { return nil } + return ExecApprovalReadbackFence( + key: key, + uncertaintyToken: self.execApprovalUncertainties[key]?.token) + } + + private func schedulePendingWatchExecApprovalResolutionFlush() { + guard !self.pendingWatchExecApprovalResolutions.isEmpty else { return } + Task { @MainActor [weak self] in + await Task.yield() + await self?.flushPendingWatchExecApprovalResolutions() + } + } + + private func isExecApprovalResolutionWriteInFlight( + approvalID: String, + gatewayStableID: String) -> Bool + { + guard let key = Self.execApprovalResolutionKey( + approvalID: approvalID, + gatewayStableID: gatewayStableID) + else { return false } + return self.activeExecApprovalResolutionAttempts[key]?.writeInFlight == true + } + + /// True while the owner-scoped attempt lease is held, including the readback window + /// after the write settled but before the outer defer releases the lease. New + /// resolution attempts are rejected for that whole span, not just while writing. + private func hasActiveExecApprovalResolutionAttempt( + approvalID: String, + gatewayStableID: String) -> Bool + { + guard let key = Self.execApprovalResolutionKey( + approvalID: approvalID, + gatewayStableID: gatewayStableID) + else { return false } + return self.activeExecApprovalResolutionAttempts[key] != nil + } + + private func markWatchResolutionAttemptResettable( + _ event: WatchExecApprovalResolveEvent) + { + guard let key = Self.execApprovalInboxKey( + approvalID: event.approvalId, + gatewayStableID: event.gatewayStableID), + let attemptKey = ExactOpaqueIdentifier.key(event.replyId) + else { return } + var attempts = self.resettableWatchResolutionAttempts[key] ?? [:] + attempts[attemptKey] = event.replyId + if attempts.count > 8, + let evictedKey = attempts.keys.min(by: { lhs, rhs in + Self.exactStringSortsBefore(lhs.rawValue, rhs.rawValue) + }) + { + attempts.removeValue(forKey: evictedKey) + } + self.resettableWatchResolutionAttempts[key] = attempts + } + + private func resettableWatchResolutionAttemptID( + for prompt: ExecApprovalPrompt, + heldAttemptID: String?) -> String? + { + guard let key = Self.execApprovalInboxKey(prompt), + self.activeExecApprovalResolutionAttempts[key] == nil, + let heldAttemptKey = ExactOpaqueIdentifier.key(heldAttemptID), + let recordedAttemptID = self.resettableWatchResolutionAttempts[key]?[heldAttemptKey] + else { return nil } + return recordedAttemptID + } + + private nonisolated static func exactStringSortsBefore(_ lhs: String, _ rhs: String) -> Bool { + Array(lhs.utf8).lexicographicallyPrecedes(Array(rhs.utf8)) + } + + private nonisolated static func execApprovalPushSortsBefore( + _ lhs: ExecApprovalNotificationPrompt, + _ rhs: ExecApprovalNotificationPrompt) -> Bool + { + let lhsGatewayID = lhs.gatewayDeviceId ?? "" + let rhsGatewayID = rhs.gatewayDeviceId ?? "" + let lhsGatewayBytes = Array(lhsGatewayID.utf8) + let rhsGatewayBytes = Array(rhsGatewayID.utf8) + if lhsGatewayBytes != rhsGatewayBytes { + return lhsGatewayBytes.lexicographicallyPrecedes(rhsGatewayBytes) + } + return self.approvalIDSortsBefore(lhs.approvalId, rhs.approvalId) + } + + private nonisolated static func execApprovalPushKey( + _ push: ExecApprovalNotificationPrompt) -> ExecApprovalPushKey? + { + guard let approvalID = self.execApprovalIDKey(push.approvalId) else { return nil } + let gatewayDeviceID: GatewayStableIdentifier.Key? + if let rawGatewayDeviceID = push.gatewayDeviceId { + guard let exactGatewayDeviceID = GatewayStableIdentifier.key(rawGatewayDeviceID) else { return nil } + gatewayDeviceID = exactGatewayDeviceID + } else { + gatewayDeviceID = nil + } + return ExecApprovalPushKey( + approvalID: approvalID, + gatewayDeviceID: gatewayDeviceID) + } + + private nonisolated static func persistedExecApprovalReadbackKey( + _ readback: PersistedExecApprovalReadback) -> PersistedExecApprovalReadbackKey? + { + guard let approvalID = ExecApprovalIdentifier.key(readback.approvalId), + let gatewayID = GatewayStableIdentifier.key(readback.gatewayStableID) + else { return nil } + return PersistedExecApprovalReadbackKey( + approvalID: approvalID, + gatewayID: gatewayID) + } + + private nonisolated static func persistedExecApprovalReadbackSortsBefore( + _ lhs: PersistedExecApprovalReadback, + _ rhs: PersistedExecApprovalReadback) -> Bool + { + if !GatewayStableIdentifier.matches(lhs.gatewayStableID, rhs.gatewayStableID) { + return self.exactStringSortsBefore(lhs.gatewayStableID, rhs.gatewayStableID) + } + return self.approvalIDSortsBefore(lhs.approvalId, rhs.approvalId) + } + + private nonisolated static func persistedExecApprovalUncertaintyKey( + _ uncertainty: PersistedExecApprovalUncertainty) -> ExecApprovalResolutionKey? + { + self.execApprovalResolutionKey( + approvalID: uncertainty.approvalId, + gatewayStableID: uncertainty.gatewayStableID) + } + + private nonisolated static func persistedExecApprovalUncertaintySortsBefore( + _ lhs: PersistedExecApprovalUncertainty, + _ rhs: PersistedExecApprovalUncertainty) -> Bool + { + if !GatewayStableIdentifier.matches(lhs.gatewayStableID, rhs.gatewayStableID) { + return self.exactStringSortsBefore(lhs.gatewayStableID, rhs.gatewayStableID) + } + return self.approvalIDSortsBefore(lhs.approvalId, rhs.approvalId) } private func applyTalkModeSync(enabled: Bool, phase: String?) { @@ -2849,7 +3367,7 @@ extension NodeAppModel { connectOptions: GatewayConnectOptions, forceReconnect: Bool = false) { - let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) + let stableID = GatewayStableIdentifier.exact(gatewayStableID) ?? "" let effectiveStableID = stableID.isEmpty ? url.absoluteString : stableID let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } let nextConfig = GatewayConnectConfig( @@ -2863,10 +3381,10 @@ extension NodeAppModel { let previousGatewayStableID = self.activeGatewayConnectConfig?.effectiveStableID ?? self.connectedGatewayID let targetChanged = previousGatewayStableID.map { - !$0.isEmpty && $0 != effectiveStableID + !$0.isEmpty && !GatewayStableIdentifier.matches($0, effectiveStableID) } ?? false let hasForeignCachedApproval = self.watchExecApprovalPromptsByID.values.contains { - $0.gatewayStableID != effectiveStableID + !GatewayStableIdentifier.matches($0.gatewayStableID, effectiveStableID) } if hasForeignCachedApproval || targetChanged { // Approval IDs are gateway-local authorization handles. A target switch must remove @@ -3323,7 +3841,7 @@ extension NodeAppModel { fallback: GatewayConnectOptions) -> GatewayConnectOptions { guard let config = activeGatewayConnectConfig, - config.effectiveStableID == stableID + GatewayStableIdentifier.matches(config.effectiveStableID, stableID) else { return fallback } return config.nodeOptions } @@ -3352,7 +3870,7 @@ extension NodeAppModel { return nodeOptions.allowStoredDeviceAuth ? nodeOptions : nil } guard let config = activeGatewayConnectConfig, - config.effectiveStableID == stableID + GatewayStableIdentifier.matches(config.effectiveStableID, stableID) else { return nil } let instanceID = GatewaySettingsStore.currentInstanceID() let deviceAuthGatewayID = nodeOptions.deviceAuthGatewayID ?? stableID @@ -3483,7 +4001,21 @@ extension NodeAppModel { private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool { generation == self.gatewayRouteGeneration && - self.activeGatewayConnectConfig?.effectiveStableID == stableID + GatewayStableIdentifier.matches( + self.activeGatewayConnectConfig?.effectiveStableID, + stableID) + } + + private func isCurrentExecApprovalReadbackRoute(generation: UInt64, stableID: String) -> Bool { + #if DEBUG + if self.testExecApprovalPromptFetchHandler != nil { + return generation == self.gatewayRouteGeneration && + GatewayStableIdentifier.matches( + self.currentExecApprovalGatewayStableID(), + stableID) + } + #endif + return self.isCurrentGatewayRoute(generation: generation, stableID: stableID) } private func gatewayRouteCheck( @@ -3516,8 +4048,8 @@ extension NodeAppModel { return self.operatorTalkConnectionGeneration == talkConnectionGeneration && self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) } - await flushPendingWatchExecApprovalResolutions(shouldContinue: shouldContinue) - guard shouldContinue() else { return } + // Watch approval resolutions flush from the reconcile-gated watch path + // (reconcileWatchExecApprovalCache), not eagerly on operator connect. if let chatSessionRoutingRestoreTask { await chatSessionRoutingRestoreTask.value } @@ -4219,10 +4751,19 @@ extension NodeAppModel { self.watchMessageRetryAttempts.removeAll() } Task { [weak self] in - await self?.flushPendingExecApprovalResolvedPushes() - await self?.flushQueuedWatchMessagesIfAvailable() + guard let self else { return } + await self.flushPendingExecApprovalResolvedPushes() + var approvalStateIsAuthoritative = true + if changed { + approvalStateIsAuthoritative = await self.reconcileWatchExecApprovalCache( + reason: "operator_reconnected") + } + if changed, approvalStateIsAuthoritative { + await self.flushPendingWatchExecApprovalResolutions() + } + await self.flushQueuedWatchMessagesIfAvailable() guard changed else { return } - await self?.syncWatchAppSnapshot(reason: "operator_online") + await self.syncWatchAppSnapshot(reason: "operator_online") } } @@ -4636,9 +5177,13 @@ extension NodeAppModel { do { let expectedRoute: GatewayNodeSessionRoute? if let routeContext { - guard self.activeGatewayConnectConfig?.effectiveStableID == routeContext.gatewayStableID, - let currentRoute = await self.nodeGateway.currentRoute(), - self.activeGatewayConnectConfig?.effectiveStableID == routeContext.gatewayStableID + guard GatewayStableIdentifier.matches( + self.activeGatewayConnectConfig?.effectiveStableID, + routeContext.gatewayStableID), + let currentRoute = await self.nodeGateway.currentRoute(), + GatewayStableIdentifier.matches( + self.activeGatewayConnectConfig?.effectiveStableID, + routeContext.gatewayStableID) else { return false } expectedRoute = currentRoute } else { @@ -4668,21 +5213,25 @@ extension NodeAppModel { self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId") return } - let payloadGatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let payloadGatewayID = GatewayStableIdentifier.exact(event.gatewayStableID) let currentGatewayID = self.currentWatchChatGatewayStableID() - let routedGatewayID = self.watchMessageOutbox.gatewayStableID(forPromptID: event.promptId) ?? "" - let sourceGatewayID: String = if !payloadGatewayID.isEmpty { + let routedGatewayID = GatewayStableIdentifier.exact( + self.watchMessageOutbox.gatewayStableID(forPromptID: event.promptId)) + let sourceGatewayID: String? = if let payloadGatewayID { payloadGatewayID - } else if !routedGatewayID.isEmpty { + } else if let routedGatewayID { routedGatewayID } else { - "" + nil } - if !sourceGatewayID.isEmpty, let currentGatewayID, currentGatewayID != sourceGatewayID { + if let sourceGatewayID, + let currentGatewayID, + !GatewayStableIdentifier.matches(currentGatewayID, sourceGatewayID) + { self.watchReplyLogger.info("watch reply dropped: stale gateway target") return } - guard !sourceGatewayID.isEmpty else { + guard let sourceGatewayID else { self.watchReplyLogger.info("watch reply dropped: unresolved gateway target") return } @@ -4704,7 +5253,11 @@ extension NodeAppModel { let connected = await ensureOperatorApprovalConnectionForWatchReview( timeoutMs: 12000, reason: "watch_reply") - guard connected, self.currentWatchChatGatewayStableID() == gatewayStableID else { + guard connected, + GatewayStableIdentifier.matches( + self.currentWatchChatGatewayStableID(), + gatewayStableID) + else { self.watchReplyLogger.info("watch reply remains queued: gateway target unavailable") return } @@ -4739,51 +5292,93 @@ extension NodeAppModel { else { return } - self.watchExecApprovalPromptsByID = Dictionary( - uniqueKeysWithValues: state.approvals.map { ($0.id, $0) }) - var restoredPushes = Set() + // Shipped caches before unified approvals have no owner kind. Preserve only + // their exact ID + gateway owner until approval.get rebuilds canonical state. + let typedApprovals = state.approvals.filter { + $0.kind == ApprovalKind.exec.rawValue + } + self.watchExecApprovalPromptsByID = typedApprovals.reduce(into: [:]) { result, prompt in + guard let approvalID = Self.execApprovalIDKey(prompt.id) else { return } + result[approvalID] = prompt + } + let legacyReadbacks = state.approvals.compactMap { prompt -> PersistedExecApprovalReadback? in + guard prompt.kind == nil else { return nil } + return PersistedExecApprovalReadback( + approvalId: prompt.id, + gatewayStableID: prompt.gatewayStableID) + } + var restoredReadbacks = Set() + self.pendingPersistedExecApprovalReadbacks = ((state.pendingApprovalReadbacks ?? []) + legacyReadbacks) + .filter { readback in + guard let key = Self.persistedExecApprovalReadbackKey(readback) else { return false } + return restoredReadbacks.insert(key).inserted + } + .sorted(by: Self.persistedExecApprovalReadbackSortsBefore) + var restoredUncertainties = Set() + let persistedUncertainties = state.approvalUncertainties ?? [] + self.execApprovalUncertainties = persistedUncertainties.reduce(into: [:]) { result, uncertainty in + guard !uncertainty.message.isEmpty, + let key = Self.persistedExecApprovalUncertaintyKey(uncertainty), + restoredUncertainties.insert(key).inserted + else { return } + result[key] = ExecApprovalUncertaintyState( + token: UUID(), + message: uncertainty.message) + } + for key in restoredUncertainties where !restoredReadbacks.contains( + PersistedExecApprovalReadbackKey( + approvalID: key.approvalID, + gatewayID: key.gatewayID)) + { + self.pendingPersistedExecApprovalReadbacks.append(PersistedExecApprovalReadback( + approvalId: key.approvalID.rawValue, + gatewayStableID: key.gatewayID.rawValue)) + } + self.pendingPersistedExecApprovalReadbacks.sort( + by: Self.persistedExecApprovalReadbackSortsBefore) + var restoredPushes = Set() self.pendingWatchExecApprovalRecoveryPushes = (state.pendingApprovalPushes ?? []) .filter { push in - !push.approvalId.isEmpty && - push.gatewayDeviceId?.isEmpty != true && - restoredPushes.insert(push).inserted + guard push.gatewayDeviceId?.isEmpty != true, + let pushKey = Self.execApprovalPushKey(push) + else { return false } + return restoredPushes.insert(pushKey).inserted } - .sorted { lhs, rhs in - (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) - } - var restoredResolvedPushes = Set() + .sorted(by: Self.execApprovalPushSortsBefore) + var restoredResolvedPushes = Set() self.pendingExecApprovalResolvedPushes = (state.pendingResolvedPushes ?? []) .filter { push in - !push.approvalId.isEmpty && - push.gatewayDeviceId?.isEmpty != true && - restoredResolvedPushes.insert(push).inserted - } - .sorted { lhs, rhs in - (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) + guard push.gatewayDeviceId?.isEmpty != true, + let pushKey = Self.execApprovalPushKey(push) + else { return false } + return restoredResolvedPushes.insert(pushKey).inserted } + .sorted(by: Self.execApprovalPushSortsBefore) var restoredReplyIDs = Set() self.pendingWatchExecApprovalResolutions = Array((state.pendingResolutions ?? []).filter { event in let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) - let approvalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - let gatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let approvalID = Self.validatedApprovalID(event.approvalId) + let gatewayID = GatewayStableIdentifier.exact(event.gatewayStableID) return !replyID.isEmpty && - !approvalID.isEmpty && - !gatewayID.isEmpty && + approvalID != nil && + gatewayID != nil && restoredReplyIDs.insert(replyID).inserted }.suffix(32)) self.pruneExpiredWatchExecApprovalPrompts() + self.persistWatchExecApprovalBridgeState() } private func currentExecApprovalGatewayStableID() -> String? { let stableID = self.activeGatewayConnectConfig?.effectiveStableID ?? self.connectedGatewayID - ?? "" - let normalizedStableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) - return normalizedStableID.isEmpty ? nil : normalizedStableID + return GatewayStableIdentifier.exact(stableID) } private func isExecApprovalPromptCurrent(_ prompt: ExecApprovalPrompt) -> Bool { - self.currentExecApprovalGatewayStableID() == prompt.gatewayStableID + prompt.kind == ApprovalKind.exec.rawValue && + GatewayStableIdentifier.matches( + self.currentExecApprovalGatewayStableID(), + prompt.gatewayStableID) } private func invalidateExecApprovalSurfacesForGatewayChange() { @@ -4791,6 +5386,18 @@ extension NodeAppModel { self.dismissPendingExecApprovalPrompt() self.pendingNotificationPermissionGuidancePrompt = nil self.watchExecApprovalPromptsByID.removeAll() + self.execApprovalInboxPromptsByKey.removeAll() + self.dismissedExecApprovalPresentationKeys.removeAll() + self.terminalExecApprovalKeys.removeAll() + self.terminalExecApprovalKeyOrder.removeAll() + self.resettableWatchResolutionAttempts.removeAll() + // In-flight resolution attempts are owner-scoped write fences keyed by + // (approvalID, gatewayStableID). They must survive a target switch so returning + // to the owner cannot double-submit while the original write outcome is unknown; + // completion defers and terminal cleanup remove them per key. + // Uncertainties are owner-scoped durable records of lost write outcomes, not + // gateway-local UI. They must survive a target switch so returning to the owner + // keeps that approval frozen until approval.get classifies it canonically. let requestedPushes = self.pendingWatchExecApprovalRecoveryPushes self.pendingWatchExecApprovalRecoveryPushes.removeAll() let resolvedPushes = self.pendingExecApprovalResolvedPushes @@ -4800,7 +5407,11 @@ extension NodeAppModel { if let self { // Keep notification pushes until terminal state so route invalidation can remove // only alerts owned by the old gateway, never a newly delivered replacement. - for push in Set(requestedPushes + resolvedPushes) { + var seen = Set() + for push in requestedPushes + resolvedPushes { + guard let pushKey = Self.execApprovalPushKey(push), + seen.insert(pushKey).inserted + else { continue } await ExecApprovalNotificationBridge.removeNotifications( for: push, notificationCenter: self.notificationCenter) @@ -4818,17 +5429,23 @@ extension NodeAppModel { if lhsExpires != rhsExpires { return lhsExpires < rhsExpires } - return lhs.id < rhs.id - } - let pendingApprovalPushes = self.pendingWatchExecApprovalRecoveryPushes.sorted { lhs, rhs in - (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) - } - let pendingResolvedPushes = self.pendingExecApprovalResolvedPushes.sorted { lhs, rhs in - (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) + return Self.approvalIDSortsBefore(lhs.id, rhs.id) } + let pendingApprovalPushes = self.pendingWatchExecApprovalRecoveryPushes + .sorted(by: Self.execApprovalPushSortsBefore) + let pendingResolvedPushes = self.pendingExecApprovalResolvedPushes + .sorted(by: Self.execApprovalPushSortsBefore) + let approvalUncertainties = self.execApprovalUncertainties.map { key, state in + PersistedExecApprovalUncertainty( + approvalId: key.approvalID.rawValue, + gatewayStableID: key.gatewayID.rawValue, + message: state.message) + }.sorted(by: Self.persistedExecApprovalUncertaintySortsBefore) guard let data = try? JSONEncoder().encode( PersistedWatchExecApprovalBridgeState( approvals: approvals, + pendingApprovalReadbacks: self.pendingPersistedExecApprovalReadbacks, + approvalUncertainties: approvalUncertainties, pendingApprovalPushes: pendingApprovalPushes, pendingResolvedPushes: pendingResolvedPushes, pendingResolutions: pendingWatchExecApprovalResolutions)) @@ -4861,11 +5478,13 @@ extension NodeAppModel { } private func appendPendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) { - guard !self.pendingWatchExecApprovalRecoveryPushes.contains(push) else { return } + guard let pushKey = Self.execApprovalPushKey(push), + !self.pendingWatchExecApprovalRecoveryPushes.contains(where: { + Self.execApprovalPushKey($0) == pushKey + }) + else { return } self.pendingWatchExecApprovalRecoveryPushes.append(push) - self.pendingWatchExecApprovalRecoveryPushes.sort { lhs, rhs in - (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) - } + self.pendingWatchExecApprovalRecoveryPushes.sort(by: Self.execApprovalPushSortsBefore) GatewayDiagnostics.log( "watch exec approval: queued recovery " + "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)") @@ -4873,8 +5492,11 @@ extension NodeAppModel { } private func removePendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) { + guard let pushKey = Self.execApprovalPushKey(push) else { return } let originalCount = self.pendingWatchExecApprovalRecoveryPushes.count - self.pendingWatchExecApprovalRecoveryPushes.removeAll { $0 == push } + self.pendingWatchExecApprovalRecoveryPushes.removeAll { + Self.execApprovalPushKey($0) == pushKey + } guard self.pendingWatchExecApprovalRecoveryPushes.count != originalCount else { return } GatewayDiagnostics.log( "watch exec approval: cleared recovery " @@ -4883,74 +5505,146 @@ extension NodeAppModel { } private func appendPendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) { - guard !self.pendingExecApprovalResolvedPushes.contains(push) else { return } + guard let pushKey = Self.execApprovalPushKey(push), + !self.pendingExecApprovalResolvedPushes.contains(where: { + Self.execApprovalPushKey($0) == pushKey + }) + else { return } // A silent resolution push is not replayed by the gateway. Keep it until the // authenticated owner route returns so its matching notification cannot linger. self.pendingExecApprovalResolvedPushes.append(push) if self.pendingExecApprovalResolvedPushes.count > 32 { self.pendingExecApprovalResolvedPushes.removeFirst() } - self.pendingExecApprovalResolvedPushes.sort { lhs, rhs in - (lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId) - } + self.pendingExecApprovalResolvedPushes.sort(by: Self.execApprovalPushSortsBefore) self.persistWatchExecApprovalBridgeState() } private func removePendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) { + guard let pushKey = Self.execApprovalPushKey(push) else { return } let originalCount = self.pendingExecApprovalResolvedPushes.count - self.pendingExecApprovalResolvedPushes.removeAll { $0 == push } + self.pendingExecApprovalResolvedPushes.removeAll { + Self.execApprovalPushKey($0) == pushKey + } guard self.pendingExecApprovalResolvedPushes.count != originalCount else { return } self.persistWatchExecApprovalBridgeState() } - private func upsertWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt) { - guard self.isExecApprovalPromptCurrent(prompt) else { return } - self.watchExecApprovalPromptsByID[prompt.id] = prompt + private func removePendingPersistedExecApprovalReadback( + _ readback: PersistedExecApprovalReadback) + { + guard let readbackKey = Self.persistedExecApprovalReadbackKey(readback) else { return } + let originalCount = self.pendingPersistedExecApprovalReadbacks.count + self.pendingPersistedExecApprovalReadbacks.removeAll { + Self.persistedExecApprovalReadbackKey($0) == readbackKey + } + guard self.pendingPersistedExecApprovalReadbacks.count != originalCount else { return } self.persistWatchExecApprovalBridgeState() } - private func removeWatchExecApprovalPrompt(_ approvalId: String) { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return } - self.watchExecApprovalPromptsByID.removeValue(forKey: normalizedApprovalID) + private func appendPendingPersistedExecApprovalReadback( + approvalId: String, + gatewayStableID: String) + { + let readback = PersistedExecApprovalReadback( + approvalId: approvalId, + gatewayStableID: gatewayStableID) + guard let readbackKey = Self.persistedExecApprovalReadbackKey(readback), + !self.pendingPersistedExecApprovalReadbacks.contains(where: { + Self.persistedExecApprovalReadbackKey($0) == readbackKey + }) + else { return } + // A requested event is an edge trigger, not replayed state. Retain its exact owner + // until approval.get classifies it so a reconnect cannot lose a parked approval. + self.pendingPersistedExecApprovalReadbacks.append(readback) + if self.pendingPersistedExecApprovalReadbacks.count > 64 { + self.pendingPersistedExecApprovalReadbacks.removeFirst() + } + self.pendingPersistedExecApprovalReadbacks.sort( + by: Self.persistedExecApprovalReadbackSortsBefore) + self.persistWatchExecApprovalBridgeState() + } + + private func upsertWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt) { + guard self.isExecApprovalPromptCurrent(prompt), + let approvalID = Self.execApprovalIDKey(prompt.id), + let inboxKey = Self.execApprovalInboxKey(prompt), + !self.terminalExecApprovalKeys.contains(inboxKey) + else { return } + self.watchExecApprovalPromptsByID[approvalID] = prompt + self.execApprovalInboxPromptsByKey[inboxKey] = prompt + self.persistWatchExecApprovalBridgeState() + } + + private func markExecApprovalOwnerTerminal( + approvalId: String, + gatewayStableID: String) + { + guard let approvalID = Self.execApprovalIDKey(approvalId), + let inboxKey = Self.execApprovalInboxKey( + approvalID: approvalId, + gatewayStableID: gatewayStableID) + else { return } + if self.terminalExecApprovalKeys.insert(inboxKey).inserted { + self.terminalExecApprovalKeyOrder.append(inboxKey) + if self.terminalExecApprovalKeyOrder.count > 256 { + let evictedKey = self.terminalExecApprovalKeyOrder.removeFirst() + self.terminalExecApprovalKeys.remove(evictedKey) + } + } + self.execApprovalInboxPromptsByKey.removeValue(forKey: inboxKey) + self.dismissedExecApprovalPresentationKeys.remove(inboxKey) + self.resettableWatchResolutionAttempts.removeValue(forKey: inboxKey) + self.activeExecApprovalResolutionAttempts.removeValue(forKey: inboxKey) + self.execApprovalUncertainties.removeValue(forKey: inboxKey) + self.pendingPersistedExecApprovalReadbacks.removeAll { + Self.persistedExecApprovalReadbackKey($0) == PersistedExecApprovalReadbackKey( + approvalID: inboxKey.approvalID, + gatewayID: inboxKey.gatewayID) + } + if GatewayStableIdentifier.matches( + self.watchExecApprovalPromptsByID[approvalID]?.gatewayStableID, + gatewayStableID) + { + self.watchExecApprovalPromptsByID.removeValue(forKey: approvalID) + } self.persistWatchExecApprovalBridgeState() } private static func makeWatchExecApprovalItem(from prompt: ExecApprovalPrompt) -> OpenClawWatchExecApprovalItem { - let decisions = prompt.allowedDecisions.compactMap { decision in - let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines) - return OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision) - } + let decisions = prompt.allowedDecisions.compactMap(OpenClawWatchExecApprovalDecision.init(rawValue:)) let preview = Self.trimmedOrNil(prompt.commandPreview) ?? Self.trimmedOrNil(prompt.commandText) return OpenClawWatchExecApprovalItem( id: prompt.id, gatewayStableID: prompt.gatewayStableID, commandText: prompt.commandText, commandPreview: preview, + warningText: Self.trimmedOrNil(prompt.warningText), host: Self.trimmedOrNil(prompt.host), nodeId: Self.trimmedOrNil(prompt.nodeId), agentId: Self.trimmedOrNil(prompt.agentId), expiresAtMs: prompt.expiresAtMs, allowedDecisions: decisions, - // Prefer the watch's neutral/default presentation until exec.approval.get + // Prefer the watch's neutral/default presentation until approval.get // carries an explicit risk signal for exec approvals. risk: nil) } - private nonisolated static func shouldResetWatchExecApprovalResolvingStateOnPrompt( - reason: String) -> Bool + private func publishWatchExecApprovalPrompt( + _ prompt: ExecApprovalPrompt, + reason: String, + resetResolutionAttemptId: String? = nil, + syncSnapshots: Bool = true) async { - reason == "resolve_retry" - } - - private func publishWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt, reason: String) async { - guard self.isExecApprovalPromptCurrent(prompt) else { return } + guard self.isExecApprovalPromptCurrent(prompt), + let inboxKey = Self.execApprovalInboxKey(prompt), + !self.terminalExecApprovalKeys.contains(inboxKey) + else { return } let deliveryGeneration = self.gatewayConnectGeneration let message = OpenClawWatchExecApprovalPromptMessage( approval: Self.makeWatchExecApprovalItem(from: prompt), sentAtMs: Int64(Date().timeIntervalSince1970 * 1000), - deliveryId: UUID().uuidString, - resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason)) + resetResolutionAttemptId: resetResolutionAttemptId) do { _ = try await self.watchMessagingService.sendExecApprovalPrompt(message) self.watchExecApprovalLogger.debug( @@ -4962,6 +5656,7 @@ extension NodeAppModel { self.watchExecApprovalLogger.error( "watch approval prompt error=\(error.localizedDescription, privacy: .public)") } + guard syncSnapshots else { return } if deliveryGeneration != self.gatewayConnectGeneration { // WatchConnectivity may finish by durably queueing the old payload after a route // switch. Publish the replacement owner snapshots after that send completes. @@ -4977,44 +5672,92 @@ extension NodeAppModel { approvalId: String, gatewayStableID: String, decision: OpenClawWatchExecApprovalDecision?, - source: String) async + resolvedAtMs: Int64? = nil, + outcomeText: String? = nil, + source: String, + syncSnapshots: Bool = true) async { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return } - if self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == gatewayStableID { - self.removeWatchExecApprovalPrompt(normalizedApprovalID) - } + guard let approvalID = Self.validatedApprovalID(approvalId), + Self.execApprovalIDKey(approvalID) != nil + else { return } + self.markExecApprovalOwnerTerminal( + approvalId: approvalID, + gatewayStableID: gatewayStableID) let message = OpenClawWatchExecApprovalResolvedMessage( - approvalId: normalizedApprovalID, + approvalId: approvalID, gatewayStableID: gatewayStableID, decision: decision, - resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000), - source: source) + resolvedAtMs: resolvedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000), + source: source, + outcomeText: outcomeText) do { _ = try await self.watchMessagingService.sendExecApprovalResolved(message) } catch { self.watchExecApprovalLogger .error( - "watch approval resolve failed id=\(normalizedApprovalID, privacy: .public)") + "watch approval resolve failed id=\(approvalID, privacy: .public)") self.watchExecApprovalLogger.error( "watch approval resolve error=\(error.localizedDescription, privacy: .public)") } - await self.syncWatchAppSnapshot(reason: "resolved_app") - await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot") + if syncSnapshots { + await self.syncWatchAppSnapshot(reason: "resolved_app") + await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot") + } + } + + private func publishWatchExecApprovalTerminal( + _ terminal: ExecApprovalTerminalResult, + gatewayStableID: String, + source: String, + syncSnapshots: Bool = true) async + { + switch terminal.verdict { + case .allowOnce, .allowAlways, .deny: + await self.publishWatchExecApprovalResolved( + approvalId: terminal.id, + gatewayStableID: gatewayStableID, + decision: terminal.decision.flatMap(OpenClawWatchExecApprovalDecision.init(rawValue:)), + resolvedAtMs: terminal.resolvedAtMs, + outcomeText: Self.execApprovalTerminalText( + terminal, + alreadyResolved: source == "another-reviewer"), + source: source, + syncSnapshots: syncSnapshots) + case .expired: + await self.publishWatchExecApprovalExpired( + approvalId: terminal.id, + gatewayStableID: gatewayStableID, + reason: .expired, + syncSnapshots: syncSnapshots) + case .cancelled: + await self.publishWatchExecApprovalExpired( + approvalId: terminal.id, + gatewayStableID: gatewayStableID, + reason: .unavailable, + syncSnapshots: syncSnapshots) + case .resolvedUnknown: + await self.publishWatchExecApprovalExpired( + approvalId: terminal.id, + gatewayStableID: gatewayStableID, + reason: .resolved, + syncSnapshots: syncSnapshots) + } } private func publishWatchExecApprovalExpired( approvalId: String, gatewayStableID: String, - reason: OpenClawWatchExecApprovalCloseReason) async + reason: OpenClawWatchExecApprovalCloseReason, + syncSnapshots: Bool = true) async { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return } - if self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == gatewayStableID { - self.removeWatchExecApprovalPrompt(normalizedApprovalID) - } + guard let approvalID = Self.validatedApprovalID(approvalId), + Self.execApprovalIDKey(approvalID) != nil + else { return } + self.markExecApprovalOwnerTerminal( + approvalId: approvalID, + gatewayStableID: gatewayStableID) let message = OpenClawWatchExecApprovalExpiredMessage( - approvalId: normalizedApprovalID, + approvalId: approvalID, gatewayStableID: gatewayStableID, reason: reason, expiredAtMs: Int64(Date().timeIntervalSince1970 * 1000)) @@ -5023,16 +5766,20 @@ extension NodeAppModel { } catch { self.watchExecApprovalLogger .error( - "watch approval expiry failed id=\(normalizedApprovalID, privacy: .public)") + "watch approval expiry failed id=\(approvalID, privacy: .public)") self.watchExecApprovalLogger.error( "watch approval expiry error=\(error.localizedDescription, privacy: .public)") } - await self.syncWatchAppSnapshot(reason: "expired_\(reason.rawValue)_app") - await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)") + if syncSnapshots { + await self.syncWatchAppSnapshot(reason: "expired_\(reason.rawValue)_app") + await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)") + } } private func syncWatchExecApprovalSnapshot( reason: String, + requestId: String? = nil, + requestGatewayStableID: String? = nil, shouldContinue: @MainActor @Sendable () -> Bool = { true }) async { guard shouldContinue() else { return } @@ -5050,14 +5797,24 @@ extension NodeAppModel { if lhsExpires != rhsExpires { return lhsExpires < rhsExpires } - return lhs.id < rhs.id + return Self.approvalIDSortsBefore(lhs.id, rhs.id) } .map(Self.makeWatchExecApprovalItem) + let gatewayStableID = self.currentExecApprovalGatewayStableID() + let exactRequestGatewayStableID = GatewayStableIdentifier.exact(requestGatewayStableID) + let requestOwnerMatches = if let gatewayStableID, let exactRequestGatewayStableID { + GatewayStableIdentifier.matches(gatewayStableID, exactRequestGatewayStableID) + } else { + false + } + let canAcknowledgeRequest = requestId?.isEmpty == false && requestOwnerMatches let message = OpenClawWatchExecApprovalSnapshotMessage( approvals: approvals, - gatewayStableID: currentExecApprovalGatewayStableID(), + gatewayStableID: gatewayStableID, sentAtMs: Int64(Date().timeIntervalSince1970 * 1000), - snapshotId: UUID().uuidString) + snapshotId: UUID().uuidString, + requestId: canAcknowledgeRequest ? requestId : nil, + requestGatewayStableID: canAcknowledgeRequest ? exactRequestGatewayStableID : nil) do { guard shouldContinue() else { return } _ = try await self.watchMessagingService.syncExecApprovalSnapshot(message) @@ -5360,7 +6117,10 @@ extension NodeAppModel { self.watchMessageFlushInFlight = true defer { self.watchMessageFlushInFlight = false } guard let gatewayStableID = currentWatchChatGatewayStableID() else { return } - while self.currentWatchChatGatewayStableID() == gatewayStableID { + while GatewayStableIdentifier.matches( + self.currentWatchChatGatewayStableID(), + gatewayStableID) + { guard let event = watchMessageOutbox.nextQueuedMessage( isAvailable: isWatchMessageSendAvailable(), gatewayStableID: gatewayStableID) @@ -5401,19 +6161,18 @@ extension NodeAppModel { } private func currentWatchChatGatewayStableID() -> String? { - self.connectedGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines) + GatewayStableIdentifier.exact(self.connectedGatewayID) } private func normalizedWatchMessageGatewayStableID(_ event: WatchAppCommandEvent) -> String? { - let gatewayStableID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return gatewayStableID.isEmpty ? nil : gatewayStableID + GatewayStableIdentifier.exact(event.gatewayStableID) } private func watchMessageTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool { - let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event) ?? "" - let currentGatewayID = self.currentWatchChatGatewayStableID() ?? "" - guard !eventGatewayID.isEmpty, !currentGatewayID.isEmpty else { return false } - return eventGatewayID == currentGatewayID + guard let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event), + let currentGatewayID = self.currentWatchChatGatewayStableID() + else { return false } + return GatewayStableIdentifier.matches(eventGatewayID, currentGatewayID) } private func watchAppCommandTargetsCurrentGatewayIfTagged(_ event: WatchAppCommandEvent) -> Bool { @@ -5421,7 +6180,9 @@ extension NodeAppModel { // Ownerless commands predate route tagging and remain valid for compatibility. return true } - return eventGatewayID == self.currentWatchChatGatewayStableID() + return GatewayStableIdentifier.matches( + eventGatewayID, + self.currentWatchChatGatewayStableID()) } private func watchMessageKind(_ event: WatchAppCommandEvent) -> WatchMessageKind { @@ -5651,53 +6412,354 @@ extension NodeAppModel { } } - private func refreshWatchExecApprovalSnapshotOnDemand(reason: String) async { + private func refreshWatchExecApprovalSnapshotOnDemand( + reason: String, + requestId: String?, + requestGatewayStableID: String?, + heldApprovals: [WatchExecApprovalSnapshotRequestItem]) async + { GatewayDiagnostics.log("watch exec approval: refresh on demand start reason=\(reason)") - await self.hydrateWatchExecApprovalCacheIfNeeded(reason: reason) - await self.syncWatchExecApprovalSnapshot(reason: reason) + let currentGatewayStableID = self.currentExecApprovalGatewayStableID() + let requestOwnerMatches = GatewayStableIdentifier.matches( + currentGatewayStableID, + requestGatewayStableID) + let heldApprovalsToRead = requestOwnerMatches ? heldApprovals : [] + let hydrationWasAuthoritative = await self.hydrateWatchExecApprovalCacheIfNeeded( + reason: reason, + syncSnapshots: false) + let reconciliationWasAuthoritative = await self.reconcileWatchExecApprovalCache( + reason: reason, + heldApprovals: heldApprovalsToRead, + syncSnapshots: false) + guard hydrationWasAuthoritative, reconciliationWasAuthoritative else { + GatewayDiagnostics.log( + "watch exec approval: refresh on demand withheld snapshot reason=\(reason)") + return + } + await self.syncWatchExecApprovalSnapshot( + reason: reason, + requestId: requestOwnerMatches ? requestId : nil, + requestGatewayStableID: requestOwnerMatches ? requestGatewayStableID : nil) await self.syncWatchAppSnapshot(reason: "\(reason)_app", includeChat: true) GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)") } + @discardableResult + private func reconcileWatchExecApprovalCache( + reason: String, + heldApprovals: [WatchExecApprovalSnapshotRequestItem] = [], + syncSnapshots: Bool = true) async -> Bool + { + guard let gatewayStableID = self.currentExecApprovalGatewayStableID() else { return false } + var heldApprovalsByID: [ExecApprovalIdentifier.Key: WatchExecApprovalSnapshotRequestItem] = [:] + for heldApproval in heldApprovals { + guard let approvalID = Self.execApprovalIDKey(heldApproval.approvalId) else { continue } + if heldApprovalsByID[approvalID] == nil { + heldApprovalsByID[approvalID] = heldApproval + } + } + let prompts = self.watchExecApprovalPromptsByID.values + .filter(self.isExecApprovalPromptCurrent) + .sorted { Self.approvalIDSortsBefore($0.id, $1.id) } + let cachedApprovalIDs = Set(prompts.compactMap { Self.execApprovalIDKey($0.id) }) + let persistedReadbacks = self.pendingPersistedExecApprovalReadbacks.filter { + GatewayStableIdentifier.matches($0.gatewayStableID, gatewayStableID) && + Self.execApprovalIDKey($0.approvalId).map(cachedApprovalIDs.contains) != true + } + guard !prompts.isEmpty || !persistedReadbacks.isEmpty || !heldApprovalsByID.isEmpty else { + return true + } + + let visiblePromptAtStart = self.pendingExecApprovalPrompt + let visiblePromptWasResolving = self.pendingExecApprovalPromptResolving + let surfaceGenerationAtStart = self.pendingExecApprovalPromptSurfaceGeneration + + let cachedPass = await self.readBackCachedWatchExecApprovalPrompts( + prompts, + gatewayStableID: gatewayStableID, + reason: reason, + syncSnapshots: syncSnapshots) + let persistedPass = await self.readBackPersistedWatchExecApprovalReadbacks( + persistedReadbacks, + gatewayStableID: gatewayStableID, + syncSnapshots: syncSnapshots) + var classifiedApprovalIDs = cachedApprovalIDs + classifiedApprovalIDs.formUnion(persistedReadbacks.compactMap { + Self.execApprovalIDKey($0.approvalId) + }) + let heldPass = await self.readBackHeldWatchExecApprovals( + heldApprovalsByID, + alreadyClassifiedApprovalIDs: classifiedApprovalIDs, + gatewayStableID: gatewayStableID, + reason: reason, + syncSnapshots: syncSnapshots) + // Concatenation order mirrors the original readback order: cached prompts, + // persisted readbacks, then held Watch approvals. + var loadedPrompts = cachedPass.loadedPrompts + persistedPass.loadedPrompts + heldPass.loadedPrompts + let allReadbacksWereAuthoritative = cachedPass.allReadbacksWereAuthoritative && + persistedPass.allReadbacksWereAuthoritative && + heldPass.allReadbacksWereAuthoritative + + guard allReadbacksWereAuthoritative else { return false } + + // Readbacks can interleave with terminal events while awaiting other owners. + // Re-check the live owner table instead of replaying the stale local array. + loadedPrompts = loadedPrompts.filter { prompt in + guard let key = Self.execApprovalInboxKey(prompt), + !self.terminalExecApprovalKeys.contains(key) + else { return false } + return self.execApprovalInboxPromptsByKey[key] == prompt + } + + for prompt in loadedPrompts { + guard let approvalID = Self.execApprovalIDKey(prompt.id), + let heldAttemptID = heldApprovalsByID[approvalID]?.activeResolutionAttemptId, + let resetResolutionAttemptId = self.resettableWatchResolutionAttemptID( + for: prompt, + heldAttemptID: heldAttemptID) + else { continue } + await self.publishWatchExecApprovalPrompt( + prompt, + reason: "resolve_retry", + resetResolutionAttemptId: resetResolutionAttemptId, + syncSnapshots: syncSnapshots) + } + + let visiblePromptNow = self.pendingExecApprovalPrompt + let phoneSurfaceUnchanged = self.pendingExecApprovalPromptSurfaceGeneration == surfaceGenerationAtStart + let matchingVisiblePrompt = phoneSurfaceUnchanged ? visiblePromptNow.flatMap { visiblePrompt in + loadedPrompts.first { prompt in + Self.approvalIDsMatch(prompt.id, visiblePrompt.id) && + GatewayStableIdentifier.matches( + prompt.gatewayStableID, + visiblePrompt.gatewayStableID) + } + } : nil + let shouldRestorePhonePrompt = reason == "watch_request" || reason == "operator_reconnected" + let phoneSurfaceStayedEmpty = visiblePromptAtStart == nil && + visiblePromptNow == nil && + phoneSurfaceUnchanged + let firstUndismissedPrompt = loadedPrompts.first { prompt in + Self.execApprovalInboxKey(prompt).map { + !self.dismissedExecApprovalPresentationKeys.contains($0) + } == true + } + let selectedPhonePrompt = matchingVisiblePrompt ?? + (phoneSurfaceStayedEmpty && shouldRestorePhonePrompt ? firstUndismissedPrompt : nil) + + for prompt in loadedPrompts where selectedPhonePrompt.map({ + Self.approvalIDsMatch($0.id, prompt.id) && + GatewayStableIdentifier.matches($0.gatewayStableID, prompt.gatewayStableID) + }) != true && Self.execApprovalIDKey(prompt.id).flatMap({ + heldApprovalsByID[$0]?.activeResolutionAttemptId + }) == nil { + // A Watch-only resolve can lose its response while the iPhone has no visible + // prompt. Every canonical pending row must unlock its matching Watch card. + await self.publishWatchExecApprovalPrompt( + prompt, + reason: "resolve_retry", + syncSnapshots: syncSnapshots) + } + + guard let selectedPhonePrompt else { return allReadbacksWereAuthoritative } + let selectedPromptWasResolving = visiblePromptWasResolving && + visiblePromptAtStart.map { Self.approvalIDsMatch($0.id, selectedPhonePrompt.id) } == true && + visiblePromptNow.map { Self.approvalIDsMatch($0.id, selectedPhonePrompt.id) } == true && + phoneSurfaceUnchanged + let selectedPromptWriteIsInFlight = self.isExecApprovalResolutionWriteInFlight( + approvalID: selectedPhonePrompt.id, + gatewayStableID: selectedPhonePrompt.gatewayStableID) + self.presentFetchedExecApprovalPrompt(selectedPhonePrompt, publishReason: "resolve_retry") + if selectedPromptWasResolving, !selectedPromptWriteIsInFlight { + self.pendingExecApprovalPromptErrorText = + "The previous decision was not recorded. Review and try again." + } + return allReadbacksWereAuthoritative + } + + private struct WatchExecApprovalReadbackPass { + var loadedPrompts: [ExecApprovalPrompt] = [] + var allReadbacksWereAuthoritative = true + } + + private func readBackCachedWatchExecApprovalPrompts( + _ prompts: [ExecApprovalPrompt], + gatewayStableID: String, + reason: String, + syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass + { + var pass = WatchExecApprovalReadbackPass() + for cachedPrompt in prompts { + let persistedReadback = PersistedExecApprovalReadback( + approvalId: cachedPrompt.id, + gatewayStableID: cachedPrompt.gatewayStableID) + let readback = await self.fetchExecApprovalPrompt( + approvalId: cachedPrompt.id, + sourceReason: reason) + switch readback { + case let .loaded(prompt): + self.upsertWatchExecApprovalPrompt(prompt) + self.removePendingPersistedExecApprovalReadback(persistedReadback) + pass.loadedPrompts.append(prompt) + case let .terminal(terminal): + self.removePendingPersistedExecApprovalReadback(persistedReadback) + let outcome = await self.applyCanonicalExecApprovalTerminal( + terminal, + appliedHere: false, + gatewayStableID: gatewayStableID, + syncSnapshots: syncSnapshots) + if case .failed = outcome { + pass.allReadbacksWereAuthoritative = false + } + case .stale: + self.removePendingPersistedExecApprovalReadback(persistedReadback) + self.markPendingExecApprovalTerminal( + approvalId: cachedPrompt.id, + outcome: ExecApprovalOutcome( + text: "This approval is no longer available.", + tone: .warning)) + await self.publishWatchExecApprovalExpired( + approvalId: cachedPrompt.id, + gatewayStableID: gatewayStableID, + reason: .notFound, + syncSnapshots: syncSnapshots) + case .failed: + pass.allReadbacksWereAuthoritative = false + } + } + return pass + } + + private func readBackPersistedWatchExecApprovalReadbacks( + _ persistedReadbacks: [PersistedExecApprovalReadback], + gatewayStableID: String, + syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass + { + var pass = WatchExecApprovalReadbackPass() + for persistedReadback in persistedReadbacks { + let readback = await self.fetchExecApprovalPrompt( + approvalId: persistedReadback.approvalId, + sourceReason: "persisted_upgrade") + switch readback { + case let .loaded(prompt): + self.upsertWatchExecApprovalPrompt(prompt) + self.removePendingPersistedExecApprovalReadback(persistedReadback) + pass.loadedPrompts.append(prompt) + case let .terminal(terminal): + self.removePendingPersistedExecApprovalReadback(persistedReadback) + let outcome = await self.applyCanonicalExecApprovalTerminal( + terminal, + appliedHere: false, + gatewayStableID: gatewayStableID, + syncSnapshots: syncSnapshots) + if case .failed = outcome { + pass.allReadbacksWereAuthoritative = false + } + case .stale: + self.removePendingPersistedExecApprovalReadback(persistedReadback) + await self.publishWatchExecApprovalExpired( + approvalId: persistedReadback.approvalId, + gatewayStableID: gatewayStableID, + reason: .notFound, + syncSnapshots: syncSnapshots) + case .failed: + pass.allReadbacksWereAuthoritative = false + } + } + return pass + } + + private func readBackHeldWatchExecApprovals( + _ heldApprovalsByID: [ExecApprovalIdentifier.Key: WatchExecApprovalSnapshotRequestItem], + alreadyClassifiedApprovalIDs: Set, + gatewayStableID: String, + reason: String, + syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass + { + var pass = WatchExecApprovalReadbackPass() + var classifiedApprovalIDs = alreadyClassifiedApprovalIDs + for heldApproval in heldApprovalsByID.values.sorted(by: { + Self.approvalIDSortsBefore($0.approvalId, $1.approvalId) + }) { + guard let approvalID = Self.execApprovalIDKey(heldApproval.approvalId), + classifiedApprovalIDs.insert(approvalID).inserted + else { continue } + switch await self.fetchExecApprovalPrompt( + approvalId: heldApproval.approvalId, + sourceReason: reason) + { + case let .loaded(prompt): + self.upsertWatchExecApprovalPrompt(prompt) + pass.loadedPrompts.append(prompt) + case let .terminal(terminal): + let outcome = await self.applyCanonicalExecApprovalTerminal( + terminal, + appliedHere: false, + gatewayStableID: gatewayStableID, + syncSnapshots: syncSnapshots) + if case .failed = outcome { + pass.allReadbacksWereAuthoritative = false + } + case .stale: + await self.publishWatchExecApprovalExpired( + approvalId: heldApproval.approvalId, + gatewayStableID: gatewayStableID, + reason: .notFound, + syncSnapshots: syncSnapshots) + case .failed: + pass.allReadbacksWereAuthoritative = false + } + } + return pass + } + private nonisolated static func watchExecApprovalIDsNeedingFetch( candidateIDs: [String], cachedApprovalIDs: [String]) -> [String] { - let cachedIDs = Set(cachedApprovalIDs.compactMap { id -> String? in - let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines) - return normalizedID.isEmpty ? nil : normalizedID - }) + let cachedIDs = Set(cachedApprovalIDs.compactMap(Self.execApprovalIDKey)) var idsToFetch: [String] = [] - var seen = Set() - for rawID in candidateIDs { - let normalizedID = rawID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedID.isEmpty else { continue } - guard seen.insert(normalizedID).inserted else { continue } - guard !cachedIDs.contains(normalizedID) else { continue } - idsToFetch.append(normalizedID) + var seen = Set() + for candidateID in candidateIDs { + guard let approvalID = Self.execApprovalIDKey(candidateID) else { continue } + guard seen.insert(approvalID).inserted else { continue } + guard !cachedIDs.contains(approvalID) else { continue } + idsToFetch.append(approvalID.rawValue) } return idsToFetch } - private func hydrateWatchExecApprovalCacheIfNeeded(reason: String) async { + @discardableResult + private func hydrateWatchExecApprovalCacheIfNeeded( + reason: String, + syncSnapshots: Bool = true) async -> Bool + { self.pruneExpiredWatchExecApprovalPrompts() let approvalPushes = await pendingExecApprovalPushesForWatchRecovery() let missingApprovalIDs = Set(Self.watchExecApprovalIDsNeedingFetch( candidateIDs: approvalPushes.map(\.approvalId), - cachedApprovalIDs: Array(self.watchExecApprovalPromptsByID.keys))) + cachedApprovalIDs: self.watchExecApprovalPromptsByID.keys.map(\.rawValue)) + .compactMap(Self.execApprovalIDKey)) + let missingApprovalIDText = missingApprovalIDs + .map(\.rawValue) + .sorted(by: Self.approvalIDSortsBefore) + .joined(separator: ",") GatewayDiagnostics.log( "watch exec approval: hydrate candidates " + "reason=\(reason) ids=\(approvalPushes.map(\.approvalId).joined(separator: ",")) " - + "missing=\(missingApprovalIDs.sorted().joined(separator: ",")) " + + "missing=\(missingApprovalIDText) " + "cached=\(self.watchExecApprovalPromptsByID.count)") guard !missingApprovalIDs.isEmpty else { self.watchExecApprovalLogger.debug( "watch exec approval hydrate skipped reason=\(reason, privacy: .public): no missing approval ids") - return + return true } - for push in approvalPushes where missingApprovalIDs.contains(push.approvalId) { + var allReadbacksWereAuthoritative = true + for push in approvalPushes + where Self.execApprovalIDKey(push.approvalId).map(missingApprovalIDs.contains) == true + { let approvalId = push.approvalId GatewayDiagnostics.log( "watch exec approval: hydrate fetch start id=\(approvalId) reason=\(reason)") @@ -5706,6 +6768,7 @@ extension NodeAppModel { case let .validated(context): operatorRoute = context.route case .unavailable: + allReadbacksWereAuthoritative = false continue case .mismatchedOwner: await ExecApprovalNotificationBridge.removeNotifications( @@ -5722,6 +6785,20 @@ extension NodeAppModel { case let .loaded(prompt): GatewayDiagnostics.log("watch exec approval: hydrate fetch loaded id=\(approvalId)") self.upsertWatchExecApprovalPrompt(prompt) + case let .terminal(terminal): + GatewayDiagnostics.log( + "watch exec approval: hydrate fetch terminal id=\(approvalId) status=\(terminal.status)") + self.removePendingWatchExecApprovalRecoveryPush(push) + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + if let gatewayStableID = self.currentExecApprovalGatewayStableID() { + await self.publishWatchExecApprovalTerminal( + terminal, + gatewayStableID: gatewayStableID, + source: "gateway", + syncSnapshots: syncSnapshots) + } case .stale: GatewayDiagnostics.log("watch exec approval: hydrate fetch stale id=\(approvalId)") self.removePendingWatchExecApprovalRecoveryPush(push) @@ -5729,23 +6806,26 @@ extension NodeAppModel { for: push, notificationCenter: self.notificationCenter) case let .failed(message): + allReadbacksWereAuthoritative = false self.watchExecApprovalLogger .error("watch approval hydrate failed id=\(approvalId, privacy: .public)") self.watchExecApprovalLogger.error("watch approval hydrate reason=\(reason, privacy: .public)") self.watchExecApprovalLogger.error("watch approval hydrate error=\(message, privacy: .public)") } } + return allReadbacksWereAuthoritative } private func pendingExecApprovalPushesForWatchRecovery() async -> [ExecApprovalNotificationPrompt] { var pushes = self.pendingWatchExecApprovalRecoveryPushes - var seen = Set(pushes) + var seen = Set(pushes.compactMap(Self.execApprovalPushKey)) let delivered = await notificationCenter.deliveredNotifications() GatewayDiagnostics.log("watch exec approval: delivered notifications count=\(delivered.count)") for snapshot in delivered { guard let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: snapshot.userInfo), - seen.insert(push).inserted + let pushKey = Self.execApprovalPushKey(push), + seen.insert(pushKey).inserted else { continue } pushes.append(push) // Notification Center may be the only surviving source after relaunch. @@ -5758,11 +6838,10 @@ extension NodeAppModel { @discardableResult private func handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async -> Bool { - let normalizedApprovalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return true } + guard let approvalID = Self.validatedApprovalID(event.approvalId) else { return true } guard let routedEvent = ownerScopedWatchExecApprovalEvent( event, - approvalID: normalizedApprovalID) + approvalID: approvalID) else { await self.syncWatchExecApprovalSnapshot(reason: "legacy_watch_reply_rejected") return true @@ -5771,52 +6850,211 @@ extension NodeAppModel { self.enqueuePendingWatchExecApprovalResolution(routedEvent) return false } - guard Self.trimmedOrNil(routedEvent.gatewayStableID) == currentGatewayStableID else { + guard GatewayStableIdentifier.matches( + routedEvent.gatewayStableID, + currentGatewayStableID) + else { // Watch replies can arrive after a gateway switch. Reassert the current // snapshot instead of allowing an old same-ID prompt to target the new gateway. await self.syncWatchExecApprovalSnapshot(reason: "stale_gateway_reply") return true } - guard let prompt = watchExecApprovalPromptsByID[normalizedApprovalID], - prompt.gatewayStableID == currentGatewayStableID, - isExecApprovalPromptCurrent(prompt) - else { - await self.publishWatchExecApprovalExpired( - approvalId: normalizedApprovalID, - gatewayStableID: currentGatewayStableID, - reason: .unavailable) + let routeGeneration = self.gatewayRouteGeneration + let prompt: ExecApprovalPrompt + if let cachedPrompt = Self.execApprovalIDKey(approvalID) + .flatMap({ watchExecApprovalPromptsByID[$0] }), + GatewayStableIdentifier.matches( + cachedPrompt.gatewayStableID, + currentGatewayStableID), + isExecApprovalPromptCurrent(cachedPrompt) + { + prompt = cachedPrompt + } else { + switch await self.readBackWatchExecApprovalPromptForResolve( + approvalID: approvalID, + routedEvent: routedEvent, + currentGatewayStableID: currentGatewayStableID, + routeGeneration: routeGeneration) + { + case let .prompt(loadedPrompt): + prompt = loadedPrompt + case let .handled(completed): + return completed + } + } + guard prompt.allowedDecisions.contains(routedEvent.decision.rawValue) else { + self.markWatchResolutionAttemptResettable(routedEvent) + let resetResolutionAttemptId = self.resettableWatchResolutionAttemptID( + for: prompt, + heldAttemptID: routedEvent.replyId) + await self.publishWatchExecApprovalPrompt( + prompt, + reason: "resolve_retry", + resetResolutionAttemptId: resetResolutionAttemptId) return true } - if self.pendingExecApprovalPrompt?.id == normalizedApprovalID { + guard let resolutionAttempt = self.beginExecApprovalResolutionAttempt( + approvalID: prompt.id, + gatewayStableID: prompt.gatewayStableID) + else { + // Serialize phone and Watch writes by exact owner + ID. The delivered Watch + // action remains durable until the active contender releases this lease. + self.enqueuePendingWatchExecApprovalResolution(routedEvent) + return false + } + defer { self.finishExecApprovalResolutionAttempt(resolutionAttempt) } + + if self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalID) }) == true, + GatewayStableIdentifier.matches( + self.pendingExecApprovalPrompt?.gatewayStableID, + prompt.gatewayStableID) + { self.pendingExecApprovalPromptResolving = true self.pendingExecApprovalPromptErrorText = nil } let outcome = await resolveExecApprovalNotificationDecision( - approvalId: normalizedApprovalID, + approvalId: approvalID, + approvalKind: prompt.kind, decision: routedEvent.decision.rawValue, expectedGatewayStableID: prompt.gatewayStableID, - sourceReason: "watch_resolve") - if case let .failed(message) = outcome { - if self.pendingExecApprovalPrompt?.id == normalizedApprovalID { - self.pendingExecApprovalPromptResolving = false - self.pendingExecApprovalPromptErrorText = message - } - if let prompt = watchExecApprovalPromptsByID[normalizedApprovalID] { - await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry") - } - return false + sourceReason: "watch_resolve", + resolutionAttempt: resolutionAttempt) + if case let .uncertain(message) = outcome { + // Same contract as the phone path: a gateway switch invalidates the attempt, + // but the owner-scoped uncertainty + readback record must persist so the + // delivered Watch decision is never silently dropped without a trace. + self.markExecApprovalResolutionUncertain( + approvalID: approvalID, + gatewayStableID: prompt.gatewayStableID, + message: message) } - return true + guard self.isActiveExecApprovalResolutionAttempt(resolutionAttempt) else { return true } + switch outcome { + case .resolved, .stale: + return true + case let .pendingRetry(message): + self.markWatchResolutionAttemptResettable(routedEvent) + self.finishExecApprovalResolutionAttempt(resolutionAttempt) + // Readback definitively classified the approval as still pending. The + // lease-wide presentation fence left any re-presented phone card resolving, + // so releasing the lease must also unlock this exact owner's card. + self.unlockPendingExecApprovalPromptForRetry( + approvalID: approvalID, + gatewayStableID: prompt.gatewayStableID, + message: message) + await self.republishCachedWatchExecApprovalPromptForRetry( + approvalID: approvalID, + heldAttemptID: routedEvent.replyId) + return true + case .uncertain: + // Recorded above, before the attempt gate. + return true + case let .failed(message): + self.markWatchResolutionAttemptResettable(routedEvent) + self.finishExecApprovalResolutionAttempt(resolutionAttempt) + self.unlockPendingExecApprovalPromptForRetry( + approvalID: approvalID, + gatewayStableID: prompt.gatewayStableID, + message: message) + await self.republishCachedWatchExecApprovalPromptForRetry( + approvalID: approvalID, + heldAttemptID: routedEvent.replyId) + return true + } + } + + /// Mirrors the phone path's settled non-terminal handling: when a lease releases + /// with the approval still pending (or the write failed), the presented card for + /// this exact owner becomes actionable again with the retry message. + private func unlockPendingExecApprovalPromptForRetry( + approvalID: String, + gatewayStableID: String, + message: String) + { + guard self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalID) }) == true, + GatewayStableIdentifier.matches( + self.pendingExecApprovalPrompt?.gatewayStableID, + gatewayStableID) + else { return } + self.pendingExecApprovalPromptResolving = false + self.pendingExecApprovalPromptErrorText = message + } + + private enum WatchExecApprovalResolveReadback { + case prompt(ExecApprovalPrompt) + case handled(completed: Bool) + } + + private func readBackWatchExecApprovalPromptForResolve( + approvalID: String, + routedEvent: WatchExecApprovalResolveEvent, + currentGatewayStableID: String, + routeGeneration: UInt64) async -> WatchExecApprovalResolveReadback + { + let readback = await self.fetchExecApprovalPrompt( + approvalId: approvalID, + sourceReason: "watch_resolve") + guard self.isCurrentExecApprovalReadbackRoute( + generation: routeGeneration, + stableID: currentGatewayStableID) + else { + await self.syncWatchExecApprovalSnapshot(reason: "watch_resolve_route_changed") + return .handled(completed: true) + } + switch readback { + case let .loaded(loadedPrompt): + guard self.isExecApprovalPromptCurrent(loadedPrompt) else { + await self.syncWatchExecApprovalSnapshot(reason: "watch_resolve_owner_changed") + return .handled(completed: true) + } + self.upsertWatchExecApprovalPrompt(loadedPrompt) + return .prompt(loadedPrompt) + case let .terminal(terminal): + _ = await self.applyCanonicalExecApprovalTerminal( + terminal, + appliedHere: false, + gatewayStableID: currentGatewayStableID) + return .handled(completed: true) + case .stale: + await self.publishWatchExecApprovalExpired( + approvalId: approvalID, + gatewayStableID: currentGatewayStableID, + reason: .notFound) + return .handled(completed: true) + case .failed: + // No write was attempted. Retain the owner-bound action for the next + // operator connection instead of falsely reporting it as unavailable. + self.enqueuePendingWatchExecApprovalResolution(routedEvent) + await self.syncWatchExecApprovalSnapshot(reason: "watch_resolve_readback_failed") + return .handled(completed: false) + } + } + + private func republishCachedWatchExecApprovalPromptForRetry( + approvalID: String, + heldAttemptID: String) async + { + guard let prompt = Self.execApprovalIDKey(approvalID) + .flatMap({ self.watchExecApprovalPromptsByID[$0] }) + else { return } + await self.publishWatchExecApprovalPrompt( + prompt, + reason: "resolve_retry", + resetResolutionAttemptId: self.resettableWatchResolutionAttemptID( + for: prompt, + heldAttemptID: heldAttemptID)) } private func ownerScopedWatchExecApprovalEvent( _ event: WatchExecApprovalResolveEvent, approvalID: String) -> WatchExecApprovalResolveEvent? { - if Self.trimmedOrNil(event.gatewayStableID) != nil { + if GatewayStableIdentifier.exact(event.gatewayStableID) != nil { return event } - guard let prompt = watchExecApprovalPromptsByID[approvalID] else { return nil } + guard let approvalKey = Self.execApprovalIDKey(approvalID), + let prompt = watchExecApprovalPromptsByID[approvalKey] + else { return nil } // A shipped Watch binary can omit the owner field. Bind only to the prompt that // originally supplied this approval ID; never infer ownership from a later route. var routedEvent = event @@ -5825,9 +7063,10 @@ extension NodeAppModel { } private func enqueuePendingWatchExecApprovalResolution(_ event: WatchExecApprovalResolveEvent) { - let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !replyID.isEmpty, - !self.pendingWatchExecApprovalResolutions.contains(where: { $0.replyId == replyID }) + guard let replyID = ExactOpaqueIdentifier.key(event.replyId), + !self.pendingWatchExecApprovalResolutions.contains(where: { + ExactOpaqueIdentifier.key($0.replyId) == replyID + }) else { return } // transferUserInfo is durable only until delivery. Retain the delivered action until // startup restores a route, while bounding malformed or replayed Watch traffic. @@ -5839,8 +7078,11 @@ extension NodeAppModel { } private func removePendingWatchExecApprovalResolution(replyID: String) { + guard let replyKey = ExactOpaqueIdentifier.key(replyID) else { return } let originalCount = self.pendingWatchExecApprovalResolutions.count - self.pendingWatchExecApprovalResolutions.removeAll { $0.replyId == replyID } + self.pendingWatchExecApprovalResolutions.removeAll { + ExactOpaqueIdentifier.key($0.replyId) == replyKey + } guard self.pendingWatchExecApprovalResolutions.count != originalCount else { return } self.persistWatchExecApprovalBridgeState() } @@ -5848,15 +7090,22 @@ extension NodeAppModel { private func flushPendingWatchExecApprovalResolutions( shouldContinue: @MainActor @Sendable () -> Bool = { true }) async { - guard shouldContinue(), !self.pendingWatchExecApprovalResolutions.isEmpty else { return } + guard shouldContinue(), + !self.pendingWatchExecApprovalResolutions.isEmpty, + !self.pendingWatchExecApprovalResolutionFlushInFlight + else { return } + self.pendingWatchExecApprovalResolutionFlushInFlight = true + defer { self.pendingWatchExecApprovalResolutionFlushInFlight = false } await self.hydrateWatchExecApprovalCacheIfNeeded(reason: "queued_watch_resolve") guard shouldContinue(), let currentGatewayStableID = currentExecApprovalGatewayStableID() else { return } let pending = self.pendingWatchExecApprovalResolutions var discardedMismatchedOwner = false for event in pending { guard shouldContinue() else { return } - let owner = Self.trimmedOrNil(event.gatewayStableID) - guard owner == currentGatewayStableID else { + guard GatewayStableIdentifier.matches( + event.gatewayStableID, + currentGatewayStableID) + else { discardedMismatchedOwner = true self.removePendingWatchExecApprovalResolution(replyID: event.replyId) continue @@ -5872,8 +7121,7 @@ extension NodeAppModel { } func handleExecApprovalRequestedRemotePush(_ push: ExecApprovalNotificationPrompt) async -> Bool { - let normalizedApprovalID = push.approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return false } + guard let approvalID = Self.validatedApprovalID(push.approvalId) else { return false } let operatorRoute: GatewayNodeSessionRoute switch await self.validateExecApprovalPushRoute(push, sourceReason: "push_request") { case let .validated(context): @@ -5893,7 +7141,7 @@ extension NodeAppModel { self.appendPendingWatchExecApprovalRecoveryPush(push) guard let gatewayStableID = currentExecApprovalGatewayStableID() else { return true } let fetchedPrompt = await fetchExecApprovalPrompt( - approvalId: normalizedApprovalID, + approvalId: approvalID, sourceReason: "push_request", expectedOperatorRoute: operatorRoute) switch fetchedPrompt { @@ -5901,21 +7149,32 @@ extension NodeAppModel { self.upsertWatchExecApprovalPrompt(prompt) await self.publishWatchExecApprovalPrompt(prompt, reason: "push_request") return true + case let .terminal(terminal): + await ExecApprovalNotificationBridge.removeNotifications( + for: push, + notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(push) + self.clearPendingExecApprovalPromptIfMatches(approvalID) + await self.publishWatchExecApprovalTerminal( + terminal, + gatewayStableID: gatewayStableID, + source: "gateway") + return true case .stale: await ExecApprovalNotificationBridge.removeNotifications( for: push, notificationCenter: self.notificationCenter) self.removePendingWatchExecApprovalRecoveryPush(push) - self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) + self.clearPendingExecApprovalPromptIfMatches(approvalID) await self.publishWatchExecApprovalExpired( - approvalId: normalizedApprovalID, + approvalId: approvalID, gatewayStableID: gatewayStableID, reason: .notFound) return true case let .failed(message): self.watchExecApprovalLogger .error( - "watch approval push fetch failed id=\(normalizedApprovalID, privacy: .public)") + "watch approval push fetch failed id=\(approvalID, privacy: .public)") self.watchExecApprovalLogger.error("watch approval push fetch error=\(message, privacy: .public)") return false } @@ -5929,52 +7188,112 @@ extension NodeAppModel { shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> Bool { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty, + guard let approvalID = Self.validatedApprovalID(approvalId), await self.canApplyExecApprovalResolvedState( routeContext: routeContext, shouldContinue: shouldContinue) else { return false } let currentGatewayStableID = self.currentExecApprovalGatewayStableID() - let hadWatchPrompt = if let currentGatewayStableID { - self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == currentGatewayStableID + let hadWatchPrompt = if let currentGatewayStableID, + let approvalKey = Self.execApprovalIDKey(approvalID), + let watchPrompt = self.watchExecApprovalPromptsByID[approvalKey] + { + GatewayStableIdentifier.matches( + watchPrompt.gatewayStableID, + currentGatewayStableID) } else { false } let hadPendingPrompt = if let currentGatewayStableID { - self.pendingExecApprovalPrompt?.id == normalizedApprovalID && - self.pendingExecApprovalPrompt?.gatewayStableID == currentGatewayStableID + self.pendingExecApprovalPrompt.map { Self.approvalIDsMatch($0.id, approvalID) } == true && + GatewayStableIdentifier.matches( + self.pendingExecApprovalPrompt?.gatewayStableID, + currentGatewayStableID) } else { false } - let recoveryPushes: [ExecApprovalNotificationPrompt] = if let recoveryPushGatewayDeviceID = Self - .trimmedOrNil(recoveryPushGatewayDeviceID) + let recoveryPushes: [ExecApprovalNotificationPrompt] = if let recoveryPushGatewayDeviceID = + GatewayStableIdentifier.key(recoveryPushGatewayDeviceID) { self.pendingWatchExecApprovalRecoveryPushes.filter { push in - push.approvalId == normalizedApprovalID && - Self.trimmedOrNil(push.gatewayDeviceId) == recoveryPushGatewayDeviceID + Self.approvalIDsMatch(push.approvalId, approvalID) && + GatewayStableIdentifier.key(push.gatewayDeviceId) == recoveryPushGatewayDeviceID } } else { [] } let hadPendingRecoveryID = !recoveryPushes.isEmpty - let hadGuidancePrompt = self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID + let hadGuidancePrompt = self.pendingNotificationPermissionGuidancePrompt.map { + Self.approvalIDsMatch($0.approvalId, approvalID) + } == true let hadApprovalSurface = hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID guard hadApprovalSurface || hadGuidancePrompt else { return true } - if hadApprovalSurface, let currentGatewayStableID { - await self.publishWatchExecApprovalExpired( - approvalId: normalizedApprovalID, - gatewayStableID: currentGatewayStableID, - reason: .resolved) - guard await self.canApplyExecApprovalResolvedState( - routeContext: routeContext, - shouldContinue: shouldContinue) - else { return false } + guard let currentGatewayStableID else { return false } + let readback = await self.fetchExecApprovalPrompt( + approvalId: approvalID, + sourceReason: "resolved_event", + expectedOperatorRoute: routeContext?.route, + shouldContinue: shouldContinue) + guard await self.canApplyExecApprovalResolvedState( + routeContext: routeContext, + shouldContinue: shouldContinue) + else { return false } + + switch readback { + case let .terminal(terminal): + self.markPendingExecApprovalTerminal( + terminal, + alreadyResolved: true) + if hadApprovalSurface { + await self.publishWatchExecApprovalTerminal( + terminal, + gatewayStableID: currentGatewayStableID, + source: "another-reviewer") + } + case let .loaded(prompt): + // A delayed or duplicate resolved signal cannot override the canonical + // pending row. Re-publish it and re-enable only after this readback. + if let currentPrompt = self.pendingExecApprovalPrompt, + !Self.approvalIDsMatch(currentPrompt.id, prompt.id) || + !GatewayStableIdentifier.matches( + currentPrompt.gatewayStableID, + prompt.gatewayStableID) + { + self.upsertWatchExecApprovalPrompt(prompt) + await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry") + } else { + self.presentFetchedExecApprovalPrompt(prompt, publishReason: "resolve_retry") + } + return true + case .stale: + let terminal = ExecApprovalTerminalResult( + id: approvalID, + verdict: .resolvedUnknown, + resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000)) + self.markPendingExecApprovalTerminal( + terminal, + alreadyResolved: true) + if hadApprovalSurface { + await self.publishWatchExecApprovalTerminal( + terminal, + gatewayStableID: currentGatewayStableID, + source: "another-reviewer") + } + case let .failed(message): + self.watchExecApprovalLogger.error( + "approval terminal readback failed id=\(approvalID, privacy: .public)") + self.watchExecApprovalLogger.error( + "approval terminal readback error=\(message, privacy: .public)") + return false } + guard await self.canApplyExecApprovalResolvedState( + routeContext: routeContext, + shouldContinue: shouldContinue) + else { return false } for push in recoveryPushes { await ExecApprovalNotificationBridge.removeNotifications( for: push, @@ -5989,7 +7308,7 @@ extension NodeAppModel { routeContext: routeContext, shouldContinue: shouldContinue) else { return false } - self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) + self.clearNotificationPermissionGuidancePromptIfMatches(approvalID) return true } @@ -6014,7 +7333,7 @@ extension NodeAppModel { } case .unavailable: self.appendPendingExecApprovalResolvedPush(push) - if Self.trimmedOrNil(push.gatewayDeviceId) != nil { + if GatewayStableIdentifier.exact(push.gatewayDeviceId) != nil { // The terminal push already identifies its notification owner. Remove that // exact alert now while retaining durable state for route-bound Watch cleanup. await ExecApprovalNotificationBridge.removeNotifications( @@ -6353,7 +7672,7 @@ extension NodeAppModel { lastToken: String?, lastGatewayStableID: String?) -> Bool { - token != lastToken || gatewayStableID != lastGatewayStableID + token != lastToken || !GatewayStableIdentifier.matches(gatewayStableID, lastGatewayStableID) } private func fetchPushRelayGatewayIdentity( @@ -6364,10 +7683,21 @@ extension NodeAppModel { paramsJSON: "{}", timeoutSeconds: 8, ifCurrentRoute: expectedRoute) + if let expectedRoute, + await self.operatorGateway.currentRoute() != expectedRoute + { + throw PushRelayError.relayMisconfigured("Gateway identity route changed during readback") + } + return try Self.decodePushRelayGatewayIdentity(response) + } + + private nonisolated static func decodePushRelayGatewayIdentity( + _ response: Data) throws -> PushRelayGatewayIdentity + { let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response) - let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines) + let deviceId = GatewayStableIdentifier.exact(decoded.deviceId) let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !deviceId.isEmpty, !publicKey.isEmpty else { + guard let deviceId, !publicKey.isEmpty else { throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields") } return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey) @@ -6415,32 +7745,11 @@ extension NodeAppModel { return "unknown" } - private struct ExecApprovalGetRequest: Encodable { - let id: String - } - - private struct ExecApprovalResolveRequest: Encodable { - let id: String - let decision: String - } - - private struct ExecApprovalGetResponse: Decodable { - var id: String - var commandText: String - var commandPreview: String? - var allowedDecisions: [String] - var host: String? - var nodeId: String? - var agentId: String? - var expiresAtMs: Int64? - } - func presentExecApprovalNotificationPrompt( _ prompt: ExecApprovalNotificationPrompt, shouldContinue: @MainActor @Sendable () -> Bool = { true }) async { - let approvalId = prompt.approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard shouldContinue(), !approvalId.isEmpty else { return } + guard shouldContinue(), let approvalId = Self.validatedApprovalID(prompt.approvalId) else { return } let operatorRoute: GatewayNodeSessionRoute switch await self.validateExecApprovalPushRoute( prompt, @@ -6486,28 +7795,79 @@ extension NodeAppModel { expectedOperatorRoute: GatewayNodeSessionRoute?, shouldContinue: @MainActor @Sendable () -> Bool) async { - guard shouldContinue(), !approvalId.isEmpty else { return } + guard shouldContinue(), Self.validatedApprovalID(approvalId) != nil else { return } + let persistedReadback = self.currentExecApprovalGatewayStableID().map { + PersistedExecApprovalReadback( + approvalId: approvalId, + gatewayStableID: $0) + } self.pendingExecApprovalPromptRequestGeneration &+= 1 let requestGeneration = self.pendingExecApprovalPromptRequestGeneration - self.pendingExecApprovalPromptResolving = true - self.pendingExecApprovalPromptErrorText = nil + let visiblePromptAtStart = self.pendingExecApprovalPrompt + let surfaceGenerationAtStart = self.pendingExecApprovalPromptSurfaceGeneration + if self.canMutatePendingExecApprovalPromptState(for: approvalId) { + self.pendingExecApprovalPromptResolving = true + self.pendingExecApprovalPromptErrorText = nil + self.pendingExecApprovalPromptOutcome = nil + } let fetchedPrompt = await fetchExecApprovalPrompt( approvalId: approvalId, expectedOperatorRoute: expectedOperatorRoute, shouldContinue: shouldContinue) guard shouldContinue(), self.pendingExecApprovalPromptRequestGeneration == requestGeneration else { - if self.pendingExecApprovalPromptRequestGeneration == requestGeneration { + if self.pendingExecApprovalPromptRequestGeneration == requestGeneration, + self.canMutatePendingExecApprovalPromptState(for: approvalId) + { self.pendingExecApprovalPromptResolving = false } return } - self.pendingExecApprovalPromptResolving = false + if self.canMutatePendingExecApprovalPromptState(for: approvalId) { + self.pendingExecApprovalPromptResolving = false + } switch fetchedPrompt { case let .loaded(fetchedPrompt): - self.presentFetchedExecApprovalPrompt(fetchedPrompt) + if let persistedReadback { + self.removePendingPersistedExecApprovalReadback(persistedReadback) + } + let visiblePromptNow = self.pendingExecApprovalPrompt + let phoneSurfaceUnchanged = self.pendingExecApprovalPromptSurfaceGeneration == surfaceGenerationAtStart + // A notification tap explicitly selects a review surface. Passive events may + // warm Watch state, but must not replace another visible phone approval. + let explicitlySelectedFromNotification = notificationPush != nil + let canPresentLoadedPrompt = phoneSurfaceUnchanged && + (explicitlySelectedFromNotification || + visiblePromptNow.map { Self.approvalIDsMatch($0.id, approvalId) } == true || + (visiblePromptAtStart == nil && visiblePromptNow == nil)) + if canPresentLoadedPrompt { + self.presentFetchedExecApprovalPrompt(fetchedPrompt) + } else { + self.upsertWatchExecApprovalPrompt(fetchedPrompt) + await self.publishWatchExecApprovalPrompt(fetchedPrompt, reason: "present_prompt") + } + case let .terminal(terminal): + if let persistedReadback { + self.removePendingPersistedExecApprovalReadback(persistedReadback) + } + if let notificationPush { + await ExecApprovalNotificationBridge.removeNotifications( + for: notificationPush, + notificationCenter: self.notificationCenter) + self.removePendingWatchExecApprovalRecoveryPush(notificationPush) + } + self.clearPendingExecApprovalPromptIfMatches(approvalId) + if let gatewayStableID = currentExecApprovalGatewayStableID() { + await self.publishWatchExecApprovalTerminal( + terminal, + gatewayStableID: gatewayStableID, + source: "gateway") + } case .stale: + if let persistedReadback { + self.removePendingPersistedExecApprovalReadback(persistedReadback) + } if let notificationPush { await ExecApprovalNotificationBridge.removeNotifications( for: notificationPush, @@ -6528,44 +7888,348 @@ extension NodeAppModel { } } + private func canMutatePendingExecApprovalPromptState(for approvalId: String) -> Bool { + guard let prompt = self.pendingExecApprovalPrompt else { return true } + return Self.approvalIDsMatch(prompt.id, approvalId) + } + private enum ExecApprovalPromptFetchOutcome { case loaded(ExecApprovalPrompt) + case terminal(ExecApprovalTerminalResult) case stale case failed(message: String) } - private func presentFetchedExecApprovalPrompt(_ prompt: ExecApprovalPrompt) { - guard self.isExecApprovalPromptCurrent(prompt) else { return } + private func presentFetchedExecApprovalPrompt( + _ prompt: ExecApprovalPrompt, + publishReason: String = "present_prompt") + { + guard self.isExecApprovalPromptCurrent(prompt), + let inboxKey = Self.execApprovalInboxKey(prompt), + !self.terminalExecApprovalKeys.contains(inboxKey) + else { return } + let uncertainResolutionMessage = self.execApprovalUncertainties[inboxKey]?.message + // Attempt presence, not writeInFlight: resolution paths settle the write before + // awaiting readback classification, and taps stay fenced until the lease drops. + let preserveActiveResolution = uncertainResolutionMessage != nil || self.hasActiveExecApprovalResolutionAttempt( + approvalID: prompt.id, + gatewayStableID: prompt.gatewayStableID) + self.pendingExecApprovalPromptSurfaceGeneration &+= 1 + self.dismissedExecApprovalPresentationKeys.remove(inboxKey) self.pendingExecApprovalPrompt = prompt - self.pendingExecApprovalPromptResolving = false - self.pendingExecApprovalPromptErrorText = nil + if let uncertainResolutionMessage { + self.pendingExecApprovalPromptResolving = true + self.pendingExecApprovalPromptErrorText = uncertainResolutionMessage + self.pendingExecApprovalPromptOutcome = nil + } else if preserveActiveResolution { + // Re-presenting while the owner write fence is held (e.g. after a gateway + // round-trip cleared the surface flags) must render as resolving, or the + // card would look actionable while the fence rejects new attempts. + self.pendingExecApprovalPromptResolving = true + } else { + self.pendingExecApprovalPromptResolving = false + self.pendingExecApprovalPromptErrorText = nil + self.pendingExecApprovalPromptOutcome = nil + } self.upsertWatchExecApprovalPrompt(prompt) Task { @MainActor [weak self] in - await self?.publishWatchExecApprovalPrompt(prompt, reason: "present_prompt") + await self?.publishWatchExecApprovalPrompt(prompt, reason: publishReason) } } private static func makeExecApprovalPrompt( - from details: ExecApprovalGetResponse, + from snapshot: PendingApprovalSnapshot, + expectedApprovalID: String, gatewayStableID: String) -> ExecApprovalPrompt? { - let approvalId = details.id.trimmingCharacters(in: .whitespacesAndNewlines) - let commandText = details.commandText.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedGatewayStableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !approvalId.isEmpty, !commandText.isEmpty, !normalizedGatewayStableID.isEmpty else { return nil } + guard self.approvalIDsMatch(snapshot.id, expectedApprovalID), + !snapshot.urlpath.isEmpty, + snapshot.createdatms >= 0, + snapshot.expiresatms >= 0, + case let .exec(presentation) = snapshot.presentation, + self.isValidExecApprovalPresentation(presentation) + else { + return nil + } + return self.makeExecApprovalPrompt(ExecApprovalPrompt( + id: snapshot.id, + kind: presentation.kind, + gatewayStableID: gatewayStableID, + commandText: presentation.commandtext, + commandPreview: self.approvalPresentationString(presentation.commandpreview), + warningText: self.approvalPresentationString(presentation.warningtext), + allowedDecisions: presentation.alloweddecisions.map(\.rawValue), + host: self.approvalPresentationString(presentation.host), + nodeId: self.approvalPresentationString(presentation.nodeid), + agentId: self.approvalPresentationString(presentation.agentid), + expiresAtMs: Int64(snapshot.expiresatms))) + } + + private static func makeExecApprovalPrompt( + from result: LegacyExecApprovalGetResult, + expectedApprovalID: String, + gatewayStableID: String) -> ExecApprovalPrompt? + { + guard self.approvalIDsMatch(result.id, expectedApprovalID) else { return nil } + return self.makeExecApprovalPrompt(ExecApprovalPrompt( + id: result.id, + kind: ApprovalKind.exec.rawValue, + gatewayStableID: gatewayStableID, + commandText: result.commandText, + commandPreview: result.commandPreview, + warningText: result.warningText, + allowedDecisions: result.allowedDecisions, + host: result.host, + nodeId: result.nodeId, + agentId: result.agentId, + expiresAtMs: result.expiresAtMs)) + } + + private static func makeExecApprovalPrompt(_ input: ExecApprovalPrompt) -> ExecApprovalPrompt? { + guard let approvalId = self.validatedApprovalID(input.id) else { return nil } + let approvalKind = input.kind ?? "" + let normalizedCommandText = input.commandText.trimmingCharacters(in: .whitespacesAndNewlines) + let exactGatewayStableID = GatewayStableIdentifier.exact(input.gatewayStableID) + guard approvalKind == ApprovalKind.exec.rawValue, + !normalizedCommandText.isEmpty, + let exactGatewayStableID + else { + return nil + } + let decisions = input.allowedDecisions + guard decisions.count == Set(decisions).count, + decisions.allSatisfy({ ApprovalDecision(rawValue: $0) != nil }), + decisions.contains(ApprovalDecision.deny.rawValue) + else { + return nil + } return ExecApprovalPrompt( id: approvalId, - gatewayStableID: normalizedGatewayStableID, - commandText: commandText, - commandPreview: details.commandPreview?.trimmingCharacters(in: .whitespacesAndNewlines), - allowedDecisions: details.allowedDecisions.compactMap { decision in - let trimmed = decision.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }, - host: details.host?.trimmingCharacters(in: .whitespacesAndNewlines), - nodeId: details.nodeId?.trimmingCharacters(in: .whitespacesAndNewlines), - agentId: details.agentId?.trimmingCharacters(in: .whitespacesAndNewlines), - expiresAtMs: details.expiresAtMs) + kind: approvalKind, + gatewayStableID: exactGatewayStableID, + commandText: normalizedCommandText, + commandPreview: self.trimmedOrNil(input.commandPreview), + warningText: self.trimmedOrNil(input.warningText), + allowedDecisions: decisions, + host: self.trimmedOrNil(input.host), + nodeId: self.trimmedOrNil(input.nodeId), + agentId: self.trimmedOrNil(input.agentId), + expiresAtMs: input.expiresAtMs) + } + + private static func approvalPresentationString(_ value: AnyCodable?) -> String? { + guard let raw = value?.value as? String else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func isValidOptionalApprovalPresentationString( + _ value: AnyCodable?, + requiresNonEmpty: Bool = false) -> Bool + { + guard let value else { return true } + if value.value is NSNull { return true } + guard let text = value.value as? String else { return false } + return !requiresNonEmpty || !text.isEmpty + } + + private static func isValidExecApprovalPresentation( + _ presentation: ExecApprovalPresentation, + terminalDecision: String? = nil) -> Bool + { + let decisions = presentation.alloweddecisions.map(\.rawValue) + guard presentation.kind == ApprovalKind.exec.rawValue, + !presentation.commandtext.isEmpty, + (1...3).contains(decisions.count), + decisions.count == Set(decisions).count, + decisions.contains(ApprovalDecision.deny.rawValue), + self.isValidOptionalApprovalPresentationString(presentation.commandpreview), + self.isValidOptionalApprovalPresentationString(presentation.warningtext), + self.isValidOptionalApprovalPresentationString(presentation.host), + self.isValidOptionalApprovalPresentationString( + presentation.nodeid, + requiresNonEmpty: true), + self.isValidOptionalApprovalPresentationString( + presentation.agentid, + requiresNonEmpty: true), + terminalDecision.map(decisions.contains) != false + else { return false } + return true + } + + private struct ExecApprovalTerminalSnapshotFields { + let id: String + let urlPath: String + let createdAtMs: Int + let expiresAtMs: Int + let presentation: ApprovalPresentation + let resolvedAtMs: Int + + init(_ value: AllowedApprovalSnapshot) { + self.init( + id: value.id, + urlPath: value.urlpath, + createdAtMs: value.createdatms, + expiresAtMs: value.expiresatms, + presentation: value.presentation, + resolvedAtMs: value.resolvedatms) + } + + init(_ value: DeniedApprovalSnapshot) { + self.init( + id: value.id, + urlPath: value.urlpath, + createdAtMs: value.createdatms, + expiresAtMs: value.expiresatms, + presentation: value.presentation, + resolvedAtMs: value.resolvedatms) + } + + init(_ value: ExpiredApprovalSnapshot) { + self.init( + id: value.id, + urlPath: value.urlpath, + createdAtMs: value.createdatms, + expiresAtMs: value.expiresatms, + presentation: value.presentation, + resolvedAtMs: value.resolvedatms) + } + + init(_ value: CancelledApprovalSnapshot) { + self.init( + id: value.id, + urlPath: value.urlpath, + createdAtMs: value.createdatms, + expiresAtMs: value.expiresatms, + presentation: value.presentation, + resolvedAtMs: value.resolvedatms) + } + + private init( + id: String, + urlPath: String, + createdAtMs: Int, + expiresAtMs: Int, + presentation: ApprovalPresentation, + resolvedAtMs: Int) + { + self.id = id + self.urlPath = urlPath + self.createdAtMs = createdAtMs + self.expiresAtMs = expiresAtMs + self.presentation = presentation + self.resolvedAtMs = resolvedAtMs + } + } + + private static func makeExecApprovalTerminalResult( + fields: ExecApprovalTerminalSnapshotFields, + expectedApprovalID: String, + verdict: ExecApprovalTerminalVerdict) -> ExecApprovalTerminalResult? + { + guard self.approvalIDsMatch(fields.id, expectedApprovalID), + !fields.urlPath.isEmpty, + fields.createdAtMs >= 0, + fields.expiresAtMs >= 0, + fields.resolvedAtMs >= 0, + case let .exec(execPresentation) = fields.presentation, + self.isValidExecApprovalPresentation( + execPresentation, + terminalDecision: verdict.decision) + else { + return nil + } + return ExecApprovalTerminalResult( + id: fields.id, + verdict: verdict, + resolvedAtMs: Int64(fields.resolvedAtMs)) + } + + private static func makeExecApprovalTerminalResult( + from snapshot: TerminalApprovalSnapshot, + expectedApprovalID: String) -> ExecApprovalTerminalResult? + { + switch snapshot { + case let .allowed(value): + let verdict: ExecApprovalTerminalVerdict + switch value.decision.rawValue { + case ApprovalDecision.allowOnce.rawValue: + verdict = .allowOnce + case ApprovalDecision.allowAlways.rawValue: + verdict = .allowAlways + default: + return nil + } + return self.makeExecApprovalTerminalResult( + fields: ExecApprovalTerminalSnapshotFields(value), + expectedApprovalID: expectedApprovalID, + verdict: verdict) + case let .denied(value): + guard value.decision == ApprovalDecision.deny.rawValue else { return nil } + return self.makeExecApprovalTerminalResult( + fields: ExecApprovalTerminalSnapshotFields(value), + expectedApprovalID: expectedApprovalID, + verdict: .deny) + case let .expired(value): + return self.makeExecApprovalTerminalResult( + fields: ExecApprovalTerminalSnapshotFields(value), + expectedApprovalID: expectedApprovalID, + verdict: .expired) + case let .cancelled(value): + return self.makeExecApprovalTerminalResult( + fields: ExecApprovalTerminalSnapshotFields(value), + expectedApprovalID: expectedApprovalID, + verdict: .cancelled) + } + } + + private static func makeExecApprovalTerminalResult( + from snapshot: ApprovalSnapshot, + expectedApprovalID: String) -> ExecApprovalTerminalResult? + { + switch snapshot { + case .pending: + nil + case let .allowed(value): + self.makeExecApprovalTerminalResult( + from: TerminalApprovalSnapshot.allowed(value), + expectedApprovalID: expectedApprovalID) + case let .denied(value): + self.makeExecApprovalTerminalResult( + from: TerminalApprovalSnapshot.denied(value), + expectedApprovalID: expectedApprovalID) + case let .expired(value): + self.makeExecApprovalTerminalResult( + from: TerminalApprovalSnapshot.expired(value), + expectedApprovalID: expectedApprovalID) + case let .cancelled(value): + self.makeExecApprovalTerminalResult( + from: TerminalApprovalSnapshot.cancelled(value), + expectedApprovalID: expectedApprovalID) + } + } + + private static func execApprovalTerminalText( + _ terminal: ExecApprovalTerminalResult, + alreadyResolved: Bool) -> String + { + let prefix = alreadyResolved ? "This approval was already" : "Approval" + switch terminal.verdict { + case .allowOnce: + return "\(prefix) allowed once." + case .allowAlways: + return alreadyResolved + ? "This approval was already set to Always Allow." + : "Approval set to Always Allow." + case .deny: + return "\(prefix) denied." + case .expired: + return "Approval expired before this decision was applied." + case .cancelled: + return "Approval was cancelled before this decision was applied." + case .resolvedUnknown: + return "Approval was resolved elsewhere." + } } private nonisolated static func shouldUseBackgroundAwareExecApprovalReconnect( @@ -6652,9 +8316,12 @@ extension NodeAppModel { } // Gateways shipped before owner-tagged APNs payloads are still safe when the // approval is resolved only through the currently authenticated operator route. - guard let expectedGatewayDeviceID = push.gatewayDeviceId else { + guard let rawExpectedGatewayDeviceID = push.gatewayDeviceId else { return .validated(context) } + guard let expectedGatewayDeviceID = GatewayStableIdentifier.exact(rawExpectedGatewayDeviceID) else { + return .mismatchedOwner + } do { let identity = try await fetchPushRelayGatewayIdentity(ifCurrentRoute: context.route) guard shouldContinue(), @@ -6664,7 +8331,7 @@ extension NodeAppModel { else { return .unavailable } - guard identity.deviceId == expectedGatewayDeviceID else { + guard GatewayStableIdentifier.matches(identity.deviceId, expectedGatewayDeviceID) else { return .mismatchedOwner } return .validated(context) @@ -6679,6 +8346,10 @@ extension NodeAppModel { expectedOperatorRoute: GatewayNodeSessionRoute? = nil, shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> ExecApprovalPromptFetchOutcome { + guard Self.validatedApprovalID(approvalId) != nil else { + return .failed(message: "invalid_approval_id") + } + let readbackFence = self.execApprovalReadbackFence(approvalID: approvalId) let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines) let fetchReason: String = if let normalizedSourceReason, !normalizedSourceReason.isEmpty { normalizedSourceReason @@ -6687,6 +8358,22 @@ extension NodeAppModel { } GatewayDiagnostics.log( "watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)") + #if DEBUG + if let testExecApprovalPromptFetchHandler, + let gatewayStableID = self.currentExecApprovalGatewayStableID() + { + let routeGeneration = self.gatewayRouteGeneration + let outcome = await testExecApprovalPromptFetchHandler(approvalId, gatewayStableID) + guard shouldContinue(), + self.isCurrentExecApprovalReadbackRoute( + generation: routeGeneration, + stableID: gatewayStableID) + else { + return .failed(message: "gateway_changed") + } + return self.recordCanonicalExecApprovalFetchOutcome(outcome, fence: readbackFence) + } + #endif guard let context = await operatorRouteForExecApproval( sourceReason: fetchReason, expectedOperatorRoute: expectedOperatorRoute, @@ -6697,33 +8384,48 @@ extension NodeAppModel { return .failed(message: "operator_not_connected") } + let rpcFamily = await self.execApprovalRPCFamily(route: context.route) + if rpcFamily == .legacy { + let outcome = await self.fetchLegacyExecApprovalPrompt( + approvalId: approvalId, + context: context, + fetchReason: fetchReason, + shouldContinue: shouldContinue) + return self.recordCanonicalExecApprovalFetchOutcome(outcome, fence: readbackFence) + } + guard rpcFamily == .unified else { + return .failed(message: "approval_methods_unavailable") + } + do { - let payloadJSON = try Self.encodePayload(ExecApprovalGetRequest(id: approvalId)) + let payloadJSON = try Self.encodePayload(ApprovalGetParams(id: approvalId)) let response = try await operatorGateway.request( - method: "exec.approval.get", + method: "approval.get", paramsJSON: payloadJSON, timeoutSeconds: 12, ifCurrentRoute: context.route) - guard shouldContinue(), self.currentExecApprovalGatewayStableID() == context.gatewayStableID else { - return .failed(message: "gateway_changed") - } - let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response) - guard let prompt = Self.makeExecApprovalPrompt( - from: details, - gatewayStableID: context.gatewayStableID) + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: shouldContinue) else { - GatewayDiagnostics.log( - "watch exec approval: fetch prompt invalid payload id=\(approvalId) reason=\(fetchReason)") - return .failed(message: "invalid_prompt_payload") + return .failed(message: "route_changed") } - GatewayDiagnostics.log( - "watch exec approval: fetch prompt loaded id=\(approvalId) reason=\(fetchReason)") - return .loaded(prompt) + let outcome = Self.decodeUnifiedExecApprovalGet( + response, + approvalId: approvalId, + gatewayStableID: context.gatewayStableID, + fetchReason: fetchReason) + return self.recordCanonicalExecApprovalFetchOutcome(outcome, fence: readbackFence) } catch is CancellationError { return .failed(message: "route_changed") } catch { - guard self.currentExecApprovalGatewayStableID() == context.gatewayStableID else { - return .failed(message: "gateway_changed") + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: shouldContinue) + else { + return .failed(message: "route_changed") } if Self.isApprovalNotificationStaleError(error) { GatewayDiagnostics.log( @@ -6738,10 +8440,109 @@ extension NodeAppModel { } } + private static func decodeUnifiedExecApprovalGet( + _ response: Data, + approvalId: String, + gatewayStableID: String, + fetchReason: String) -> ExecApprovalPromptFetchOutcome + { + do { + let result = try JSONDecoder().decode(ApprovalGetResult.self, from: response) + switch result.approval { + case let .pending(snapshot): + guard let prompt = Self.makeExecApprovalPrompt( + from: snapshot, + expectedApprovalID: approvalId, + gatewayStableID: gatewayStableID) + else { + return .failed(message: "invalid_prompt_payload") + } + GatewayDiagnostics.log( + "watch exec approval: fetch prompt loaded id=\(approvalId) reason=\(fetchReason)") + return .loaded(prompt) + case .allowed, .denied, .expired, .cancelled: + guard let terminal = Self.makeExecApprovalTerminalResult( + from: result.approval, + expectedApprovalID: approvalId) + else { + return .failed(message: "invalid_terminal_payload") + } + GatewayDiagnostics.log( + "watch exec approval: fetch terminal id=\(approvalId) " + + "status=\(terminal.status) reason=\(fetchReason)") + return .terminal(terminal) + } + } catch { + return .failed(message: "invalid_approval_payload") + } + } + + private func fetchLegacyExecApprovalPrompt( + approvalId: String, + context: GatewaySessionRouteContext, + fetchReason: String, + shouldContinue: @MainActor @Sendable () -> Bool) async -> ExecApprovalPromptFetchOutcome + { + do { + let payloadJSON = try Self.encodePayload(ExecApprovalGetParams(id: approvalId)) + let response = try await self.operatorGateway.request( + method: "exec.approval.get", + paramsJSON: payloadJSON, + timeoutSeconds: 12, + ifCurrentRoute: context.route) + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: shouldContinue) + else { + return .failed(message: "route_changed") + } + let result = try JSONDecoder().decode(LegacyExecApprovalGetResult.self, from: response) + guard let prompt = Self.makeExecApprovalPrompt( + from: result, + expectedApprovalID: approvalId, + gatewayStableID: context.gatewayStableID) + else { + return .failed(message: "invalid_prompt_payload") + } + GatewayDiagnostics.log( + "watch exec approval: legacy fetch loaded id=\(approvalId) reason=\(fetchReason)") + return .loaded(prompt) + } catch is CancellationError { + return .failed(message: "route_changed") + } catch { + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: shouldContinue) + else { + return .failed(message: "route_changed") + } + if Self.isApprovalNotificationStaleError(error) { + return .stale + } + return .failed(message: error.localizedDescription) + } + } + func dismissPendingExecApprovalPrompt() { + if let inboxKey = Self.execApprovalInboxKey(self.pendingExecApprovalPrompt), + self.execApprovalInboxPromptsByKey[inboxKey] != nil + { + self.dismissedExecApprovalPresentationKeys.insert(inboxKey) + } + self.pendingExecApprovalPromptSurfaceGeneration &+= 1 self.pendingExecApprovalPrompt = nil self.pendingExecApprovalPromptResolving = false self.pendingExecApprovalPromptErrorText = nil + self.pendingExecApprovalPromptOutcome = nil + } + + func presentPendingExecApprovalFromInbox(_ key: ExecApprovalInboxKey) { + guard let prompt = self.execApprovalInboxPromptsByKey[key], + !self.terminalExecApprovalKeys.contains(key) + else { return } + self.presentFetchedExecApprovalPrompt(prompt, publishReason: "inbox_review") } func dismissPendingExecApprovalPrompt(approvalId: String) { @@ -6750,21 +8551,53 @@ extension NodeAppModel { func resolvePendingExecApprovalPrompt(decision: String) async { guard let prompt = pendingExecApprovalPrompt else { return } + guard self.pendingExecApprovalPromptResolvedText == nil else { return } guard self.isExecApprovalPromptCurrent(prompt) else { self.dismissPendingExecApprovalPrompt() return } - let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedDecision.isEmpty else { return } + guard prompt.allowedDecisions.contains(decision) else { return } + guard let resolutionAttempt = self.beginExecApprovalResolutionAttempt( + approvalID: prompt.id, + gatewayStableID: prompt.gatewayStableID) + else { return } + defer { self.finishExecApprovalResolutionAttempt(resolutionAttempt) } self.pendingExecApprovalPromptResolving = true self.pendingExecApprovalPromptErrorText = nil let outcome = await resolveExecApprovalNotificationDecision( approvalId: prompt.id, - decision: normalizedDecision, - expectedGatewayStableID: prompt.gatewayStableID) + approvalKind: prompt.kind, + decision: decision, + expectedGatewayStableID: prompt.gatewayStableID, + resolutionAttempt: resolutionAttempt) + if case let .uncertain(message) = outcome { + // A gateway switch invalidates this attempt mid-flight, but a lost write + // outcome is owner-scoped durable state: record it before the attempt gate + // or switching back would offer a fresh actionable card for a decision that + // may already be applied. UI mutations stay keyed to the exact owner inside. + self.markExecApprovalResolutionUncertain( + approvalID: prompt.id, + gatewayStableID: prompt.gatewayStableID, + message: message) + } + guard self.isActiveExecApprovalResolutionAttempt(resolutionAttempt) else { return } + guard self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, prompt.id) }) == true, + GatewayStableIdentifier.matches( + self.pendingExecApprovalPrompt?.gatewayStableID, + prompt.gatewayStableID) + else { + return + } switch outcome { - case .resolved, .stale, .unavailable: + case .resolved: + break + case let .pendingRetry(message): + self.pendingExecApprovalPromptResolving = false + self.pendingExecApprovalPromptErrorText = message + case .stale: + break + case .uncertain: break case let .failed(message): self.pendingExecApprovalPromptResolving = false @@ -6774,106 +8607,473 @@ extension NodeAppModel { private func resolveExecApprovalNotificationDecision( approvalId: String, + approvalKind: String?, decision: String, expectedGatewayStableID: String, - sourceReason: String? = nil) async -> ExecApprovalResolutionOutcome + sourceReason: String? = nil, + resolutionAttempt: ExecApprovalResolutionAttempt? = nil) async -> ExecApprovalResolutionOutcome { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolutionReason = (normalizedSourceReason?.isEmpty == false) ? normalizedSourceReason! : "direct" - guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else { + guard let approvalID = Self.validatedApprovalID(approvalId) else { return .failed(message: "Invalid approval request.") } - guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else { + let rawApprovalKind = approvalKind ?? "" + let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolutionReason = (normalizedSourceReason?.isEmpty == false) ? normalizedSourceReason! : "direct" + guard let approvalKind = ApprovalKind(rawValue: rawApprovalKind), + approvalKind.rawValue == ApprovalKind.exec.rawValue, + let approvalDecision = ApprovalDecision(rawValue: decision) + else { + return .failed(message: "Invalid approval request.") + } + guard GatewayStableIdentifier.matches( + self.currentExecApprovalGatewayStableID(), + expectedGatewayStableID) + else { return .failed(message: "This approval belongs to a different gateway.") } - let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect( - sourceReason: resolutionReason, - isBackgrounded: self.isBackgrounded) + #if DEBUG + if let outcome = await self.testExecApprovalResolutionOutcome( + approvalID: approvalID, + decision: decision, + expectedGatewayStableID: expectedGatewayStableID, + resolutionAttempt: resolutionAttempt) { - await self.ensureOperatorApprovalConnectionForWatchReview( - timeoutMs: 12000, - reason: resolutionReason) - } else { - await self.ensureOperatorApprovalConnection(timeoutMs: 12000) + return outcome } - guard connected, - self.currentExecApprovalGatewayStableID() == expectedGatewayStableID, - let operatorRoute = await operatorGateway.currentRoute() + #endif + + guard let context = await self.operatorRouteForExecApproval(sourceReason: resolutionReason), + GatewayStableIdentifier.matches(context.gatewayStableID, expectedGatewayStableID) else { self.execApprovalNotificationLogger.error( - "Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected") + "Exec approval action failed id=\(approvalID, privacy: .public): operator not connected") return .failed(message: "OpenClaw couldn't connect to the gateway operator session.") } + let rpcFamily = await self.execApprovalRPCFamily(route: context.route) + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: { true }) + else { + return .failed(message: "The gateway operator route changed before the approval response was applied.") + } + if rpcFamily == .legacy { + return await self.resolveLegacyExecApproval( + approvalId: approvalID, + decision: approvalDecision, + context: context, + resolutionAttempt: resolutionAttempt) + } + guard rpcFamily == .unified else { + return .failed(message: "This gateway does not advertise a complete approval API.") + } + do { let payloadJSON = try Self.encodePayload( - ExecApprovalResolveRequest(id: normalizedApprovalID, decision: normalizedDecision)) - _ = try await self.operatorGateway.request( - method: "exec.approval.resolve", + ApprovalResolveParams( + id: approvalID, + kind: approvalKind, + decision: approvalDecision)) + let response = try await self.operatorGateway.request( + method: "approval.resolve", paramsJSON: payloadJSON, timeoutSeconds: 12, - ifCurrentRoute: operatorRoute) - guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else { - return .resolved + ifCurrentRoute: context.route, + distinguishPreDispatchRouteChange: true) + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: { true }) + else { + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + return .uncertain( + message: "Decision status is unknown after the gateway operator route changed.") } - await self.removeCurrentGatewayExecApprovalNotifications( - approvalId: normalizedApprovalID) - self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) - await self.publishWatchExecApprovalResolved( - approvalId: normalizedApprovalID, - gatewayStableID: expectedGatewayStableID, - decision: OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision), - source: "iphone") - return .resolved + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + guard let result = try? JSONDecoder().decode(ApprovalResolveResult.self, from: response), + let terminal = Self.makeExecApprovalTerminalResult( + from: result.approval, + expectedApprovalID: approvalID) + else { + return await self.reconcileUnknownExecApprovalResolution( + approvalId: approvalID, + gatewayStableID: context.gatewayStableID, + operatorRoute: context.route) + } + if !Self.isValidUnifiedExecApprovalResolveAck( + result: result, + terminal: terminal, + attemptedDecision: approvalDecision) + { + return await self.reconcileUnknownExecApprovalResolution( + approvalId: approvalID, + gatewayStableID: context.gatewayStableID, + operatorRoute: context.route) + } + return await self.applyCanonicalExecApprovalTerminal( + terminal, + appliedHere: result.applied, + gatewayStableID: context.gatewayStableID) } catch { - guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else { - return .failed(message: "This approval belongs to a different gateway.") + if let requestError = error as? GatewayNodeSessionRequestError, + case .routeChangedBeforeDispatch = requestError + { + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + return .failed(message: "The gateway operator route changed before the decision was sent.") } - if Self.isApprovalNotificationStaleError(error) { - await self.removeCurrentGatewayExecApprovalNotifications( - approvalId: normalizedApprovalID) - self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) - await self.publishWatchExecApprovalExpired( - approvalId: normalizedApprovalID, - gatewayStableID: expectedGatewayStableID, - reason: .notFound) - return .stale + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: { true }) + else { + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + return .uncertain( + message: "Decision status is unknown after the gateway operator route changed.") } - if Self.isApprovalNotificationUnavailableError(error) { - await self.removeCurrentGatewayExecApprovalNotifications( - approvalId: normalizedApprovalID) - self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID) - await self.publishWatchExecApprovalExpired( - approvalId: normalizedApprovalID, - gatewayStableID: expectedGatewayStableID, - reason: .unavailable) - return .unavailable + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) } let logMessage = - "Exec approval action failed id=\(normalizedApprovalID) error=\(error.localizedDescription)" + "Exec approval action response unknown id=\(approvalID) " + + "error=\(error.localizedDescription)" self.execApprovalNotificationLogger.error("\(logMessage, privacy: .public)") - return .failed( - message: "OpenClaw couldn't resolve this approval right now. Try again.") + return await self.reconcileUnknownExecApprovalResolution( + approvalId: approvalID, + gatewayStableID: context.gatewayStableID, + operatorRoute: context.route) } } + #if DEBUG + /// Stubbed resolve transport for tests; nil when no handler is installed. + private func testExecApprovalResolutionOutcome( + approvalID: String, + decision: String, + expectedGatewayStableID: String, + resolutionAttempt: ExecApprovalResolutionAttempt?) async -> ExecApprovalResolutionOutcome? + { + guard let testExecApprovalResolutionHandler else { return nil } + let outcome = await testExecApprovalResolutionHandler( + approvalID, + decision, + expectedGatewayStableID) + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + if self.testExecApprovalResolutionReconcilesUnknownAck { + // Mirror the production unknown-ack path: the settled write's outcome is + // classified by canonical readback while the attempt lease stays active. + return await self.reconcileUnknownExecApprovalResolution( + approvalId: approvalID, + gatewayStableID: expectedGatewayStableID, + operatorRoute: nil) + } + return outcome + } + #endif + + private func execApprovalRPCFamily(route: GatewayNodeSessionRoute) async -> ExecApprovalRPCFamily { + let unifiedGet = await self.operatorGateway.supportsServerMethod( + "approval.get", + ifCurrentRoute: route) + let unifiedResolve = await self.operatorGateway.supportsServerMethod( + "approval.resolve", + ifCurrentRoute: route) + let legacyGet = await self.operatorGateway.supportsServerMethod( + "exec.approval.get", + ifCurrentRoute: route) + let legacyResolve = await self.operatorGateway.supportsServerMethod( + "exec.approval.resolve", + ifCurrentRoute: route) + return Self.selectExecApprovalRPCFamily( + unifiedGet: unifiedGet, + unifiedResolve: unifiedResolve, + legacyGet: legacyGet, + legacyResolve: legacyResolve) + } + + /// Legacy exec.approval.* fallback serves shipped Gateway v4 peers; remove when the + /// minimum supported gateway advertises approval.get/approval.resolve. + private nonisolated static func selectExecApprovalRPCFamily( + unifiedGet: Bool?, + unifiedResolve: Bool?, + legacyGet: Bool?, + legacyResolve: Bool?) -> ExecApprovalRPCFamily + { + if unifiedGet == true, unifiedResolve == true { + return .unified + } + if unifiedGet == false, + unifiedResolve == false, + legacyGet == true, + legacyResolve == true + { + return .legacy + } + return .unavailable + } + + private func resolveLegacyExecApproval( + approvalId: String, + decision: ApprovalDecision, + context: GatewaySessionRouteContext, + resolutionAttempt: ExecApprovalResolutionAttempt?) async -> ExecApprovalResolutionOutcome + { + struct LegacyResolveResult: Decodable { let ok: Bool } + + do { + let payloadJSON = try Self.encodePayload(ExecApprovalResolveParams( + id: approvalId, + decision: decision.rawValue)) + let response = try await self.operatorGateway.request( + method: "exec.approval.resolve", + paramsJSON: payloadJSON, + timeoutSeconds: 12, + ifCurrentRoute: context.route, + distinguishPreDispatchRouteChange: true) + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: { true }) + else { + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + return .uncertain( + message: "Decision status is unknown after the gateway operator route changed.") + } + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + guard (try? JSONDecoder().decode(LegacyResolveResult.self, from: response))?.ok == true else { + return await self.reconcileUnknownExecApprovalResolution( + approvalId: approvalId, + gatewayStableID: context.gatewayStableID, + operatorRoute: context.route) + } + let terminal = ExecApprovalTerminalResult( + id: approvalId, + verdict: Self.execApprovalVerdict(for: decision), + resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000)) + return await self.applyLegacyExecApprovalTerminal( + terminal, + gatewayStableID: context.gatewayStableID) + } catch { + if let requestError = error as? GatewayNodeSessionRequestError, + case .routeChangedBeforeDispatch = requestError + { + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + return .failed(message: "The gateway operator route changed before the decision was sent.") + } + guard await self.isCurrentGatewaySessionRoute( + context, + session: self.operatorGateway, + shouldContinue: { true }) + else { + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + return .uncertain( + message: "Decision status is unknown after the gateway operator route changed.") + } + if let resolutionAttempt { + self.markExecApprovalResolutionWriteSettled(resolutionAttempt) + } + if Self.isApprovalAlreadyResolvedError(error) { + let terminal = ExecApprovalTerminalResult( + id: approvalId, + verdict: .resolvedUnknown, + resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000)) + return await self.applyCanonicalExecApprovalTerminal( + terminal, + appliedHere: false, + gatewayStableID: context.gatewayStableID) + } + return await self.reconcileUnknownExecApprovalResolution( + approvalId: approvalId, + gatewayStableID: context.gatewayStableID, + operatorRoute: context.route) + } + } + + /// `operatorRoute` is nil only from the DEBUG unknown-ack seam, where the stubbed + /// fetch handler owns route admission instead of an operator session lease. + private func reconcileUnknownExecApprovalResolution( + approvalId: String, + gatewayStableID: String, + operatorRoute: GatewayNodeSessionRoute?) async -> ExecApprovalResolutionOutcome + { + switch await self.fetchExecApprovalPrompt( + approvalId: approvalId, + sourceReason: "resolve_reconcile", + expectedOperatorRoute: operatorRoute) + { + case let .terminal(terminal): + return await self.applyCanonicalExecApprovalTerminal( + terminal, + appliedHere: false, + gatewayStableID: gatewayStableID) + case let .loaded(prompt): + if self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalId) }) == true, + GatewayStableIdentifier.matches( + self.pendingExecApprovalPrompt?.gatewayStableID, + gatewayStableID) + { + self.presentFetchedExecApprovalPrompt(prompt, publishReason: "resolve_retry") + } else { + self.upsertWatchExecApprovalPrompt(prompt) + await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry") + } + return .pendingRetry(message: "The previous decision was not recorded. Review and try again.") + case .stale: + // This readback follows a dispatched write whose response was lost or malformed. + // Legacy get removes committed rows, so not-found cannot distinguish success from + // expiry. Keep every surface frozen until an explicit terminal event/reconnect. + return .uncertain( + message: "Decision status is unknown. Actions remain locked until OpenClaw reconnects.") + case .failed: + return .uncertain(message: "Decision status is unknown. Actions remain locked until OpenClaw reconnects.") + } + } + + private func applyCanonicalExecApprovalTerminal( + _ terminal: ExecApprovalTerminalResult, + appliedHere: Bool, + gatewayStableID: String, + syncSnapshots: Bool = true) async -> ExecApprovalResolutionOutcome + { + guard GatewayStableIdentifier.matches( + self.currentExecApprovalGatewayStableID(), + gatewayStableID) + else { + return .failed(message: "This approval belongs to a different gateway.") + } + // Record the owner tombstone before any suspension point. A concurrent pending + // readback must not resurrect this exact approval after canonical terminal truth. + self.markExecApprovalOwnerTerminal( + approvalId: terminal.id, + gatewayStableID: gatewayStableID) + self.markPendingExecApprovalTerminal( + terminal, + alreadyResolved: !appliedHere) + await self.removeCurrentGatewayExecApprovalNotifications(approvalId: terminal.id) + await self.publishWatchExecApprovalTerminal( + terminal, + gatewayStableID: gatewayStableID, + source: appliedHere ? "iphone" : "another-reviewer", + syncSnapshots: syncSnapshots) + return .resolved(terminal, applied: appliedHere) + } + + private func applyLegacyExecApprovalTerminal( + _ terminal: ExecApprovalTerminalResult, + gatewayStableID: String) async -> ExecApprovalResolutionOutcome + { + guard GatewayStableIdentifier.matches( + self.currentExecApprovalGatewayStableID(), + gatewayStableID) + else { + return .failed(message: "This approval belongs to a different gateway.") + } + self.markExecApprovalOwnerTerminal( + approvalId: terminal.id, + gatewayStableID: gatewayStableID) + self.markPendingExecApprovalTerminal( + terminal, + alreadyResolved: false) + await self.removeCurrentGatewayExecApprovalNotifications(approvalId: terminal.id) + // Legacy {ok:true} proves terminal acceptance, but not which surface won. + // Attribute the canonical result to the gateway and keep its wording neutral. + await self.publishWatchExecApprovalTerminal( + terminal, + gatewayStableID: gatewayStableID, + source: "gateway") + return .resolved(terminal, applied: false) + } + + private func markPendingExecApprovalTerminal( + _ terminal: ExecApprovalTerminalResult, + alreadyResolved: Bool) + { + let tone: ExecApprovalOutcomeTone = switch terminal.verdict { + case .allowOnce, .allowAlways: + .success + case .deny: + .danger + case .expired, .cancelled: + .warning + case .resolvedUnknown: + .neutral + } + self.markPendingExecApprovalTerminal( + approvalId: terminal.id, + outcome: ExecApprovalOutcome( + text: Self.execApprovalTerminalText(terminal, alreadyResolved: alreadyResolved), + tone: tone)) + } + + private func markPendingExecApprovalTerminal( + approvalId: String, + outcome: ExecApprovalOutcome) + { + self.clearNotificationPermissionGuidancePromptIfMatches(approvalId) + guard self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalId) }) == true else { + return + } + self.pendingExecApprovalPromptSurfaceGeneration &+= 1 + self.pendingExecApprovalPromptResolving = false + self.pendingExecApprovalPromptErrorText = nil + self.pendingExecApprovalPromptOutcome = outcome + } + + private static func execApprovalVerdict(for decision: ApprovalDecision) -> ExecApprovalTerminalVerdict { + switch decision { + case .allowOnce: + .allowOnce + case .allowAlways: + .allowAlways + case .deny: + .deny + } + } + + private static func isValidUnifiedExecApprovalResolveAck( + result: ApprovalResolveResult, + terminal: ExecApprovalTerminalResult, + attemptedDecision: ApprovalDecision) -> Bool + { + !result.applied || terminal.decision == attemptedDecision.rawValue + } + private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - self.clearNotificationPermissionGuidancePromptIfMatches(normalizedApprovalID) - guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return } + guard let approvalID = Self.validatedApprovalID(approvalId) else { return } + self.clearNotificationPermissionGuidancePromptIfMatches(approvalID) + guard self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalID) }) == true else { + return + } self.dismissPendingExecApprovalPrompt() } private func removeCurrentGatewayExecApprovalNotifications(approvalId: String) async { let delivered = await notificationCenter.deliveredNotifications() - var seen = Set() + var seen = Set() for snapshot in delivered { guard let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: snapshot.userInfo), - push.approvalId == approvalId, - seen.insert(push).inserted, + let pushKey = Self.execApprovalPushKey(push), + Self.approvalIDsMatch(push.approvalId, approvalId), + seen.insert(pushKey).inserted, await validatedExecApprovalPushRoute( push, sourceReason: "notification_action") != nil @@ -6887,8 +9087,10 @@ extension NodeAppModel { } private func clearNotificationPermissionGuidancePromptIfMatches(_ approvalId: String) { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID else { return } + guard let approvalID = Self.validatedApprovalID(approvalId) else { return } + guard self.pendingNotificationPermissionGuidancePrompt.map({ + Self.approvalIDsMatch($0.approvalId, approvalID) + }) == true else { return } self.pendingNotificationPermissionGuidancePrompt = nil } @@ -6903,15 +9105,10 @@ extension NodeAppModel { return gatewayError.message.lowercased().contains("unknown or expired approval id") } - private nonisolated static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool { + private nonisolated static func isApprovalAlreadyResolvedError(_ error: Error) -> Bool { guard let gatewayError = error as? GatewayResponseError else { return false } - if gatewayError.code != "INVALID_REQUEST" { - return false - } - if gatewayError.detailsReason == "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE" { - return true - } - return gatewayError.message.lowercased().contains("allow-always is unavailable") + return gatewayError.code == "INVALID_REQUEST" && + gatewayError.detailsReason == "APPROVAL_ALREADY_RESOLVED" } private struct BackgroundAliveWakeAttemptResult { @@ -6968,12 +9165,10 @@ extension NodeAppModel { guard self.operatorGatewayTask == nil else { return } - let stableID = cfg.stableID.trimmingCharacters(in: .whitespacesAndNewlines) - let effectiveStableID = stableID.isEmpty ? cfg.url.absoluteString : stableID let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } self.startOperatorGatewayLoop( url: cfg.url, - stableID: effectiveStableID, + stableID: cfg.effectiveStableID, token: cfg.token, bootstrapToken: cfg.bootstrapToken, password: cfg.password, @@ -7757,6 +9952,52 @@ extension NodeAppModel { self.pendingExecApprovalPrompt } + func _test_pendingExecApprovalInboxItems() -> [(id: String, gatewayStableID: String)] { + self.pendingExecApprovalInboxItems.map { + (id: $0.prompt.id, gatewayStableID: $0.prompt.gatewayStableID) + } + } + + func _test_presentPendingExecApprovalFromInbox( + approvalID: String, + gatewayStableID: String) + { + guard let key = Self.execApprovalInboxKey( + approvalID: approvalID, + gatewayStableID: gatewayStableID) + else { return } + self.presentPendingExecApprovalFromInbox(key) + } + + struct PendingExecApprovalStateSnapshot { + let resolving: Bool + let canDismiss: Bool + let error: String? + let resolved: String? + let tone: ExecApprovalOutcomeTone? + } + + func _test_pendingExecApprovalState() -> PendingExecApprovalStateSnapshot { + PendingExecApprovalStateSnapshot( + resolving: self.pendingExecApprovalPromptResolving, + canDismiss: self.pendingExecApprovalPromptCanDismiss, + error: self.pendingExecApprovalPromptErrorText, + resolved: self.pendingExecApprovalPromptResolvedText, + tone: self.pendingExecApprovalPromptOutcome?.tone) + } + + nonisolated static func _test_decodePushRelayGatewayIdentity(_ json: String) throws -> PushRelayGatewayIdentity { + try self.decodePushRelayGatewayIdentity(Data(json.utf8)) + } + + func _test_setPendingExecApprovalPromptUncertain(_ message: String) { + guard let prompt = self.pendingExecApprovalPrompt else { return } + self.markExecApprovalResolutionUncertain( + approvalID: prompt.id, + gatewayStableID: prompt.gatewayStableID, + message: message) + } + func _test_pendingNotificationPermissionGuidancePrompt() -> NotificationPermissionGuidancePrompt? { self.pendingNotificationPermissionGuidancePrompt } @@ -7780,6 +10021,14 @@ extension NodeAppModel { gatewayDeviceId: gatewayDeviceId)) } + func _test_removePendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) { + self.removePendingWatchExecApprovalRecoveryPush(push) + } + + func _test_removePendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) { + self.removePendingExecApprovalResolvedPush(push) + } + func _test_pendingWatchExecApprovalRecoveryIDs() -> [String] { self.pendingWatchExecApprovalRecoveryPushes.map(\.approvalId) } @@ -7788,6 +10037,20 @@ extension NodeAppModel { self.pendingWatchExecApprovalRecoveryPushes } + func _test_pendingPersistedExecApprovalReadbacks() + -> [(approvalId: String, gatewayStableID: String)] + { + self.pendingPersistedExecApprovalReadbacks.map { + (approvalId: $0.approvalId, gatewayStableID: $0.gatewayStableID) + } + } + + func _test_watchExecApprovalCacheIDs() -> [String] { + self.watchExecApprovalPromptsByID.keys + .map(\.rawValue) + .sorted(by: Self.approvalIDSortsBefore) + } + func _test_handleExecApprovalResolvedForCurrentGateway( approvalId: String, recoveryPushGatewayDeviceID: String?) async @@ -7797,6 +10060,137 @@ extension NodeAppModel { recoveryPushGatewayDeviceID: recoveryPushGatewayDeviceID) } + func _test_handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async -> Bool { + await self.handleWatchExecApprovalResolve(event) + } + + func _test_refreshWatchExecApprovalSnapshotOnDemand( + _ event: WatchExecApprovalSnapshotRequestEvent) async + { + await self.refreshWatchExecApprovalSnapshotOnDemand( + reason: "watch_request", + requestId: event.requestId, + requestGatewayStableID: event.gatewayStableID, + heldApprovals: event.heldApprovals) + } + + @discardableResult + func _test_reconcileWatchExecApprovalCache(reason: String) async -> Bool { + await self.reconcileWatchExecApprovalCache(reason: reason) + } + + func _test_setUnifiedExecApprovalGetResponse( + _ json: String?, + beforeResponse: (@Sendable () async -> Void)? = nil) + { + guard let json else { + self.testExecApprovalPromptFetchHandler = nil + return + } + let response = Data(json.utf8) + self.testExecApprovalPromptFetchHandler = { approvalID, gatewayStableID in + await beforeResponse?() + return Self.decodeUnifiedExecApprovalGet( + response, + approvalId: approvalID, + gatewayStableID: gatewayStableID, + fetchReason: "test") + } + } + + func _test_setExecApprovalPromptFetchStale() { + self.testExecApprovalPromptFetchHandler = { _, _ in .stale } + } + + func _test_setExecApprovalPromptFetchFailure(_ message: String) { + self.testExecApprovalPromptFetchHandler = { _, _ in .failed(message: message) } + } + + func _test_setExecApprovalResolutionFailureHandler( + _ handler: @escaping @Sendable (String, String, String) async -> String) + { + self.testExecApprovalResolutionHandler = { approvalID, decision, gatewayStableID in + let message = await handler(approvalID, decision, gatewayStableID) + return .failed(message: message) + } + } + + func _test_setExecApprovalResolutionUncertainHandler( + _ handler: @escaping @Sendable (String, String, String) async -> String) + { + self.testExecApprovalResolutionHandler = { approvalID, decision, gatewayStableID in + let message = await handler(approvalID, decision, gatewayStableID) + return .uncertain(message: message) + } + } + + /// Routes DEBUG resolves through the production unknown-ack path: the write settles + /// immediately, then canonical readback (the DEBUG fetch handler) classifies the + /// outcome while the attempt lease is still active. + func _test_setExecApprovalResolutionUnknownAck() { + self.testExecApprovalResolutionReconcilesUnknownAck = true + self.testExecApprovalResolutionHandler = { _, _, _ in + .failed(message: "unknown_ack_outcome_replaced_by_readback") + } + } + + func _test_setUnifiedExecApprovalGetResponses( + _ responses: [(approvalID: String, json: String)], + beforeResponse: (@Sendable (String) async -> Void)? = nil) + { + let keyedResponses = responses.compactMap { response -> (ExecApprovalIdentifier.Key, Data)? in + guard let approvalID = Self.execApprovalIDKey(response.approvalID) else { return nil } + return (approvalID, Data(response.json.utf8)) + } + self.testExecApprovalPromptFetchHandler = { approvalID, gatewayStableID in + await beforeResponse?(approvalID) + guard let approvalKey = Self.execApprovalIDKey(approvalID), + let response = keyedResponses.first(where: { $0.0 == approvalKey })?.1 + else { + return .failed(message: "missing_test_response") + } + return Self.decodeUnifiedExecApprovalGet( + response, + approvalId: approvalID, + gatewayStableID: gatewayStableID, + fetchReason: "test") + } + } + + func _test_presentExecApprovalGatewayEventPrompt(_ approvalID: String) async { + await self.presentExecApprovalGatewayEventPrompt(approvalId: approvalID) + } + + func _test_presentExecApprovalNotificationPrompt(_ push: ExecApprovalNotificationPrompt) async { + await self.presentExecApprovalPrompt( + approvalId: push.approvalId, + notificationPush: push, + expectedOperatorRoute: nil, + shouldContinue: { true }) + } + + @discardableResult + func _test_applyLegacyExecApprovalTerminal( + approvalID: String, + decision: ApprovalDecision, + expectedGatewayStableID: String? = nil) async -> Bool + { + guard let gatewayStableID = expectedGatewayStableID ?? self.currentExecApprovalGatewayStableID() else { + return false + } + let terminal = ExecApprovalTerminalResult( + id: approvalID, + verdict: Self.execApprovalVerdict(for: decision), + resolvedAtMs: 1) + let outcome = await self.applyLegacyExecApprovalTerminal( + terminal, + gatewayStableID: gatewayStableID) + if case .resolved = outcome { + return true + } + return false + } + func _test_pendingExecApprovalResolvedPushes() -> [ExecApprovalNotificationPrompt] { self.pendingExecApprovalResolvedPushes } @@ -7809,10 +10203,6 @@ extension NodeAppModel { self.isApprovalNotificationStaleError(error) } - nonisolated static func _test_isApprovalNotificationUnavailableError(_ error: Error) -> Bool { - self.isApprovalNotificationUnavailableError(error) - } - nonisolated static func _test_shouldUseBackgroundAwareExecApprovalReconnect( sourceReason: String, isBackgrounded: Bool) -> Bool @@ -7846,35 +10236,123 @@ extension NodeAppModel { cachedApprovalIDs: cachedApprovalIDs) } - nonisolated static func _test_shouldResetWatchExecApprovalResolvingStateOnPrompt( - reason: String) -> Bool - { - self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason) - } - static func _test_makeExecApprovalPrompt( id: String, gatewayStableID: String = "test-gateway", commandText: String, + warningText: String? = nil, allowedDecisions: [String], host: String?, nodeId: String?, agentId: String?, expiresAtMs: Int64?) -> ExecApprovalPrompt? { - self.makeExecApprovalPrompt( - from: ExecApprovalGetResponse( - id: id, - commandText: commandText, - commandPreview: nil, - allowedDecisions: allowedDecisions, - host: host, - nodeId: nodeId, - agentId: agentId, - expiresAtMs: expiresAtMs), + self.makeExecApprovalPrompt(ExecApprovalPrompt( + id: id, + kind: ApprovalKind.exec.rawValue, + gatewayStableID: gatewayStableID, + commandText: commandText, + commandPreview: nil, + warningText: warningText, + allowedDecisions: allowedDecisions, + host: host, + nodeId: nodeId, + agentId: agentId, + expiresAtMs: expiresAtMs)) + } + + static func _test_decodeUnifiedExecApprovalPrompt( + _ json: String, + approvalID: String, + gatewayStableID: String = "test-gateway") throws -> ExecApprovalPrompt? + { + let result = try JSONDecoder().decode(ApprovalGetResult.self, from: Data(json.utf8)) + guard case let .pending(snapshot) = result.approval else { return nil } + return self.makeExecApprovalPrompt( + from: snapshot, + expectedApprovalID: approvalID, gatewayStableID: gatewayStableID) } + static func _test_decodeUnifiedExecApprovalResolution( + _ json: String, + approvalID: String) throws + -> (applied: Bool, status: String, decision: String?, text: String)? + { + let result = try JSONDecoder().decode(ApprovalResolveResult.self, from: Data(json.utf8)) + guard let terminal = self.makeExecApprovalTerminalResult( + from: result.approval, + expectedApprovalID: approvalID) + else { + return nil + } + return ( + applied: result.applied, + status: terminal.status, + decision: terminal.decision, + text: self.execApprovalTerminalText(terminal, alreadyResolved: !result.applied)) + } + + static func _test_isValidUnifiedExecApprovalResolveAck( + _ json: String, + approvalID: String, + attemptedDecision: ApprovalDecision) throws -> Bool + { + let result = try JSONDecoder().decode(ApprovalResolveResult.self, from: Data(json.utf8)) + guard let terminal = self.makeExecApprovalTerminalResult( + from: result.approval, + expectedApprovalID: approvalID) + else { return false } + return self.isValidUnifiedExecApprovalResolveAck( + result: result, + terminal: terminal, + attemptedDecision: attemptedDecision) + } + + func _test_applyUnifiedExecApprovalResolveResult( + _ json: String, + approvalID: String, + attemptedDecision: ApprovalDecision) async throws -> Bool + { + let result = try JSONDecoder().decode(ApprovalResolveResult.self, from: Data(json.utf8)) + guard let terminal = Self.makeExecApprovalTerminalResult( + from: result.approval, + expectedApprovalID: approvalID) + else { return false } + guard Self.isValidUnifiedExecApprovalResolveAck( + result: result, + terminal: terminal, + attemptedDecision: attemptedDecision) + else { return false } + guard let gatewayStableID = self.currentExecApprovalGatewayStableID() else { return false } + _ = await self.applyCanonicalExecApprovalTerminal( + terminal, + appliedHere: result.applied, + gatewayStableID: gatewayStableID) + return true + } + + nonisolated static func _test_execApprovalRPCFamily( + unifiedGet: Bool?, + unifiedResolve: Bool?, + legacyGet: Bool?, + legacyResolve: Bool?) -> String + { + switch self.selectExecApprovalRPCFamily( + unifiedGet: unifiedGet, + unifiedResolve: unifiedResolve, + legacyGet: legacyGet, + legacyResolve: legacyResolve) + { + case .unified: + "unified" + case .legacy: + "legacy" + case .unavailable: + "unavailable" + } + } + static func _test_currentDeepLinkKey() -> String { self.expectedDeepLinkKey() } @@ -7891,6 +10369,12 @@ extension NodeAppModel { UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey) } + static func _test_setPersistedWatchExecApprovalBridgeStateJSON(_ json: String) { + UserDefaults.standard.set( + Data(json.utf8), + forKey: self.watchExecApprovalBridgeStateKey) + } + nonisolated static func _test_shouldStartOperatorGatewayLoop( token: String?, bootstrapToken: String?, diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift b/apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift index 6be33cffa48f..76bf3faab618 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardConnectionSections.swift @@ -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) diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardTypes.swift b/apps/ios/Sources/Onboarding/OnboardingWizardTypes.swift index 35ba147a6843..b1710a89e9d5 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardTypes.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardTypes.swift @@ -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? diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 67f15ae7e893..b4676df62866 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -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 } diff --git a/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift b/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift index 1ec7d6399f45..5286f107ae99 100644 --- a/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift +++ b/apps/ios/Sources/Push/ExecApprovalNotificationBridge.swift @@ -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() 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 { diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index a378b8246fd8..78addc880f09 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -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() { diff --git a/apps/ios/Sources/RootTabsNavigation.swift b/apps/ios/Sources/RootTabsNavigation.swift index bdeaf16c4cd5..0aa0f469caf5 100644 --- a/apps/ios/Sources/RootTabsNavigation.swift +++ b/apps/ios/Sources/RootTabsNavigation.swift @@ -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 diff --git a/apps/ios/Sources/Services/ExactOpaqueIdentifier.swift b/apps/ios/Sources/Services/ExactOpaqueIdentifier.swift new file mode 100644 index 000000000000..76eb0d07c507 --- /dev/null +++ b/apps/ios/Sources/Services/ExactOpaqueIdentifier.swift @@ -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: "") + } +} diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index 172f52a4e313..803906dfe66c 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -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 { diff --git a/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift b/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift index 42176407a653..3ab63c0a194f 100644 --- a/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift +++ b/apps/ios/Sources/Services/WatchMessagingPayloadCodec.swift @@ -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( diff --git a/apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift b/apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift index 72a72ad20e54..52c49bd730f8 100644 --- a/apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift +++ b/apps/ios/Tests/ExecApprovalNotificationBridgeTests.swift @@ -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"]]) } diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 8bb5e7d66386..acd8b6091334 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -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() } diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index 8e8b8a371df5..1c6be34b9db9 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -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) } diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index 90f6456786fe..1a184c393c97 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -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) } } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index e5c4f19a3fd9..16689104735f 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -309,14 +309,27 @@ private func makeProjectedWatchChatRawMessage( return try JSONDecoder().decode(AnyCodable.self, from: data) } +private func makePendingExecApprovalJSON(_ approvalID: String) -> String { + #"{"approval":{"id":"\#(approvalID)","status":"pending","urlPath":"/approve/\#(approvalID)","createdAtMs":100,"expiresAtMs":4000000000000,"presentation":{"kind":"exec","commandText":"echo held","commandPreview":"echo held","warningText":null,"host":"gateway","nodeId":null,"agentId":"main","allowedDecisions":["allow-once","deny"]}}}"# +} + +private func makeExpiredExecApprovalJSON(_ approvalID: String) -> String { + #"{"approval":{"id":"\#(approvalID)","status":"expired","urlPath":"/approve/\#(approvalID)","createdAtMs":0,"expiresAtMs":1,"resolvedAtMs":2,"reason":"timeout","presentation":{"kind":"exec","commandText":"echo expired","commandPreview":"echo expired","warningText":null,"host":"gateway","nodeId":null,"agentId":"main","allowedDecisions":["allow-once","deny"]}}}"# +} + @MainActor -private func waitForMainActorWork(_ condition: () -> Bool) async { - for _ in 0..<100 { - if condition() { - return - } +@discardableResult +private func waitForMainActorWork( + timeout: Duration = .seconds(2), + _ condition: () -> Bool) async -> Bool +{ + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } await Task.yield() } + return condition() } @MainActor @@ -343,6 +356,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer var lastSent: (id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?)? var lastDirectNodeSetupCode: String? var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage? + var sentExecApprovalPrompts: [OpenClawWatchExecApprovalPromptMessage] = [] var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage? var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage? var lastSentExecApprovalSnapshot: OpenClawWatchExecApprovalSnapshotMessage? @@ -417,6 +431,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer _ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult { self.lastSentExecApprovalPrompt = message + self.sentExecApprovalPrompts.append(message) if let sendError { throw sendError } @@ -553,10 +568,15 @@ private actor NotificationAuthorizationGate { private actor WatchSnapshotSendGate { private var didStart = false + private var resumePending = false private var continuation: CheckedContinuation? func wait() async { self.didStart = true + if self.resumePending { + self.resumePending = false + return + } await withCheckedContinuation { continuation in self.continuation = continuation } @@ -566,12 +586,72 @@ private actor WatchSnapshotSendGate { self.didStart } + func resume() { + guard let continuation else { + self.resumePending = true + return + } + continuation.resume() + self.continuation = nil + } +} + +private actor ExecApprovalResolutionGate { + private var calls = 0 + private var continuation: CheckedContinuation? + + func waitForFirstCall() async -> String { + self.calls += 1 + guard self.calls == 1 else { return "unexpected duplicate approval write" } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + return "simulated approval write failure" + } + + func callCount() -> Int { + self.calls + } + + func hasStarted() -> Bool { + self.calls > 0 + } + func resume() { self.continuation?.resume() self.continuation = nil } } +private actor ExecApprovalConcurrentWriteProbe { + private var calls: [String] = [] + private var activeWrites = 0 + private var maximumActiveWrites = 0 + private var firstContinuation: CheckedContinuation? + + func resolve(decision: String) async -> String { + self.calls.append(decision) + self.activeWrites += 1 + self.maximumActiveWrites = max(self.maximumActiveWrites, self.activeWrites) + if self.calls.count == 1 { + await withCheckedContinuation { continuation in + self.firstContinuation = continuation + } + } + self.activeWrites -= 1 + return "simulated approval write failure" + } + + func snapshot() -> (calls: [String], maximumActiveWrites: Int) { + (calls: self.calls, maximumActiveWrites: self.maximumActiveWrites) + } + + func releaseFirst() { + self.firstContinuation?.resume() + self.firstContinuation = nil + } +} + private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Void { let defaults = UserDefaults.standard let previous = defaults.object(forKey: NotificationServingPreference.storageKey) @@ -790,6 +870,393 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(appModel._test_pendingExecApprovalPrompt() == nil) } + @Test @MainActor func `explicit notification tap replaces visible approval after canonical fetch`() async throws { + let fetchGate = WatchSnapshotSendGate() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + appModel._test_setConnectedGatewayID("test-gateway") + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-visible-a", + commandText: "echo visible-a", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-tapped-b", + "status": "pending", + "urlPath": "/approve/approval-tapped-b", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo tapped-b", + "commandPreview": "echo tapped-b", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#, beforeResponse: { await fetchGate.wait() }) + + let fetching = Task { @MainActor in + await appModel._test_presentExecApprovalNotificationPrompt(ExecApprovalNotificationPrompt( + approvalId: "approval-tapped-b", + gatewayDeviceId: nil)) + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !(fetchGate.hasStarted()), ContinuousClock().now < deadline { + await Task.yield() + } + + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-visible-a") + await fetchGate.resume() + await fetching.value + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-tapped-b") + #expect(appModel._test_pendingExecApprovalPrompt()?.commandText == "echo tapped-b") + } + + @Test @MainActor func `unified approval get accepts only matching exec presentation`() throws { + let execJSON = #""" + { + "approval": { + "id": "approval-unified", + "status": "pending", + "urlPath": "/approve/approval-unified", + "createdAtMs": 100, + "expiresAtMs": 200, + "presentation": { + "kind": "exec", + "commandText": "echo unified", + "commandPreview": "echo unified", + "warningText": " Review shell expansion ", + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """# + + let decodedPrompt = try NodeAppModel._test_decodeUnifiedExecApprovalPrompt( + execJSON, + approvalID: "approval-unified") + let prompt = try #require(decodedPrompt) + #expect(prompt.kind == "exec") + #expect(prompt.commandText == "echo unified") + #expect(prompt.warningText == "Review shell expansion") + #expect(prompt.allowedDecisions == ["allow-once", "deny"]) + #expect(prompt.gatewayStableID == "test-gateway") + + #expect(try NodeAppModel._test_decodeUnifiedExecApprovalPrompt( + execJSON, + approvalID: "different-approval") == nil) + + let composedID = "approval-\u{00E9}" + let decomposedID = "approval-e\u{0301}" + let composedJSON = execJSON.replacingOccurrences( + of: "approval-unified", + with: composedID) + #expect(try NodeAppModel._test_decodeUnifiedExecApprovalPrompt( + composedJSON, + approvalID: composedID)?.id == composedID) + #expect(try NodeAppModel._test_decodeUnifiedExecApprovalPrompt( + composedJSON, + approvalID: decomposedID) == nil) + + let pluginJSON = #""" + { + "approval": { + "id": "approval-unified", + "status": "pending", + "urlPath": "/approve/approval-unified", + "createdAtMs": 100, + "expiresAtMs": 200, + "presentation": { + "kind": "plugin", + "title": "Plugin approval", + "description": "Review", + "severity": "warning", + "pluginId": "example", + "toolName": "guarded", + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """# + #expect(try NodeAppModel._test_decodeUnifiedExecApprovalPrompt( + pluginJSON, + approvalID: "approval-unified") == nil) + } + + @Test @MainActor func `exec approval prompt rejects malformed decision sets`() { + for decisions in [ + ["allow-once"], + ["allow-once", "allow-once", "deny"], + ["accept", "deny"], + [" allow-once ", "deny"], + ["allow-once", "deny "], + ] { + #expect(NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-malformed", + commandText: "echo guarded", + allowedDecisions: decisions, + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 200) == nil) + } + } + + @Test @MainActor func `unified approval resolve reports and applies canonical late winner`() async throws { + let paramsData = try JSONEncoder().encode(ApprovalResolveParams( + id: "approval-race", + kind: .exec, + decision: .deny)) + let params = try #require(JSONSerialization.jsonObject(with: paramsData) as? [String: String]) + #expect(params == [ + "id": "approval-race", + "kind": "exec", + "decision": "deny", + ]) + + let responseJSON = #""" + { + "applied": false, + "approval": { + "id": "approval-race", + "status": "allowed", + "urlPath": "/approve/approval-race", + "createdAtMs": 100, + "expiresAtMs": 200, + "resolvedAtMs": 150, + "reason": "user", + "decision": "allow-always", + "presentation": { + "kind": "exec", + "commandText": "npm publish", + "commandPreview": "npm publish", + "warningText": "Publishes a package", + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "allow-always", "deny"] + } + } + } + """# + + let decodedResult = try NodeAppModel._test_decodeUnifiedExecApprovalResolution( + responseJSON, + approvalID: "approval-race") + let result = try #require(decodedResult) + #expect(!result.applied) + #expect(result.status == "allowed") + #expect(result.decision == "allow-always") + #expect(result.text == "This approval was already set to Always Allow.") + #expect(try NodeAppModel._test_isValidUnifiedExecApprovalResolveAck( + responseJSON, + approvalID: "approval-race", + attemptedDecision: .deny)) + let mismatchedAppliedAck = try NodeAppModel._test_isValidUnifiedExecApprovalResolveAck( + responseJSON.replacingOccurrences(of: #""applied": false"#, with: #""applied": true"#), + approvalID: "approval-race", + attemptedDecision: .deny) + #expect(!mismatchedAppliedAck) + #expect(try NodeAppModel._test_decodeUnifiedExecApprovalResolution( + responseJSON, + approvalID: "different-approval") == nil) + for malformedResponse in [ + responseJSON.replacingOccurrences( + of: #""urlPath": "/approve/approval-race""#, + with: #""urlPath": """#), + responseJSON.replacingOccurrences( + of: #""createdAtMs": 100"#, + with: #""createdAtMs": -1"#), + responseJSON.replacingOccurrences( + of: #""resolvedAtMs": 150"#, + with: #""resolvedAtMs": -1"#), + responseJSON.replacingOccurrences( + of: #"["allow-once", "allow-always", "deny"]"#, + with: #"["allow-once", "deny"]"#), + responseJSON.replacingOccurrences( + of: #"["allow-once", "allow-always", "deny"]"#, + with: #"["allow-always", "allow-always", "deny"]"#), + ] { + #expect(try NodeAppModel._test_decodeUnifiedExecApprovalResolution( + malformedResponse, + approvalID: "approval-race") == nil) + } + + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-race", + commandText: "npm publish", + warningText: "Publishes a package", + allowedDecisions: ["allow-once", "allow-always", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 200))) + #expect(try await appModel._test_applyUnifiedExecApprovalResolveResult( + responseJSON, + approvalID: "approval-race", + attemptedDecision: .deny)) + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-race") + #expect(appModel._test_pendingExecApprovalState().resolved == + "This approval was already set to Always Allow.") + #expect(appModel._test_pendingExecApprovalState().tone == .success) + #expect(appModel._test_pendingExecApprovalState().resolving == false) + await appModel.resolvePendingExecApprovalPrompt(decision: "deny") + #expect(appModel._test_pendingExecApprovalState().resolved == + "This approval was already set to Always Allow.") + #expect(appModel._test_pendingExecApprovalState().resolving == false) + #expect(watchService.lastSentExecApprovalResolved?.source == "another-reviewer") + #expect(watchService.lastSentExecApprovalResolved?.outcomeText == + "This approval was already set to Always Allow.") + + let ownWinnerService = MockWatchMessagingService() + let ownWinnerModel = NodeAppModel(watchMessagingService: ownWinnerService) + try ownWinnerModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-race", + commandText: "npm publish", + allowedDecisions: ["allow-once", "allow-always", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 200))) + let ownWinnerResponse = responseJSON.replacingOccurrences( + of: #""applied": false"#, + with: #""applied": true"#) + #expect(try await ownWinnerModel._test_applyUnifiedExecApprovalResolveResult( + ownWinnerResponse, + approvalID: "approval-race", + attemptedDecision: .allowAlways)) + #expect(ownWinnerModel._test_pendingExecApprovalPrompt()?.id == "approval-race") + #expect(ownWinnerModel._test_pendingExecApprovalState().resolved == + "Approval set to Always Allow.") + #expect(ownWinnerModel._test_pendingExecApprovalInboxItems().isEmpty) + #expect(ownWinnerService.lastSentExecApprovalResolved?.source == "iphone") + + let pluginResponseJSON = #""" + { + "applied": false, + "approval": { + "id": "approval-race", + "status": "denied", + "urlPath": "/approve/approval-race", + "createdAtMs": 100, + "expiresAtMs": 200, + "resolvedAtMs": 150, + "reason": "user", + "decision": "deny", + "presentation": { + "kind": "plugin", + "title": "Plugin approval", + "description": "Review", + "severity": "warning", + "pluginId": "example", + "toolName": "guarded", + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """# + #expect(try NodeAppModel._test_decodeUnifiedExecApprovalResolution( + pluginResponseJSON, + approvalID: "approval-race") == nil) + } + + @Test @MainActor func `legacy approval resolve acknowledgment uses neutral gateway attribution`() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-legacy-ack", + commandText: "echo legacy", + allowedDecisions: ["deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: nil))) + + await appModel._test_applyLegacyExecApprovalTerminal( + approvalID: "approval-legacy-ack", + decision: .deny) + + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-legacy-ack") + #expect(appModel._test_pendingExecApprovalState().resolved == "Approval denied.") + #expect(watchService.lastSentExecApprovalResolved?.source == "gateway") + #expect(watchService.lastSentExecApprovalResolved?.outcomeText == "Approval denied.") + } + + @Test @MainActor func `canonical denial keeps destructive terminal tone`() async throws { + let responseJSON = #""" + { + "applied": false, + "approval": { + "id": "approval-denied-elsewhere", + "status": "denied", + "urlPath": "/approve/approval-denied-elsewhere", + "createdAtMs": 100, + "expiresAtMs": 200, + "resolvedAtMs": 150, + "reason": "user", + "decision": "deny", + "presentation": { + "kind": "exec", + "commandText": "rm -rf build", + "commandPreview": "rm build", + "warningText": "Deletes build output", + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """# + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-denied-elsewhere", + commandText: "rm -rf build", + warningText: "Deletes build output", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 200))) + + #expect(try await appModel._test_applyUnifiedExecApprovalResolveResult( + responseJSON, + approvalID: "approval-denied-elsewhere", + attemptedDecision: .allowOnce)) + #expect(appModel._test_pendingExecApprovalState().resolved == + "This approval was already denied.") + #expect(appModel._test_pendingExecApprovalState().tone == .danger) + } + @Test @MainActor func `gateway switch invalidates privileged approval surfaces`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } @@ -872,6 +1339,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs().isEmpty) #expect(watchService.lastSentExecApprovalSnapshot?.approvals.isEmpty == true) #expect(notificationCenter.pendingRemovedIdentifiers.contains([ + "exec.approval-v2.8:device-a.recovery-a", "exec.approval.device-a.recovery-a", ])) #expect(notificationCenter.deliveredRemovedIdentifiers.contains([ @@ -908,6 +1376,702 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi .effectiveStableID) } + @Test @MainActor func `uncertain approval survives dismiss and restart until canonical readback`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + let approvalID = "approval-uncertain-dismissible" + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo uncertain", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + appModel._test_presentExecApprovalPrompt(prompt) + + let uncertainMessage = "Decision status is unknown. Actions remain locked until OpenClaw reconnects." + appModel._test_setPendingExecApprovalPromptUncertain(uncertainMessage) + + #expect(appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().canDismiss) + appModel._test_dismissPendingExecApprovalPrompt() + #expect(appModel._test_pendingExecApprovalPrompt() == nil) + + appModel._test_presentPendingExecApprovalFromInbox( + approvalID: approvalID, + gatewayStableID: prompt.gatewayStableID) + #expect(appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == uncertainMessage) + + let restoredModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + restoredModel._test_presentExecApprovalPrompt(prompt) + #expect(restoredModel._test_pendingExecApprovalState().resolving) + #expect(restoredModel._test_pendingExecApprovalState().error == uncertainMessage) + + restoredModel._test_setUnifiedExecApprovalGetResponse(makePendingExecApprovalJSON(approvalID)) + await restoredModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + #expect(!restoredModel._test_pendingExecApprovalState().resolving) + #expect(restoredModel._test_pendingExecApprovalState().error == + "The previous decision was not recorded. Review and try again.") + } + + @Test @MainActor func `readback started before uncertainty cannot unlock approval`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let approvalID = "approval-uncertain-readback-fence" + let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo fenced", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + let fetchGate = WatchSnapshotSendGate() + appModel._test_setUnifiedExecApprovalGetResponse( + makePendingExecApprovalJSON(approvalID), + beforeResponse: { await fetchGate.wait() }) + + let reconciliation = Task { @MainActor in + await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !fetchGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(await fetchGate.hasStarted()) + let uncertainMessage = "Decision status is unknown while an older readback is in flight." + appModel._test_setPendingExecApprovalPromptUncertain(uncertainMessage) + await fetchGate.resume() + _ = await reconciliation.value + + #expect(appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == uncertainMessage) + } + + @Test @MainActor func `expired persisted uncertainty remains a canonical readback candidate`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let approvalID = "approval-expired-uncertainty-readback" + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo expired", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 1)) + let firstModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + firstModel._test_presentExecApprovalPrompt(prompt) + firstModel._test_setPendingExecApprovalPromptUncertain("Awaiting expired terminal truth.") + #expect(firstModel._test_watchExecApprovalCacheIDs().isEmpty) + #expect(firstModel._test_pendingPersistedExecApprovalReadbacks().map(\.approvalId) == [approvalID]) + + let restoredModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + restoredModel._test_setConnectedGatewayID(prompt.gatewayStableID) + #expect(restoredModel._test_watchExecApprovalCacheIDs().isEmpty) + #expect(restoredModel._test_pendingPersistedExecApprovalReadbacks().map(\.approvalId) == [approvalID]) + restoredModel._test_setUnifiedExecApprovalGetResponse(makeExpiredExecApprovalJSON(approvalID)) + await restoredModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + + #expect(restoredModel._test_pendingPersistedExecApprovalReadbacks().isEmpty) + restoredModel._test_presentExecApprovalPrompt(prompt) + #expect(restoredModel._test_pendingExecApprovalPrompt() == nil) + } + + @Test @MainActor func `canonical pending readback resumes queued watch decision`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let approvalID = "approval-watch-uncertain-resume" + let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo watch", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + appModel._test_presentExecApprovalPrompt(prompt) + appModel._test_setPendingExecApprovalPromptUncertain("Awaiting canonical state.") + let watchEvent = WatchExecApprovalResolveEvent( + replyId: "watch-uncertain-resume", + approvalId: approvalID, + gatewayStableID: prompt.gatewayStableID, + decision: .deny, + sentAtMs: 123, + transport: "test") + let resolvedImmediately = await appModel._test_handleWatchExecApprovalResolve(watchEvent) + #expect(!resolvedImmediately) + + let writeGate = ExecApprovalResolutionGate() + appModel._test_setExecApprovalResolutionFailureHandler { _, _, _ in + await writeGate.waitForFirstCall() + } + appModel._test_setUnifiedExecApprovalGetResponse(makePendingExecApprovalJSON(approvalID)) + await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !writeGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + let writeCount = await writeGate.callCount() + #expect(writeCount == 1) + await writeGate.resume() + } + + @Test @MainActor func `uncertain result stays owner scoped after another prompt replaces it`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + let firstPrompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-uncertain-replaced", + commandText: "echo first", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + let secondPrompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-visible-replacement", + commandText: "echo second", + allowedDecisions: ["deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + appModel._test_presentExecApprovalPrompt(firstPrompt) + let writeGate = ExecApprovalResolutionGate() + appModel._test_setExecApprovalResolutionUncertainHandler { _, _, _ in + await writeGate.waitForFirstCall() + } + + let firstWrite = Task { @MainActor in + await appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !writeGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(await writeGate.hasStarted()) + appModel._test_presentExecApprovalPrompt(secondPrompt) + await writeGate.resume() + await firstWrite.value + + #expect(appModel._test_pendingExecApprovalPrompt()?.id == secondPrompt.id) + appModel._test_dismissPendingExecApprovalPrompt() + appModel._test_presentPendingExecApprovalFromInbox( + approvalID: firstPrompt.id, + gatewayStableID: firstPrompt.gatewayStableID) + #expect(appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == "simulated approval write failure") + } + + @Test @MainActor func `canonical terminal invalidates an in flight uncertain result`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + let approvalID = "approval-terminal-beats-uncertain" + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo terminal", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + let writeGate = ExecApprovalResolutionGate() + appModel._test_setExecApprovalResolutionUncertainHandler { _, _, _ in + await writeGate.waitForFirstCall() + } + + let pendingWrite = Task { @MainActor in + await appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !writeGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(await writeGate.hasStarted()) + let terminalApplied = await appModel._test_applyLegacyExecApprovalTerminal( + approvalID: approvalID, + decision: .deny) + #expect(terminalApplied) + await writeGate.resume() + await pendingWrite.value + + #expect(appModel._test_pendingExecApprovalState().resolved == "Approval denied.") + #expect(!appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalInboxItems().isEmpty) + } + + @Test @MainActor func `gateway switch during uncertain resolve keeps owner frozen after switching back`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + defer { appModel.disconnectGateway() } + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone") + let gatewayA = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "gateway-a", + tls: nil, + token: "token-a", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let gatewayB = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:2")), + stableID: "gateway-b", + tls: nil, + token: "token-b", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let approvalID = "approval-switch-mid-uncertain" + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + gatewayStableID: gatewayA.effectiveStableID, + commandText: "echo switch", + allowedDecisions: ["allow-once", "deny"], + host: "gateway-a", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + + appModel.applyGatewayConnectConfig(gatewayA) + appModel._test_presentExecApprovalPrompt(prompt) + let writeGate = ExecApprovalResolutionGate() + appModel._test_setExecApprovalResolutionUncertainHandler { _, _, _ in + await writeGate.waitForFirstCall() + } + + let pendingWrite = Task { @MainActor in + await appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !writeGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(await writeGate.hasStarted()) + appModel.applyGatewayConnectConfig(gatewayB) + await writeGate.resume() + await pendingWrite.value + + // The invalidated attempt must not surface UI on the newly selected gateway, + // but the lost outcome must survive as an owner-scoped readback candidate. + #expect(appModel._test_pendingExecApprovalPrompt() == nil) + #expect(appModel._test_pendingPersistedExecApprovalReadbacks().contains { readback in + readback.approvalId == approvalID && readback.gatewayStableID == gatewayA.effectiveStableID + }) + + appModel.applyGatewayConnectConfig(gatewayA) + appModel._test_presentExecApprovalPrompt(prompt) + #expect(appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == "simulated approval write failure") + + let restoredModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + restoredModel._test_presentExecApprovalPrompt(prompt) + #expect(restoredModel._test_pendingExecApprovalState().resolving) + #expect(restoredModel._test_pendingExecApprovalState().error == "simulated approval write failure") + } + + @Test @MainActor func `gateway switch during in flight resolve keeps the owner write fence`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + defer { appModel.disconnectGateway() } + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone") + let gatewayA = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "gateway-a", + tls: nil, + token: "token-a", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let gatewayB = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:2")), + stableID: "gateway-b", + tls: nil, + token: "token-b", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let approvalID = "approval-switch-mid-write" + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + gatewayStableID: gatewayA.effectiveStableID, + commandText: "echo fence", + allowedDecisions: ["allow-once", "deny"], + host: "gateway-a", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + + appModel.applyGatewayConnectConfig(gatewayA) + appModel._test_presentExecApprovalPrompt(prompt) + let writeGate = ExecApprovalResolutionGate() + appModel._test_setExecApprovalResolutionFailureHandler { _, _, _ in + await writeGate.waitForFirstCall() + } + + let pendingWrite = Task { @MainActor in + await appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !writeGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(await writeGate.hasStarted()) + + appModel.applyGatewayConnectConfig(gatewayB) + appModel.applyGatewayConnectConfig(gatewayA) + appModel._test_presentExecApprovalPrompt(prompt) + // The preserved write fence keeps the owner card non-actionable: the prompt + // renders as resolving and a second resolution attempt never reaches transport. + #expect(appModel._test_pendingExecApprovalState().resolving) + await appModel.resolvePendingExecApprovalPrompt(decision: "deny") + #expect(await writeGate.callCount() == 1) + + await writeGate.resume() + await pendingWrite.value + + // Settling the original write releases the fence and reports its outcome. + #expect(!appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == "simulated approval write failure") + await appModel.resolvePendingExecApprovalPrompt(decision: "deny") + #expect(await writeGate.callCount() == 2) + } + + @Test @MainActor func `gateway switch during unknown ack readback keeps re-presented card resolving`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + defer { appModel.disconnectGateway() } + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone") + let gatewayA = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "gateway-a", + tls: nil, + token: "token-a", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let gatewayB = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:2")), + stableID: "gateway-b", + tls: nil, + token: "token-b", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let approvalID = "approval-switch-mid-readback" + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + gatewayStableID: gatewayA.effectiveStableID, + commandText: "echo readback", + allowedDecisions: ["allow-once", "deny"], + host: "gateway-a", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + + appModel.applyGatewayConnectConfig(gatewayA) + appModel._test_presentExecApprovalPrompt(prompt) + appModel._test_setExecApprovalResolutionUnknownAck() + let fetchGate = ExecApprovalResolutionGate() + appModel._test_setUnifiedExecApprovalGetResponse( + makePendingExecApprovalJSON(approvalID), + beforeResponse: { _ = await fetchGate.waitForFirstCall() }) + + let pendingWrite = Task { @MainActor in + await appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !fetchGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(await fetchGate.hasStarted()) + + appModel.applyGatewayConnectConfig(gatewayB) + appModel.applyGatewayConnectConfig(gatewayA) + appModel._test_presentExecApprovalPrompt(prompt) + // The write settled but readback has not classified it: the attempt lease is + // still held, so the re-presented card must render resolving (non-actionable) + // and a second resolution attempt must never reach the transport. + #expect(appModel._test_pendingExecApprovalState().resolving) + await appModel.resolvePendingExecApprovalPrompt(decision: "deny") + #expect(await fetchGate.callCount() == 1) + + await fetchGate.resume() + await pendingWrite.value + + // The gated readback lost its route to the A->B->A switch, so the settle is the + // owner-frozen uncertain contract with a durable readback record. + #expect(appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == + "Decision status is unknown. Actions remain locked until OpenClaw reconnects.") + #expect(appModel._test_pendingPersistedExecApprovalReadbacks().contains { readback in + readback.approvalId == approvalID && readback.gatewayStableID == gatewayA.effectiveStableID + }) + } + + @Test @MainActor func `watch pending retry after unknown ack unlocks the phone card`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + let approvalID = "approval-watch-unknown-ack-retry" + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo watch retry", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + appModel._test_presentExecApprovalPrompt(prompt) + appModel._test_setExecApprovalResolutionUnknownAck() + let fetchGate = ExecApprovalResolutionGate() + appModel._test_setUnifiedExecApprovalGetResponse( + makePendingExecApprovalJSON(approvalID), + beforeResponse: { _ = await fetchGate.waitForFirstCall() }) + + let watchResolve = Task { @MainActor in + await appModel._test_handleWatchExecApprovalResolve(WatchExecApprovalResolveEvent( + replyId: "watch-unknown-ack-retry", + approvalId: approvalID, + gatewayStableID: prompt.gatewayStableID, + decision: .allowOnce, + sentAtMs: nil, + transport: "test")) + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !fetchGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(await fetchGate.hasStarted()) + + // Re-presenting during the gated readback keeps the card fenced as resolving. + appModel._test_presentExecApprovalPrompt(prompt) + #expect(appModel._test_pendingExecApprovalState().resolving) + + await fetchGate.resume() + let completed = await watchResolve.value + #expect(completed) + + // Pending readback settled the watch attempt: the phone card must unlock with + // the same retry message the phone path stamps. + #expect(!appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == + "The previous decision was not recorded. Review and try again.") + + // The released lease admits a fresh resolve that reaches the transport again. + await appModel.resolvePendingExecApprovalPrompt(decision: "deny") + #expect(await fetchGate.callCount() == 2) + } + + @Test @MainActor func `gateway switch during uncertain watch resolve records owner uncertainty`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + defer { appModel.disconnectGateway() } + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone") + let gatewayA = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: "gateway-a", + tls: nil, + token: "token-a", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let gatewayB = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:2")), + stableID: "gateway-b", + tls: nil, + token: "token-b", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let approvalID = "approval-watch-switch-mid-uncertain" + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + gatewayStableID: gatewayA.effectiveStableID, + commandText: "echo watch switch", + allowedDecisions: ["allow-once", "deny"], + host: "gateway-a", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + + appModel.applyGatewayConnectConfig(gatewayA) + appModel._test_presentExecApprovalPrompt(prompt) + let writeGate = ExecApprovalResolutionGate() + appModel._test_setExecApprovalResolutionUncertainHandler { _, _, _ in + await writeGate.waitForFirstCall() + } + + let watchResolve = Task { @MainActor in + await appModel._test_handleWatchExecApprovalResolve(WatchExecApprovalResolveEvent( + replyId: "watch-switch-mid-uncertain", + approvalId: approvalID, + gatewayStableID: gatewayA.effectiveStableID, + decision: .allowOnce, + sentAtMs: nil, + transport: "test")) + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !writeGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + #expect(await writeGate.hasStarted()) + appModel.applyGatewayConnectConfig(gatewayB) + await writeGate.resume() + let completed = await watchResolve.value + + // The Watch decision was written with an unknown outcome: consume it, keep the + // owner-scoped uncertainty + readback record instead of dropping every trace. + #expect(completed) + #expect(appModel._test_pendingPersistedExecApprovalReadbacks().contains { readback in + readback.approvalId == approvalID && readback.gatewayStableID == gatewayA.effectiveStableID + }) + + appModel.applyGatewayConnectConfig(gatewayA) + appModel._test_presentExecApprovalPrompt(prompt) + #expect(appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == "simulated approval write failure") + } + + @Test @MainActor func `canonically equivalent gateway owners stay distinct across switch and resolve`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let composedGatewayID = "gateway-\u{00E9}" + let decomposedGatewayID = "gateway-e\u{0301}" + #expect(composedGatewayID == decomposedGatewayID) + #expect(GatewayStableIdentifier.key(composedGatewayID) != + GatewayStableIdentifier.key(decomposedGatewayID)) + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: "ios", + clientMode: "node", + clientDisplayName: "Phone") + let switchModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + defer { switchModel.disconnectGateway() } + let composedGateway = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:1")), + stableID: composedGatewayID, + tls: nil, + token: "token-a", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + let decomposedGateway = try GatewayConnectConfig( + url: #require(URL(string: "wss://127.0.0.1:2")), + stableID: decomposedGatewayID, + tls: nil, + token: "token-b", + bootstrapToken: nil, + password: nil, + nodeOptions: options) + + switchModel.applyGatewayConnectConfig(composedGateway) + try switchModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-exact-gateway-switch", + gatewayStableID: composedGatewayID, + commandText: "echo composed", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + switchModel.applyGatewayConnectConfig(decomposedGateway) + #expect(switchModel._test_pendingExecApprovalPrompt() == nil) + #expect(switchModel._test_watchExecApprovalCacheIDs().isEmpty) + + let watchService = MockWatchMessagingService() + let resolveModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + resolveModel._test_setConnectedGatewayID(composedGatewayID) + try resolveModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-exact-gateway-resolve", + gatewayStableID: composedGatewayID, + commandText: "echo resolve", + allowedDecisions: ["deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + resolveModel._test_setConnectedGatewayID(decomposedGatewayID) + + let applied = await resolveModel._test_applyLegacyExecApprovalTerminal( + approvalID: "approval-exact-gateway-resolve", + decision: .deny, + expectedGatewayStableID: composedGatewayID) + #expect(!applied) + #expect(resolveModel._test_pendingExecApprovalPrompt()?.id == "approval-exact-gateway-resolve") + #expect(watchService.lastSentExecApprovalResolved == nil) + } + @Test @MainActor func `offline resolution push remains durable until its gateway reconnects`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } @@ -929,6 +2093,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(await firstModel.handleExecApprovalResolvedRemotePush(push)) #expect(firstModel._test_pendingExecApprovalResolvedPushes() == [push]) #expect(notificationCenter.pendingRemovedIdentifiers == [[ + "exec.approval-v2.16:gateway-device-a.approval-resolved-offline", "exec.approval.gateway-device-a.approval-resolved-offline", ]]) #expect(notificationCenter.deliveredRemovedIdentifiers == [["offline-request-alert"]]) @@ -2550,6 +3715,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi NodeAppModel._test_makeExecApprovalPrompt( id: "approval-watch-sync", commandText: "npm publish", + warningText: "Publishes a package", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: "node-1", @@ -2557,17 +3723,23 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi expiresAtMs: 1234)) appModel._test_presentExecApprovalPrompt(prompt) - await Task.yield() + let promptPublished = await waitForMainActorWork { + watchService.lastSentExecApprovalPrompt?.approval.id == "approval-watch-sync" + } + try #require(promptPublished) let sent = try #require(watchService.lastSentExecApprovalPrompt) #expect(sent.approval.id == "approval-watch-sync") #expect(sent.approval.allowedDecisions == [.allowOnce, .deny]) + #expect(sent.approval.warningText == "Publishes a package") #expect(sent.approval.host == "gateway") #expect(sent.approval.risk == nil) - #expect(sent.resetResolvingState != true) + #expect(sent.resetResolutionAttemptId == nil) } @Test @MainActor func `watch exec approval snapshot request publishes cached approvals in background`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000 @@ -2581,45 +3753,981 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi nodeId: nil, agentId: nil, expiresAtMs: futureExpiryMs))) - await Task.yield() + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-watch-snapshot", + "status": "pending", + "urlPath": "/approve/approval-watch-snapshot", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo from watch", + "commandPreview": "echo from watch", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": null, + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) + let initialSnapshotPublished = await waitForMainActorWork { + watchService.sentExecApprovalSnapshots.contains { snapshot in + snapshot.requestId == nil && + snapshot.approvals.map(\.id) == ["approval-watch-snapshot"] + } + } + try #require(initialSnapshotPublished) appModel.setScenePhase(.background) + let snapshotCount = watchService.sentExecApprovalSnapshots.count watchService.emitExecApprovalSnapshotRequest( WatchExecApprovalSnapshotRequestEvent( requestId: "snapshot-1", + gatewayStableID: "test-gateway", sentAtMs: 111, transport: "sendMessage")) - await Task.yield() + let correlatedSnapshotPublished = await waitForMainActorWork { + watchService.sentExecApprovalSnapshots.dropFirst(snapshotCount).contains { snapshot in + snapshot.requestId == "snapshot-1" && + snapshot.requestGatewayStableID == "test-gateway" + } + } + try #require(correlatedSnapshotPublished) - let snapshot = try #require(watchService.lastSentExecApprovalSnapshot) + let snapshot = try #require(watchService.sentExecApprovalSnapshots + .dropFirst(snapshotCount) + .first { $0.requestId == "snapshot-1" }) #expect(snapshot.approvals.map(\.id) == ["approval-watch-snapshot"]) + #expect(snapshot.requestId == "snapshot-1") + #expect(snapshot.requestGatewayStableID == "test-gateway") } - @Test @MainActor func `watch exec approval snapshot request skips foreground recovery`() async throws { + @Test @MainActor func `foreground watch snapshot acknowledgment requires canonical readback`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) let futureExpiryMs = Int64(Date().timeIntervalSince1970 * 1000) + 60000 try appModel._test_presentExecApprovalPrompt( #require( NodeAppModel._test_makeExecApprovalPrompt( - id: "approval-watch-foreground-skip", + id: "approval-watch-foreground", commandText: "echo foreground", allowedDecisions: ["allow-once", "deny"], host: "gateway", nodeId: nil, agentId: nil, expiresAtMs: futureExpiryMs))) - await Task.yield() + let canonicalResponse = #""" + { + "approval": { + "id": "approval-watch-foreground", + "status": "pending", + "urlPath": "/approve/approval-watch-foreground", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo foreground", + "commandPreview": "echo foreground", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": null, + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """# + let initialSnapshotPublished = await waitForMainActorWork { + watchService.sentExecApprovalSnapshots.contains { snapshot in + snapshot.requestId == nil && + snapshot.approvals.map(\.id) == ["approval-watch-foreground"] + } + } + try #require(initialSnapshotPublished) + watchService.lastSentExecApprovalSnapshot = nil + let snapshotCountBeforeMatchingRequest = watchService.sentExecApprovalSnapshots.count + + appModel._test_setExecApprovalPromptFetchFailure("gateway unavailable") + await appModel._test_refreshWatchExecApprovalSnapshotOnDemand( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-foreground-failed", + gatewayStableID: "test-gateway", + sentAtMs: 221, + transport: "sendMessage")) + #expect(watchService.sentExecApprovalSnapshots.count == snapshotCountBeforeMatchingRequest) + + appModel._test_setUnifiedExecApprovalGetResponse(canonicalResponse) + await appModel._test_refreshWatchExecApprovalSnapshotOnDemand( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-foreground", + gatewayStableID: "test-gateway", + sentAtMs: 222, + transport: "sendMessage")) + let matchingSnapshotPublished = await waitForMainActorWork { + watchService.sentExecApprovalSnapshots.dropFirst(snapshotCountBeforeMatchingRequest).contains { snapshot in + snapshot.requestId == "snapshot-foreground" && + snapshot.requestGatewayStableID == "test-gateway" + } + } + try #require(matchingSnapshotPublished) + let matchingSnapshot = try #require(watchService.sentExecApprovalSnapshots + .dropFirst(snapshotCountBeforeMatchingRequest) + .first { $0.requestId == "snapshot-foreground" }) + + #expect(matchingSnapshot.approvals.map(\.id) == [ + "approval-watch-foreground", + ]) + #expect(matchingSnapshot.requestId == "snapshot-foreground") + #expect(matchingSnapshot.requestGatewayStableID == "test-gateway") + + watchService.lastSentExecApprovalSnapshot = nil + let snapshotCountBeforeWrongOwnerRequest = watchService.sentExecApprovalSnapshots.count + watchService.emitExecApprovalSnapshotRequest( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-wrong-owner", + gatewayStableID: "other-gateway", + sentAtMs: 223, + transport: "sendMessage")) + let uncorrelatedSnapshotPublished = await waitForMainActorWork { + watchService.sentExecApprovalSnapshots.dropFirst(snapshotCountBeforeWrongOwnerRequest) + .contains { snapshot in + snapshot.requestId == nil && snapshot.requestGatewayStableID == nil + } + } + try #require(uncorrelatedSnapshotPublished) + let uncorrelatedSnapshot = try #require(watchService.sentExecApprovalSnapshots + .dropFirst(snapshotCountBeforeWrongOwnerRequest) + .first { $0.requestId == nil && $0.requestGatewayStableID == nil }) + #expect(uncorrelatedSnapshot.requestId == nil) + #expect(uncorrelatedSnapshot.requestGatewayStableID == nil) + } + + @Test @MainActor func `unknown held attempt stays frozen after pending readback`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + let approvalID = "approval-held-pending" + let resolutionAttemptID = "attempt-e\u{0301}-\u{0085}" + appModel._test_setUnifiedExecApprovalGetResponse(makePendingExecApprovalJSON(approvalID)) + + await appModel._test_refreshWatchExecApprovalSnapshotOnDemand( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-held-pending", + gatewayStableID: "test-gateway", + heldApprovals: [WatchExecApprovalSnapshotRequestItem( + approvalId: approvalID, + activeResolutionAttemptId: resolutionAttemptID)], + sentAtMs: 225, + transport: "sendMessage")) + + let snapshot = try #require(watchService.lastSentExecApprovalSnapshot) + #expect(snapshot.requestId == "snapshot-held-pending") + #expect(snapshot.approvals.map(\.id) == [approvalID]) + #expect(!watchService.sentExecApprovalPrompts.contains { + $0.approval.id == approvalID && $0.resetResolutionAttemptId != nil + }) + } + + @Test @MainActor func `failed held approval readback sends no request snapshot`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + appModel._test_setUnifiedExecApprovalGetResponse(#"{"invalid":true}"#) + let snapshotCount = watchService.sentExecApprovalSnapshots.count + + await appModel._test_refreshWatchExecApprovalSnapshotOnDemand( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-readback-failed", + gatewayStableID: "test-gateway", + heldApprovals: [WatchExecApprovalSnapshotRequestItem( + approvalId: "approval-watch-readback-failure", + activeResolutionAttemptId: nil)], + sentAtMs: 225, + transport: "sendMessage")) + + #expect(watchService.sentExecApprovalSnapshots.count == snapshotCount) + } + + @Test @MainActor func `watch refresh classifies every held approval before acknowledging`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + let approvalIDs = ["approval-held-b", "approval-held-a"] + appModel._test_setUnifiedExecApprovalGetResponses(approvalIDs.map { + (approvalID: $0, json: makePendingExecApprovalJSON($0)) + }) + + await appModel._test_refreshWatchExecApprovalSnapshotOnDemand( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-held-all", + gatewayStableID: "test-gateway", + heldApprovals: approvalIDs.map { + WatchExecApprovalSnapshotRequestItem( + approvalId: $0, + activeResolutionAttemptId: nil) + }, + sentAtMs: 226, + transport: "sendMessage")) + + let snapshot = try #require(watchService.lastSentExecApprovalSnapshot) + #expect(snapshot.requestId == "snapshot-held-all") + #expect(snapshot.approvals.map(\.id) == approvalIDs.sorted()) + } + + @Test @MainActor func `canonical watch refresh does not acknowledge byte distinct owner`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let composedGatewayID = "gateway-\u{00E9}" + let decomposedGatewayID = "gateway-e\u{0301}" + #expect(composedGatewayID == decomposedGatewayID) + #expect(GatewayStableIdentifier.key(composedGatewayID) != + GatewayStableIdentifier.key(decomposedGatewayID)) + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID(composedGatewayID) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-watch-exact-owner", + gatewayStableID: composedGatewayID, + commandText: "echo exact owner", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-watch-exact-owner", + "status": "pending", + "urlPath": "/approve/approval-watch-exact-owner", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo exact owner", + "commandPreview": "echo exact owner", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) + await waitForMainActorWork { watchService.lastSentExecApprovalSnapshot != nil } + let snapshotCount = watchService.sentExecApprovalSnapshots.count + + watchService.emitExecApprovalSnapshotRequest( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-byte-distinct-owner", + gatewayStableID: decomposedGatewayID, + sentAtMs: 227, + transport: "sendMessage")) + await waitForMainActorWork { + watchService.sentExecApprovalSnapshots.count > snapshotCount + } + + let snapshot = try #require(watchService.sentExecApprovalSnapshots.last) + #expect(snapshot.approvals.map(\.id) == ["approval-watch-exact-owner"]) + #expect(snapshot.requestId == nil) + #expect(snapshot.requestGatewayStableID == nil) + #expect(try Array(#require(snapshot.gatewayStableID).utf8) == Array(composedGatewayID.utf8)) + } + + @Test @MainActor func `not found canonical watch refresh acknowledges request`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-watch-not-found", + commandText: "echo cached", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + appModel._test_setExecApprovalPromptFetchStale() + await waitForMainActorWork { watchService.lastSentExecApprovalSnapshot != nil } + + watchService.emitExecApprovalSnapshotRequest( + WatchExecApprovalSnapshotRequestEvent( + requestId: "snapshot-not-found", + gatewayStableID: "test-gateway", + sentAtMs: 226, + transport: "sendMessage")) + await waitForMainActorWork { + watchService.lastSentExecApprovalSnapshot?.requestId == "snapshot-not-found" + } + + #expect(watchService.lastSentExecApprovalSnapshot?.approvals.isEmpty == true) + #expect(watchService.lastSentExecApprovalExpired?.approvalId == "approval-watch-not-found") + #expect(watchService.lastSentExecApprovalExpired?.reason == .notFound) + } + + @Test @MainActor func `foreground watch snapshot acknowledgment follows canonical readback`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-watch-stale-cache", + commandText: "echo stale", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-watch-stale-cache", + "status": "denied", + "urlPath": "/approve/approval-watch-stale-cache", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "resolvedAtMs": 150, + "reason": "user", + "decision": "deny", + "presentation": { + "kind": "exec", + "commandText": "echo stale", + "commandPreview": "echo stale", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) watchService.lastSentExecApprovalSnapshot = nil watchService.emitExecApprovalSnapshotRequest( WatchExecApprovalSnapshotRequestEvent( - requestId: "snapshot-foreground", - sentAtMs: 222, + requestId: "snapshot-canonical", + gatewayStableID: "test-gateway", + sentAtMs: 224, transport: "sendMessage")) - await Task.yield() + await waitForMainActorWork { + watchService.lastSentExecApprovalSnapshot?.requestId == "snapshot-canonical" + } - #expect(watchService.lastSentExecApprovalSnapshot == nil) + #expect(watchService.lastSentExecApprovalSnapshot?.approvals.isEmpty == true) + #expect(watchService.lastSentExecApprovalResolved?.approvalId == "approval-watch-stale-cache") + #expect(watchService.lastSentExecApprovalResolved?.decision == .deny) + } + + @Test @MainActor func `watch approval cache miss reports canonical terminal readback`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-watch-terminal-readback", + "status": "denied", + "urlPath": "/approve/approval-watch-terminal-readback", + "createdAtMs": 100, + "expiresAtMs": 200, + "resolvedAtMs": 150, + "reason": "user", + "decision": "deny", + "presentation": { + "kind": "exec", + "commandText": "echo guarded", + "commandPreview": "echo guarded", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) + + let handled = await appModel._test_handleWatchExecApprovalResolve( + WatchExecApprovalResolveEvent( + replyId: "watch-terminal-readback", + approvalId: "approval-watch-terminal-readback", + gatewayStableID: "test-gateway", + decision: .allowOnce, + sentAtMs: 123, + transport: "test")) + + #expect(handled) + #expect(watchService.lastSentExecApprovalResolved?.approvalId == + "approval-watch-terminal-readback") + #expect(watchService.lastSentExecApprovalResolved?.decision == .deny) + #expect(watchService.lastSentExecApprovalResolved?.source == "another-reviewer") + #expect(watchService.lastSentExecApprovalExpired == nil) + } + + @Test @MainActor func `watch approval cache miss reports canonical pending readback`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-watch-pending-readback", + "status": "pending", + "urlPath": "/approve/approval-watch-pending-readback", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo guarded", + "commandPreview": "echo guarded", + "warningText": "Review this command", + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["deny"] + } + } + } + """#) + + let handled = await appModel._test_handleWatchExecApprovalResolve( + WatchExecApprovalResolveEvent( + replyId: "watch-pending-readback", + approvalId: "approval-watch-pending-readback", + gatewayStableID: "test-gateway", + decision: .allowOnce, + sentAtMs: 123, + transport: "test")) + + #expect(handled) + let prompt = try #require(watchService.lastSentExecApprovalPrompt) + #expect(prompt.approval.id == "approval-watch-pending-readback") + #expect(prompt.approval.allowedDecisions == [.deny]) + #expect(prompt.resetResolutionAttemptId == "watch-pending-readback") + #expect(watchService.lastSentExecApprovalExpired == nil) + } + + @Test @MainActor func `delayed terminal fetch does not mutate another visible approval`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let fetchGate = WatchSnapshotSendGate() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + appModel._test_setConnectedGatewayID("test-gateway") + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-visible-b", + commandText: "echo visible-b", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-terminal-a", + "status": "denied", + "urlPath": "/approve/approval-terminal-a", + "createdAtMs": 100, + "expiresAtMs": 200, + "resolvedAtMs": 150, + "reason": "user", + "decision": "deny", + "presentation": { + "kind": "exec", + "commandText": "echo terminal-a", + "commandPreview": "echo terminal-a", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#, beforeResponse: { await fetchGate.wait() }) + + let fetching = Task { @MainActor in + await appModel._test_presentExecApprovalGatewayEventPrompt("approval-terminal-a") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !(fetchGate.hasStarted()), ContinuousClock().now < deadline { + await Task.yield() + } + + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-visible-b") + #expect(appModel._test_pendingExecApprovalState().resolving == false) + await fetchGate.resume() + await fetching.value + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-visible-b") + #expect(appModel._test_pendingExecApprovalState().resolving == false) + #expect(appModel._test_pendingExecApprovalState().resolved == nil) + } + + @Test @MainActor func `delayed pending fetch cannot replace a newer visible approval`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let fetchGate = WatchSnapshotSendGate() + let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + appModel._test_setConnectedGatewayID("test-gateway") + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-delayed-a", + "status": "pending", + "urlPath": "/approve/approval-delayed-a", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo delayed-a", + "commandPreview": "echo delayed-a", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#, beforeResponse: { await fetchGate.wait() }) + let fetching = Task { @MainActor in + await appModel._test_presentExecApprovalGatewayEventPrompt("approval-delayed-a") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !(fetchGate.hasStarted()), ContinuousClock().now < deadline { + await Task.yield() + } + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-newer-b", + commandText: "echo newer-b", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + + await fetchGate.resume() + await fetching.value + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-newer-b") + #expect(appModel._test_pendingExecApprovalState().resolving == false) + } + + @Test @MainActor func `terminal event tombstone blocks delayed pending reconciliation resurrection`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let fetchGate = WatchSnapshotSendGate() + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + let approvalID = "approval-terminal-interleave" + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo guarded", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + await waitForMainActorWork { watchService.lastSentExecApprovalPrompt != nil } + watchService.lastSentExecApprovalPrompt = nil + watchService.sentExecApprovalPrompts.removeAll() + appModel._test_setUnifiedExecApprovalGetResponse(makePendingExecApprovalJSON(approvalID), beforeResponse: { + await fetchGate.wait() + }) + + let reconciling = Task { @MainActor in + await appModel._test_reconcileWatchExecApprovalCache(reason: "watch_request") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !fetchGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + + let terminalJSON = #""" + { + "applied": true, + "approval": { + "id": "approval-terminal-interleave", + "status": "allowed", + "urlPath": "/approve/approval-terminal-interleave", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "resolvedAtMs": 150, + "reason": "user", + "decision": "allow-once", + "presentation": { + "kind": "exec", + "commandText": "echo guarded", + "commandPreview": "echo guarded", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """# + #expect(try await appModel._test_applyUnifiedExecApprovalResolveResult( + terminalJSON, + approvalID: approvalID, + attemptedDecision: .allowOnce)) + await fetchGate.resume() + _ = await reconciling.value + + #expect(appModel._test_pendingExecApprovalInboxItems().isEmpty) + #expect(appModel._test_pendingExecApprovalPrompt()?.id == approvalID) + #expect(appModel._test_pendingExecApprovalState().resolved == "Approval allowed once.") + #expect(watchService.sentExecApprovalPrompts.isEmpty) + #expect(watchService.lastSentExecApprovalResolved?.approvalId == approvalID) + } + + @Test @MainActor func `operator reconnect preserves dismissed approval in reopenable inbox`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + let prompt = try #require(NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-reconnect-restore", + commandText: "echo restore", + warningText: "Review after reconnect", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000)) + appModel._test_presentExecApprovalPrompt(prompt) + appModel.dismissPendingExecApprovalPrompt() + #expect(appModel._test_pendingExecApprovalPrompt() == nil) + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-reconnect-restore", + "status": "pending", + "urlPath": "/approve/approval-reconnect-restore", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo restore", + "commandPreview": "echo restore", + "warningText": "Review after reconnect", + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) + + await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + + #expect(appModel._test_pendingExecApprovalPrompt() == nil) + #expect(appModel._test_pendingExecApprovalInboxItems().map(\.id) == ["approval-reconnect-restore"]) + await waitForMainActorWork { watchService.lastSentExecApprovalPrompt != nil } + #expect(watchService.lastSentExecApprovalPrompt?.approval.id == "approval-reconnect-restore") + #expect(watchService.lastSentExecApprovalPrompt?.resetResolutionAttemptId == nil) + appModel._test_presentPendingExecApprovalFromInbox( + approvalID: "approval-reconnect-restore", + gatewayStableID: "test-gateway") + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-reconnect-restore") + } + + @Test @MainActor func `watch reconciliation does not reopen dismissed phone presentation`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: watchService) + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-watch-reconcile", + commandText: "echo reconcile", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + await waitForMainActorWork { watchService.lastSentExecApprovalPrompt != nil } + appModel._test_dismissPendingExecApprovalPrompt() + watchService.lastSentExecApprovalPrompt = nil + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-watch-reconcile", + "status": "pending", + "urlPath": "/approve/approval-watch-reconcile", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo reconcile", + "commandPreview": "echo reconcile", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) + + await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + + await waitForMainActorWork { watchService.lastSentExecApprovalPrompt != nil } + #expect(watchService.lastSentExecApprovalPrompt?.approval.id == "approval-watch-reconcile") + #expect(watchService.lastSentExecApprovalPrompt?.resetResolutionAttemptId == nil) + #expect(appModel._test_pendingExecApprovalPrompt() == nil) + #expect(appModel._test_pendingExecApprovalInboxItems().map(\.id) == ["approval-watch-reconcile"]) + } + + @Test @MainActor func `pending reconciliation cannot unlock or duplicate an active phone approval write`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + let approvalID = "approval-phone-write-race" + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo race", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-phone-write-race", + "status": "pending", + "urlPath": "/approve/approval-phone-write-race", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "echo race", + "commandPreview": "echo race", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) + let writeGate = ExecApprovalResolutionGate() + appModel._test_setExecApprovalResolutionFailureHandler { _, _, _ in + await writeGate.waitForFirstCall() + } + + let firstWrite = Task { @MainActor in + await appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !writeGate.hasStarted(), ContinuousClock().now < deadline { + await Task.yield() + } + let initialWriteCount = await writeGate.callCount() + #expect(initialWriteCount == 1) + #expect(appModel._test_pendingExecApprovalState().resolving) + + await appModel._test_reconcileWatchExecApprovalCache(reason: "watch_request") + #expect(appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == nil) + + await appModel.resolvePendingExecApprovalPrompt(decision: "deny") + let conflictingWriteCount = await writeGate.callCount() + #expect(conflictingWriteCount == 1) + #expect(appModel._test_pendingExecApprovalState().resolving) + + await writeGate.resume() + await firstWrite.value + #expect(!appModel._test_pendingExecApprovalState().resolving) + #expect(appModel._test_pendingExecApprovalState().error == "simulated approval write failure") + } + + @Test @MainActor func `phone and watch decisions share one exact owner write lease`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let approvalID = "approval-phone-watch-lease" + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo serialized", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + await waitForMainActorWork { watchService.lastSentExecApprovalPrompt != nil } + watchService.lastSentExecApprovalPrompt = nil + let probe = ExecApprovalConcurrentWriteProbe() + appModel._test_setExecApprovalResolutionFailureHandler { _, decision, _ in + await probe.resolve(decision: decision) + } + + let phoneWrite = Task { @MainActor in + await appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await probe.snapshot().calls.count < 1, ContinuousClock().now < deadline { + await Task.yield() + } + let queuedWatchDecision = await appModel._test_handleWatchExecApprovalResolve( + WatchExecApprovalResolveEvent( + replyId: "watch-lease-attempt", + approvalId: approvalID, + gatewayStableID: "test-gateway", + decision: .deny, + sentAtMs: 123, + transport: "test")) + #expect(!queuedWatchDecision) + let queuedSnapshot = await probe.snapshot() + #expect(queuedSnapshot.calls == ["allow-once"]) + + await probe.releaseFirst() + await phoneWrite.value + let secondDeadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await probe.snapshot().calls.count < 2, ContinuousClock().now < secondDeadline { + await Task.yield() + } + let snapshot = await probe.snapshot() + #expect(snapshot.calls == ["allow-once", "deny"]) + #expect(snapshot.maximumActiveWrites == 1) + await waitForMainActorWork { + watchService.lastSentExecApprovalPrompt?.resetResolutionAttemptId == "watch-lease-attempt" + } + #expect(watchService.lastSentExecApprovalPrompt?.resetResolutionAttemptId == "watch-lease-attempt") + } + + @Test @MainActor func `watch reconciliation retains visible approval and otherwise chooses first exact I d`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let appModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + appModel._test_setConnectedGatewayID("test-gateway") + for approvalID in ["approval-b", "approval-a"] { + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo cached \(approvalID)", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + } + let responseTemplate = #""" + { + "approval": { + "id": "__ID__", + "status": "pending", + "urlPath": "/approve/__ID__", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "canonical __ID__", + "commandPreview": "canonical __ID__", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """# + let responses: [(approvalID: String, json: String)] = [ + ("approval-a", responseTemplate.replacingOccurrences(of: "__ID__", with: "approval-a")), + ("approval-b", responseTemplate.replacingOccurrences(of: "__ID__", with: "approval-b")), + ] + appModel._test_setUnifiedExecApprovalGetResponses(responses) + + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-a") + await appModel._test_reconcileWatchExecApprovalCache(reason: "watch_request") + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-a") + #expect(appModel._test_pendingExecApprovalPrompt()?.commandText == "canonical approval-a") + + let fetchGate = WatchSnapshotSendGate() + appModel._test_setUnifiedExecApprovalGetResponses(responses, beforeResponse: { approvalID in + if approvalID == "approval-a" { + await fetchGate.wait() + } + }) + let reconciling = Task { @MainActor in + await appModel._test_reconcileWatchExecApprovalCache(reason: "watch_request") + } + let deadline = ContinuousClock().now.advanced(by: .seconds(2)) + while await !(fetchGate.hasStarted()), ContinuousClock().now < deadline { + await Task.yield() + } + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: "approval-b", + commandText: "newer visible b", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + await fetchGate.resume() + _ = await reconciling.value + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-b") + #expect(appModel._test_pendingExecApprovalPrompt()?.commandText == "newer visible b") + + appModel._test_dismissPendingExecApprovalPrompt() + appModel._test_setUnifiedExecApprovalGetResponses(responses) + await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-a") } @Test @MainActor func `watch app snapshot request publishes current dashboard state`() async throws { @@ -2627,9 +4735,10 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let watchService = MockWatchMessagingService() let appModel = NodeAppModel(watchMessagingService: watchService) + let gatewayStableID = " gateway-watch-snapshot " appModel._test_setGatewayConnected(true) appModel._test_setOperatorConnected(true) - appModel._test_setConnectedGatewayID("gateway-watch-snapshot") + appModel._test_setConnectedGatewayID(gatewayStableID) appModel.gatewayStatusText = "Connected" appModel.talkMode.setEnabled(true) appModel.talkMode.statusText = "Listening" @@ -2651,7 +4760,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(snapshot.gatewayStatusText == "Connected") #expect(snapshot.agentName == "Main") #expect(snapshot.sessionKey == "main") - #expect(snapshot.gatewayStableID == "gateway-watch-snapshot") + #expect(try Array(#require(snapshot.gatewayStableID).utf8) == Array(gatewayStableID.utf8)) #expect(!snapshot.talkStatusText.isEmpty) #expect(snapshot.talkEnabled == true) #expect(snapshot.pendingApprovalCount == 0) @@ -3531,6 +5640,120 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi ]) } + @Test @MainActor func `approval push owners dedupe and remove by exact bytes`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let composedOwner = "gateway-device-\u{00E9}" + let decomposedOwner = "gateway-device-e\u{0301}" + #expect(composedOwner == decomposedOwner) + let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) + let composedRecovery = ExecApprovalNotificationPrompt( + approvalId: "approval-exact-push-recovery", + gatewayDeviceId: composedOwner) + let decomposedRecovery = ExecApprovalNotificationPrompt( + approvalId: "approval-exact-push-recovery", + gatewayDeviceId: decomposedOwner) + + appModel._test_recordPendingWatchExecApprovalRecoveryID( + composedRecovery.approvalId, + gatewayDeviceId: composedOwner) + appModel._test_recordPendingWatchExecApprovalRecoveryID( + decomposedRecovery.approvalId, + gatewayDeviceId: decomposedOwner) + var recoveryPushes = appModel._test_pendingWatchExecApprovalRecoveryPushes() + #expect(recoveryPushes.count == 2) + #expect(Set(recoveryPushes.compactMap { GatewayStableIdentifier.key($0.gatewayDeviceId) }).count == 2) + + appModel._test_removePendingWatchExecApprovalRecoveryPush(composedRecovery) + recoveryPushes = appModel._test_pendingWatchExecApprovalRecoveryPushes() + #expect(recoveryPushes.count == 1) + #expect(GatewayStableIdentifier.key(recoveryPushes.first?.gatewayDeviceId) == + GatewayStableIdentifier.key(decomposedOwner)) + + let composedResolved = ExecApprovalNotificationPrompt( + approvalId: "approval-exact-push-resolved", + gatewayDeviceId: composedOwner) + let decomposedResolved = ExecApprovalNotificationPrompt( + approvalId: "approval-exact-push-resolved", + gatewayDeviceId: decomposedOwner) + #expect(await appModel.handleExecApprovalResolvedRemotePush(composedResolved)) + #expect(await appModel.handleExecApprovalResolvedRemotePush(decomposedResolved)) + var resolvedPushes = appModel._test_pendingExecApprovalResolvedPushes() + #expect(resolvedPushes.count == 2) + #expect(Set(resolvedPushes.compactMap { GatewayStableIdentifier.key($0.gatewayDeviceId) }).count == 2) + + appModel._test_removePendingExecApprovalResolvedPush(composedResolved) + resolvedPushes = appModel._test_pendingExecApprovalResolvedPushes() + #expect(resolvedPushes.count == 1) + #expect(GatewayStableIdentifier.key(resolvedPushes.first?.gatewayDeviceId) == + GatewayStableIdentifier.key(decomposedOwner)) + } + + @Test @MainActor func `shipped kindless approval cache migrates through owner scoped canonical readback`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + NodeAppModel._test_setPersistedWatchExecApprovalBridgeStateJSON(#""" + { + "approvals": [{ + "id": "approval-shipped-cache", + "gatewayStableID": "gateway-a", + "commandText": "stale cached command", + "commandPreview": null, + "warningText": null, + "allowedDecisions": ["allow-once", "deny"], + "host": "gateway", + "nodeId": null, + "agentId": "main", + "expiresAtMs": 4000000000000 + }] + } + """#) + let appModel = NodeAppModel( + notificationCenter: MockBootstrapNotificationCenter(), + watchMessagingService: MockWatchMessagingService()) + + #expect(appModel._test_watchExecApprovalCacheIDs().isEmpty) + var readbacks = appModel._test_pendingPersistedExecApprovalReadbacks() + #expect(readbacks.count == 1) + #expect(readbacks.first?.approvalId == "approval-shipped-cache") + #expect(readbacks.first?.gatewayStableID == "gateway-a") + + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-shipped-cache", + "status": "pending", + "urlPath": "/approve/approval-shipped-cache", + "createdAtMs": 100, + "expiresAtMs": 4000000000000, + "presentation": { + "kind": "exec", + "commandText": "canonical command", + "commandPreview": "canonical command", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) + appModel._test_setConnectedGatewayID("gateway-b") + await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + #expect(appModel._test_watchExecApprovalCacheIDs().isEmpty) + #expect(appModel._test_pendingExecApprovalPrompt() == nil) + #expect(appModel._test_pendingPersistedExecApprovalReadbacks().count == 1) + + appModel._test_setConnectedGatewayID("gateway-a") + await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + #expect(appModel._test_watchExecApprovalCacheIDs() == ["approval-shipped-cache"]) + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-shipped-cache") + #expect(appModel._test_pendingExecApprovalPrompt()?.commandText == "canonical command") + readbacks = appModel._test_pendingPersistedExecApprovalReadbacks() + #expect(readbacks.isEmpty) + } + @Test @MainActor func `route prompt cannot clear ownerful push recovery`() throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } @@ -3553,20 +5776,50 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"]) } - @Test func `approval notification error classification prefers structured details`() { + @Test func `approval notification stale error classification prefers structured details`() { let staleError = GatewayResponseError( - method: "exec.approval.get", + method: "approval.get", code: "INVALID_REQUEST", message: "gateway error", details: ["reason": AnyCodable("APPROVAL_NOT_FOUND")]) - let unavailableError = GatewayResponseError( - method: "exec.approval.resolve", - code: "INVALID_REQUEST", - message: "gateway error", - details: ["reason": AnyCodable("APPROVAL_ALLOW_ALWAYS_UNAVAILABLE")]) #expect(NodeAppModel._test_isApprovalNotificationStaleError(staleError)) - #expect(NodeAppModel._test_isApprovalNotificationUnavailableError(unavailableError)) + } + + @Test func `approval RPC family requires a complete route catalog family`() { + #expect(NodeAppModel._test_execApprovalRPCFamily( + unifiedGet: true, + unifiedResolve: true, + legacyGet: true, + legacyResolve: true) == "unified") + #expect(NodeAppModel._test_execApprovalRPCFamily( + unifiedGet: false, + unifiedResolve: false, + legacyGet: true, + legacyResolve: true) == "legacy") + + for methods in [ + (true, false, true, true), + (false, true, true, true), + (false, false, true, false), + (false, false, false, true), + ] { + #expect(NodeAppModel._test_execApprovalRPCFamily( + unifiedGet: methods.0, + unifiedResolve: methods.1, + legacyGet: methods.2, + legacyResolve: methods.3) == "unavailable") + } + #expect(NodeAppModel._test_execApprovalRPCFamily( + unifiedGet: nil, + unifiedResolve: nil, + legacyGet: nil, + legacyResolve: nil) == "unavailable") + #expect(NodeAppModel._test_execApprovalRPCFamily( + unifiedGet: nil, + unifiedResolve: nil, + legacyGet: true, + legacyResolve: true) == "unavailable") } @Test func `background aware exec approval reconnect covers watch and push paths`() { @@ -3593,12 +5846,22 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi } @Test func `exec approval event ID decodes gateway payload`() { - #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1") - #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil) + let controlPrefixedID = "\u{001C}approval-1" + #expect(NodeAppModel + ._test_execApprovalEventID(from: AnyCodable(["id": controlPrefixedID])) == controlPrefixedID) + #expect(NodeAppModel + ._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == " approval-1 ") + #expect(NodeAppModel + ._test_execApprovalEventID(from: AnyCodable(["id": "\tapproval-1"])) == "\tapproval-1") + #expect(NodeAppModel + ._test_execApprovalEventID(from: AnyCodable(["id": "\u{FEFF}approval-1"])) == "\u{FEFF}approval-1") + #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": "."])) == nil) + #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": ".."])) == nil) + #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == " ") #expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil) } - @Test @MainActor func `operator gateway resolved event leaves unvalidated push recovery`() async throws { + @Test @MainActor func `operator gateway resolved event waits for canonical readback`() async throws { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let notificationCenter = MockBootstrapNotificationCenter() @@ -3633,14 +5896,18 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi seq: nil, stateversion: nil)) - #expect(appModel._test_pendingExecApprovalPrompt() == nil) + #expect(appModel._test_pendingExecApprovalPrompt()?.id == "approval-event-resolved") #expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-event-resolved"]) + let pendingResolvedPush = ExecApprovalNotificationPrompt( + approvalId: "approval-event-resolved", + gatewayDeviceId: nil) + #expect(appModel._test_pendingExecApprovalResolvedPushes() == [pendingResolvedPush]) #expect(!notificationCenter.deliveredRemovedIdentifiers.contains([ "approval-event-notification", ])) } - @Test @MainActor func `validated resolved push clears only its gateway recovery`() async { + @Test @MainActor func `resolved push without canonical readback preserves gateway recoveries`() async { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter()) @@ -3662,21 +5929,71 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi approvalId: gatewayA.approvalId, recoveryPushGatewayDeviceID: gatewayA.gatewayDeviceId) - #expect(appModel._test_pendingWatchExecApprovalRecoveryPushes() == [gatewayB]) + #expect(appModel._test_pendingWatchExecApprovalRecoveryPushes() == [gatewayA, gatewayB]) } - @Test func `watch exec approval hydrate fetches only missing I ds`() { + @Test func `watch exec approval hydrate preserves exact missing I ds`() { + let controlPrefixedID = "\u{001C}pending" + let composedID = "pending-\u{00E9}" + let decomposedID = "pending-e\u{0301}" let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch( - candidateIDs: ["cached", "pending", "cached", "other", "", " pending "], + candidateIDs: [ + "cached", + controlPrefixedID, + "pending", + composedID, + decomposedID, + "cached", + "other", + "", + " pending ", + ], cachedApprovalIDs: ["cached", "also-cached"]) - #expect(idsToFetch == ["pending", "other"]) + #expect(idsToFetch.count == 6) + #expect(idsToFetch[0] == controlPrefixedID) + #expect(idsToFetch[1] == "pending") + #expect(Array(idsToFetch[2].utf8) == Array(composedID.utf8)) + #expect(Array(idsToFetch[3].utf8) == Array(decomposedID.utf8)) + #expect(idsToFetch[4] == "other") + #expect(idsToFetch[5] == " pending ") } - @Test func `watch exec approval retry prompt resets resolving state only for retry reason`() { - #expect(NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "resolve_retry")) - #expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "push_request")) - #expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "present_prompt")) + @Test @MainActor func `watch approval cache orders canonically equivalent I ds exactly`() async throws { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let composedID = "approval-\u{00E9}" + let decomposedID = "approval-e\u{0301}" + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID("test-gateway") + + for approvalID in [composedID, decomposedID] { + try appModel._test_presentExecApprovalPrompt(#require( + NodeAppModel._test_makeExecApprovalPrompt( + id: approvalID, + commandText: "echo exact", + allowedDecisions: ["allow-once", "deny"], + host: "gateway", + nodeId: nil, + agentId: "main", + expiresAtMs: 4_000_000_000_000))) + } + + let cachedIDs = appModel._test_watchExecApprovalCacheIDs() + #expect(cachedIDs.count == 2) + #expect(Array(cachedIDs[0].utf8) == Array(decomposedID.utf8)) + #expect(Array(cachedIDs[1].utf8) == Array(composedID.utf8)) + + await waitForMainActorWork { + watchService.lastSentExecApprovalSnapshot?.approvals.count == 2 + } + let snapshotIDs = try #require(watchService.lastSentExecApprovalSnapshot).approvals.map(\.id) + #expect(Array(snapshotIDs[0].utf8) == Array(decomposedID.utf8)) + #expect(Array(snapshotIDs[1].utf8) == Array(composedID.utf8)) + + let restoredModel = NodeAppModel(watchMessagingService: MockWatchMessagingService()) + #expect(restoredModel._test_watchExecApprovalCacheIDs().count == 2) } @Test func `operator loop waits for bootstrap handoff before using stored token`() { @@ -3743,6 +6060,37 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(prompt.approvalId == "approval-notifications-off") } + @Test @MainActor func `requested event persists exact readback until canonical classification`() async { + NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() + defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() } + let center = MockBootstrapNotificationCenter() + center.status = .authorized + let appModel = NodeAppModel( + notificationCenter: center, + watchMessagingService: MockWatchMessagingService()) + appModel._test_setConnectedGatewayID("test-gateway") + appModel._test_setExecApprovalPromptFetchFailure("route_changed") + + await appModel._test_handleOperatorGatewayServerEvent(EventFrame( + type: "event", + event: ExecApprovalNotificationBridge.requestedKind, + payload: AnyCodable(["id": "approval-requested-retry"]), + seq: nil, + stateversion: nil)) + + #expect(appModel._test_pendingPersistedExecApprovalReadbacks().map(\.approvalId) == [ + "approval-requested-retry", + ]) + appModel._test_setUnifiedExecApprovalGetResponse( + makePendingExecApprovalJSON("approval-requested-retry")) + await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected") + + #expect(appModel._test_pendingPersistedExecApprovalReadbacks().isEmpty) + #expect(appModel._test_pendingExecApprovalInboxItems().map(\.id) == [ + "approval-requested-retry", + ]) + } + @Test @MainActor func `stale operator event cannot mutate approval UI after suspension`() async { let center = MockBootstrapNotificationCenter() let authorizationGate = NotificationAuthorizationGate() @@ -3793,7 +6141,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil) } - @Test @MainActor func `operator gateway resolved event clears notification guidance prompt`() async throws { + @Test @MainActor func `canonical resolved readback clears notification guidance prompt`() async throws { let center = MockBootstrapNotificationCenter() center.status = .denied let appModel = NodeAppModel(notificationCenter: center) @@ -3807,13 +6155,35 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi seq: nil, stateversion: nil)) _ = try #require(appModel._test_pendingNotificationPermissionGuidancePrompt()) + appModel._test_setConnectedGatewayID("test-gateway") + appModel._test_setUnifiedExecApprovalGetResponse(#""" + { + "approval": { + "id": "approval-guidance-resolved", + "status": "denied", + "urlPath": "/approve/approval-guidance-resolved", + "createdAtMs": 100, + "expiresAtMs": 200, + "resolvedAtMs": 150, + "reason": "user", + "decision": "deny", + "presentation": { + "kind": "exec", + "commandText": "echo guarded", + "commandPreview": "echo guarded", + "warningText": null, + "host": "gateway", + "nodeId": null, + "agentId": "main", + "allowedDecisions": ["allow-once", "deny"] + } + } + } + """#) - await appModel._test_handleOperatorGatewayServerEvent(EventFrame( - type: "event", - event: ExecApprovalNotificationBridge.resolvedKind, - payload: AnyCodable(["id": "approval-guidance-resolved"]), - seq: nil, - stateversion: nil)) + await appModel._test_handleExecApprovalResolvedForCurrentGateway( + approvalId: "approval-guidance-resolved", + recoveryPushGatewayDeviceID: nil) #expect(appModel._test_pendingNotificationPermissionGuidancePrompt() == nil) } @@ -4277,11 +6647,13 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi id: "approval-a", gatewayStableID: "gateway-a", commandText: "echo safe", + warningText: "Review shell expansion", allowedDecisions: [.allowOnce, .deny]) let prompt = WatchMessagingPayloadCodec.encodeExecApprovalPromptPayload( OpenClawWatchExecApprovalPromptMessage(approval: approval)) let encodedApproval = try #require(prompt["approval"] as? [String: Any]) #expect(encodedApproval["gatewayStableID"] as? String == "gateway-a") + #expect(encodedApproval["warningText"] as? String == "Review shell expansion") let reply = try #require(WatchMessagingPayloadCodec.parseExecApprovalResolvePayload([ "type": OpenClawWatchPayloadType.execApprovalResolve.rawValue, @@ -4295,14 +6667,136 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi let resolved = WatchMessagingPayloadCodec.encodeExecApprovalResolvedPayload( OpenClawWatchExecApprovalResolvedMessage( approvalId: "approval-a", - gatewayStableID: "gateway-a")) + gatewayStableID: "gateway-a", + outcomeText: "This approval was already set to Always Allow.")) let expired = WatchMessagingPayloadCodec.encodeExecApprovalExpiredPayload( OpenClawWatchExecApprovalExpiredMessage( approvalId: "approval-a", gatewayStableID: "gateway-a", reason: .notFound)) #expect(resolved["gatewayStableID"] as? String == "gateway-a") + #expect(resolved["outcomeText"] as? String == "This approval was already set to Always Allow.") #expect(expired["gatewayStableID"] as? String == "gateway-a") + + let requestID = "\u{0085}snapshot-request-a" + let heldApprovalID = "\u{0085}held-approval-a\u{0085}" + let activeResolutionAttemptID = "\u{0085}resolution-attempt-a\u{0085}" + let snapshotRequest = try #require( + WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "requestId": requestID, + "gatewayStableID": "gateway-a", + "heldApprovals": [ + [ + "approvalId": heldApprovalID, + "activeResolutionAttemptId": activeResolutionAttemptID, + ], + ["approvalId": "held-approval-b"], + ], + ], transport: "sendMessage")) + #expect(Array(snapshotRequest.requestId.utf8) == Array(requestID.utf8)) + #expect(snapshotRequest.gatewayStableID == "gateway-a") + #expect(snapshotRequest.heldApprovals.count == 2) + #expect(Array(snapshotRequest.heldApprovals[0].approvalId.utf8) == Array(heldApprovalID.utf8)) + #expect(try Array(#require(snapshotRequest.heldApprovals[0].activeResolutionAttemptId).utf8) == + Array(activeResolutionAttemptID.utf8)) + #expect(snapshotRequest.heldApprovals[1].activeResolutionAttemptId == nil) + + let snapshot = WatchMessagingPayloadCodec.encodeExecApprovalSnapshotPayload( + OpenClawWatchExecApprovalSnapshotMessage( + approvals: [approval], + gatewayStableID: "gateway-a", + requestId: requestID, + requestGatewayStableID: "gateway-a")) + #expect(try Array(#require(snapshot["requestId"] as? String).utf8) == Array(requestID.utf8)) + #expect(snapshot["requestGatewayStableID"] as? String == "gateway-a") + + let legacySnapshot = try JSONDecoder().decode( + OpenClawWatchExecApprovalSnapshotMessage.self, + from: Data(#"{"type":"watch.execApproval.snapshot","approvals":[]}"#.utf8)) + #expect(legacySnapshot.requestId == nil) + #expect(legacySnapshot.requestGatewayStableID == nil) + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode( + OpenClawWatchExecApprovalSnapshotRequestMessage.self, + from: Data(#"{"type":"watch.execApproval.snapshotRequest","requestId":"legacy"}"#.utf8)) + } + // Shipped Watch binaries request snapshots with neither requestId nor heldApprovals. + let shippedShapeRequest = try #require( + WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + ], transport: "sendMessage")) + #expect(!shippedShapeRequest.requestId.isEmpty) + #expect(shippedShapeRequest.heldApprovals.isEmpty) + #expect(shippedShapeRequest.gatewayStableID == nil) + let missingHeldApprovalsRequest = try #require( + WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "requestId": "missing-held-approvals", + ], transport: "applicationContext")) + #expect(missingHeldApprovalsRequest.requestId == "missing-held-approvals") + #expect(missingHeldApprovalsRequest.heldApprovals.isEmpty) + let missingRequestIdRequest = try #require( + WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "heldApprovals": [], + ], transport: "applicationContext")) + #expect(!missingRequestIdRequest.requestId.isEmpty) + let emptyRequestIdRequest = try #require( + WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "requestId": "", + "heldApprovals": [], + ], transport: "applicationContext")) + #expect(!emptyRequestIdRequest.requestId.isEmpty) + // A present heldApprovals key keeps strict rejection when malformed. + #expect(WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "requestId": "malformed-held-approvals-shape", + "heldApprovals": "not-an-array", + ], transport: "applicationContext") == nil) + #expect(WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "requestId": "malformed-held-approval", + "heldApprovals": [ + ["approvalId": "valid"], + ["approvalId": ""], + ], + ], transport: "applicationContext") == nil) + #expect(WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ + "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "requestId": "malformed-attempt", + "heldApprovals": [[ + "approvalId": "valid", + "activeResolutionAttemptId": "", + ]], + ], transport: "applicationContext") == nil) + } + + @Test @MainActor func `watch exec approval codec round trips exact opaque identifiers`() throws { + let approvalID = "\u{0085}approval-a\u{0085}" + let gatewayID = "\u{0085}gateway-a\u{0085}" + let replyID = "\u{0085}reply-e\u{0301}\u{0085}" + let prompt = WatchMessagingPayloadCodec.encodeExecApprovalPromptPayload( + OpenClawWatchExecApprovalPromptMessage(approval: OpenClawWatchExecApprovalItem( + id: approvalID, + gatewayStableID: gatewayID, + commandText: "echo exact", + allowedDecisions: [.allowOnce, .deny]))) + let encodedApproval = try #require(prompt["approval"] as? [String: Any]) + let encodedApprovalID = try #require(encodedApproval["id"] as? String) + let encodedGatewayID = try #require(encodedApproval["gatewayStableID"] as? String) + let reply = try #require(WatchMessagingPayloadCodec.parseExecApprovalResolvePayload([ + "type": OpenClawWatchPayloadType.execApprovalResolve.rawValue, + "replyId": replyID, + "approvalId": encodedApprovalID, + "gatewayStableID": encodedGatewayID, + "decision": OpenClawWatchExecApprovalDecision.allowOnce.rawValue, + ], transport: "sendMessage")) + + #expect(Array(reply.replyId.utf8) == Array(replyID.utf8)) + #expect(Array(reply.approvalId.utf8) == Array(approvalID.utf8)) + #expect(try Array(#require(reply.gatewayStableID).utf8) == Array(gatewayID.utf8)) } @Test @MainActor func `watch direct node setup codec carries opaque setup code`() { @@ -4334,7 +6828,9 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi let approvalSnapshotRequest = try #require( WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload([ "type": OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue, + "requestId": "timestamp-request", "sentAtMs": encodedTimestamp, + "heldApprovals": [], ], transport: "sendMessage")) let appSnapshotRequest = try #require(WatchMessagingPayloadCodec.parseAppSnapshotRequestPayload([ "type": OpenClawWatchPayloadType.appSnapshotRequest.rawValue, @@ -4374,6 +6870,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi id: "approval-a", gatewayStableID: "gateway-a", commandText: "echo safe", + warningText: "Review shell expansion", allowedDecisions: [.allowOnce, .deny]), ], gatewayStableID: "gateway-a", @@ -4396,6 +6893,8 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(nestedApprovals["snapshotId"] as? String == "approval-a") #expect(nestedApprovals["gatewayStableID"] as? String == "gateway-a") #expect((nestedApprovals["approvals"] as? [Any])?.count == 1) + let nestedApproval = try #require((nestedApprovals["approvals"] as? [[String: Any]])?.first) + #expect(nestedApproval["warningText"] as? String == "Review shell expansion") } @Test @MainActor func `handle invoke watch notify rejects empty message`() async throws { @@ -4560,6 +7059,49 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi #expect(appModel._test_queuedWatchReplyCount() == 1) } + @Test @MainActor func `watch chat and reply preserve boundary whitespace gateway owner`() async { + NodeAppModel._test_resetPersistedWatchChatQueueState() + defer { NodeAppModel._test_resetPersistedWatchChatQueueState() } + let gatewayID = " gateway-boundary " + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + appModel._test_setConnectedGatewayID(gatewayID) + + watchService.emitAppCommand(WatchAppCommandEvent( + commandId: "watch-boundary-chat", + command: .sendChat, + sessionKey: "main", + gatewayStableID: gatewayID, + text: "Keep exact owner", + sentAtMs: 1, + transport: "sendMessage")) + watchService.emitReply(WatchQuickReplyEvent( + replyId: "watch-boundary-reply", + promptId: "watch-boundary-prompt", + actionId: "approve", + actionLabel: "Approve", + sessionKey: "main", + gatewayStableID: gatewayID, + note: nil, + sentAtMs: 2, + transport: "sendMessage")) + await waitForMainActorWork { appModel._test_queuedWatchReplyCount() == 1 } + + #expect(appModel._test_queuedWatchChatCommandIds() == ["watch-boundary-chat"]) + #expect(appModel._test_queuedWatchReplyCount() == 1) + + watchService.emitAppCommand(WatchAppCommandEvent( + commandId: "watch-trimmed-owner-chat", + command: .sendChat, + sessionKey: "main", + gatewayStableID: "gateway-boundary", + text: "Wrong owner", + sentAtMs: 3, + transport: "sendMessage")) + await Task.yield() + #expect(appModel._test_queuedWatchChatCommandIds() == ["watch-boundary-chat"]) + } + @Test @MainActor func `watch message outbox restores queued reply after restart`() throws { let suiteName = "watch-reply-queue-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) diff --git a/apps/ios/Tests/OpenClawTypographyTests.swift b/apps/ios/Tests/OpenClawTypographyTests.swift index a0d8494d8e49..0f4339ec7d00 100644 --- a/apps/ios/Tests/OpenClawTypographyTests.swift +++ b/apps/ios/Tests/OpenClawTypographyTests.swift @@ -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\"")) diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index 2a037d219758..1ee74c9fd1b5 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -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)) diff --git a/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift b/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift index cc8fe1e570c5..6f26fa3f3a3b 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift @@ -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)")) } } diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index dd700aeb8b5c..9c9d27f257bb 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -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() diff --git a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift index d86c92d113ae..f4899e64eb0b 100644 --- a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift +++ b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift @@ -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) diff --git a/apps/ios/Tests/WatchApprovalTransportSourceGuardTests.swift b/apps/ios/Tests/WatchApprovalTransportSourceGuardTests.swift new file mode 100644 index 000000000000..d0bb0e958cf1 --- /dev/null +++ b/apps/ios/Tests/WatchApprovalTransportSourceGuardTests.swift @@ -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 = [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.. 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: 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[..? - 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", diff --git a/apps/ios/WatchApp/Sources/WatchClawTypography.swift b/apps/ios/WatchApp/Sources/WatchClawTypography.swift index d5975f18e8d9..46149383de88 100644 --- a/apps/ios/WatchApp/Sources/WatchClawTypography.swift +++ b/apps/ios/WatchApp/Sources/WatchClawTypography.swift @@ -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) } diff --git a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift index e9165ff25db9..00b923aead1d 100644 --- a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift @@ -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 + 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 = [] + 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 } diff --git a/apps/ios/WatchApp/Sources/WatchInboxMessages.swift b/apps/ios/WatchApp/Sources/WatchInboxMessages.swift new file mode 100644 index 000000000000..68d46561e7e1 --- /dev/null +++ b/apps/ios/WatchApp/Sources/WatchInboxMessages.swift @@ -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 + } +} diff --git a/apps/ios/WatchApp/Sources/WatchInboxStore.swift b/apps/ios/WatchApp/Sources/WatchInboxStore.swift index d8e757105a3e..a809edbd11bd 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxStore.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxStore.swift @@ -3,186 +3,17 @@ import Observation import UserNotifications import WatchKit -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 WatchExecApprovalItem: Codable, Equatable, Identifiable { - var id: String - var gatewayStableID: String? - var commandText: String - var commandPreview: 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 deliveryId: String? - var resetResolvingState: Bool? -} - -struct WatchExecApprovalResolvedMessage: Codable, Equatable { - var approvalId: String - var gatewayStableID: String? - var decision: WatchExecApprovalDecision? - var resolvedAtMs: Int64? - var source: 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? -} - -struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable { - var requestId: String - var sentAtMs: Int64? -} - -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 updatedAt: Date - var isResolving: Bool - var pendingDecision: WatchExecApprovalDecision? - var statusText: String? - var statusAt: Date? - - var id: String { - self.approval.id - } -} - @MainActor @Observable final class WatchInboxStore { + private typealias ExecApprovalOwnerKey = WatchExecApprovalIdentityKey + + private struct ExecApprovalTerminalTombstone: Codable, Equatable { + var approvalId: String + var gatewayStableID: String + var outcomeText: String + var outcomeIsAuthoritative: Bool? + var recordedAt: Date + } + private enum DeferredGatewayPayload: Codable { case notification(message: WatchNotifyMessage, transport: String) case execApprovalPrompt(message: WatchExecApprovalPromptMessage, transport: String) @@ -268,6 +99,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var replyStatusAt: Date? var execApprovals: [WatchExecApprovalRecord] var selectedExecApprovalID: String? + var selectedExecApprovalGatewayStableID: String? var lastExecApprovalSnapshotID: String? var lastExecApprovalSnapshotGatewayStableID: String? var lastExecApprovalSnapshotSentAtMs: Int64? @@ -278,10 +110,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var appSnapshotStatusText: String? var appCommandStatusText: String? var deferredGatewayPayloads: [DeferredGatewayPayload]? + var execApprovalTerminalTombstones: [ExecApprovalTerminalTombstone]? } private static let persistedStateKey = "watch.inbox.state.v2" private static let maxDeferredGatewayPayloads = 32 + private static let maxExecApprovalTerminalTombstones = 128 + private static let maxExecApprovalTerminalOutcomeCharacters = 160 + private static let execApprovalTerminalTombstoneLifetime: TimeInterval = 24 * 60 * 60 private static let defaultTitle = "OpenClaw" private static let defaultBody = "Waiting for messages from your iPhone." private let defaults: UserDefaults @@ -303,6 +139,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { var isReplySending = false var execApprovals: [WatchExecApprovalRecord] = [] var selectedExecApprovalID: String? + var selectedExecApprovalGatewayStableID: String? var lastExecApprovalOutcomeText: String? var lastExecApprovalOutcomeAt: Date? var appSnapshot: WatchAppSnapshotMessage? @@ -323,6 +160,10 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { /// transfers. Persist a bounded handoff queue so a new route's alert is not lost /// before its owner snapshot arrives. private var deferredGatewayPayloads: [DeferredGatewayPayload] = [] + /// Terminal events can race older prompts and snapshots across WatchConnectivity + /// transports. Keep a short owner-scoped history so stale deliveries cannot restore + /// live decision buttons after the canonical approval has closed. + private var execApprovalTerminalTombstones: [ExecApprovalTerminalTombstone] = [] init( defaults: UserDefaults = .standard, @@ -330,6 +171,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { { self.defaults = defaults self.restorePersistedState() + self.pruneExecApprovalTerminalTombstones(now: Date()) self.pruneExpiredExecApprovals(nowMs: Self.nowMs()) if requestNotificationAuthorization { Task { @@ -350,8 +192,10 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } var activeExecApproval: WatchExecApprovalRecord? { - if let selectedExecApprovalID, - let selected = execApprovals.first(where: { $0.id == selectedExecApprovalID }) + if let selectedKey = Self.execApprovalOwnerKey( + approvalId: self.selectedExecApprovalID ?? "", + gatewayStableID: self.selectedExecApprovalGatewayStableID), + let selected = execApprovals.first(where: { $0.id == selectedKey }) { return selected } @@ -359,17 +203,42 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } var shouldAutoRequestExecApprovalSnapshot: Bool { - self.execApprovals.isEmpty - && self.actions.isEmpty - && self.title == Self.defaultTitle - && self.body == Self.defaultBody - && !self.hasCompletedExecApprovalSnapshotRefreshInSession + self.execApprovals.contains(where: \.isResolving) + || (self.execApprovals.isEmpty + && self.actions.isEmpty + && self.title == Self.defaultTitle + && self.body == Self.defaultBody + && !self.hasCompletedExecApprovalSnapshotRefreshInSession) } var hasCompletedExecApprovalSnapshotRefresh: Bool { self.hasCompletedExecApprovalSnapshotRefreshInSession } + var execApprovalReviewGatewayStableID: String? { + WatchGatewayID.exact(self.activeExecApproval?.approval.gatewayStableID) + ?? WatchGatewayID.exact(self.appSnapshot?.gatewayStableID) + } + + func execApprovalSnapshotRequestItems( + gatewayStableID: String?) -> [WatchExecApprovalSnapshotRequestItem] + { + guard let gatewayKey = WatchGatewayID.key(gatewayStableID) else { return [] } + return self.execApprovals.compactMap { record in + guard WatchGatewayID.key(record.approval.gatewayStableID) == gatewayKey, + let approvalID = WatchApprovalID.exact(record.approvalID) + else { return nil } + let activeAttemptID = record.activeResolutionAttemptID.flatMap { attemptID in + attemptID.isEmpty ? nil : attemptID + } + return WatchExecApprovalSnapshotRequestItem( + approvalId: approvalID, + activeResolutionAttemptId: activeAttemptID) + }.sorted { lhs, rhs in + Array(lhs.approvalId.utf8).lexicographicallyPrecedes(Array(rhs.approvalId.utf8)) + } + } + var shouldShowExecApprovalReviewStatus: Bool { self.execApprovals.isEmpty && !(self.execApprovalReviewStatusText?.isEmpty ?? true) } @@ -475,20 +344,56 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { transport: String) { guard self.routeGatewayPayload(.execApprovalPrompt(message: message, transport: transport)) else { return } - self.pruneExpiredExecApprovals(nowMs: Self.nowMs()) - self.upsertExecApproval( + guard WatchApprovalID.exact(message.approval.id) != nil else { return } + self.pruneExecApprovalTerminalTombstones(now: Date()) + guard !self.isExecApprovalTerminal( + approvalId: message.approval.id, + gatewayStableID: message.approval.gatewayStableID) + else { + self.removeExecApprovalNotifications(approvals: [message.approval]) + self.markExecApprovalReviewLoaded() + self.persistState() + return + } + let nowMs = Self.nowMs() + self.pruneExpiredExecApprovals(nowMs: nowMs) + if let expiresAtMs = message.approval.expiresAtMs, expiresAtMs <= nowMs { + self.removeExecApprovalNotifications(approvals: [message.approval]) + self.markExecApprovalReviewLoaded() + self.persistState() + return + } + if self.isExecApprovalPromptSupersededBySnapshot(message) { + self.removeExecApprovalNotifications(approvals: [message.approval]) + self.markExecApprovalReviewLoaded() + self.persistState() + return + } + guard self.upsertExecApproval( message.approval, transport: transport, + sourceSentAtMs: message.sentAtMs, keepSelectionIfPossible: true, - resetResolvingState: message.resetResolvingState == true) - let approvalID = message.approval.id - let approvalGatewayID = message.approval.gatewayStableID + resetResolutionAttemptID: message.resetResolutionAttemptId) + else { return } + guard let approvalOwnerKey = Self.execApprovalOwnerKey( + approvalId: message.approval.id, + gatewayStableID: message.approval.gatewayStableID) + else { return } guard let notificationIdentifier = Self.execApprovalNotificationIdentifier(for: message.approval) else { return } self.markExecApprovalReviewLoaded() self.lastExecApprovalOutcomeText = nil self.lastExecApprovalOutcomeAt = nil + if let legacyNotificationIdentifier = Self.legacyExecApprovalNotificationIdentifier( + for: message.approval), + !self.hasLiveLegacyNotificationCollision( + identifier: legacyNotificationIdentifier, + excluding: message.approval) + { + self.removeLocalNotifications(identifiers: [legacyNotificationIdentifier]) + } Task { await self.postLocalNotification( @@ -498,53 +403,129 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { risk: message.approval.risk?.rawValue, stillCurrent: { self.execApprovals.contains { record in - record.id == approvalID && record.approval.gatewayStableID == approvalGatewayID + Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) == approvalOwnerKey } }) } } + /// Returns true only after this owner snapshot is applied; forced refresh uses it as its retry acknowledgment. + @discardableResult func consume( execApprovalSnapshot message: WatchExecApprovalSnapshotMessage, - transport: String) + transport: String) -> Bool { let deferredPayload = DeferredGatewayPayload.execApprovalSnapshot( message: message, transport: transport) if deferredPayload.gatewayStableID != nil { - guard self.routeGatewayPayload(deferredPayload) else { return } + guard self.routeGatewayPayload(deferredPayload) else { return false } + } + guard let snapshotGatewayID = Self.normalizedGatewayID(deferredPayload.gatewayStableID) else { + return false } - let snapshotGatewayID = Self.normalizedGatewayID(deferredPayload.gatewayStableID) let previousSnapshotGatewayID = Self.normalizedGatewayID( self.lastExecApprovalSnapshotGatewayStableID) - let hasSameSnapshotOwner = snapshotGatewayID == previousSnapshotGatewayID + let hasSameSnapshotOwner = Self.gatewayIDsMatch(snapshotGatewayID, previousSnapshotGatewayID) + let hasCanonicalRequestCorrelation = message.requestId?.isEmpty == false + && Self.gatewayIDsMatch(message.requestGatewayStableID, snapshotGatewayID) + if hasCanonicalRequestCorrelation { + // A correlated snapshot may authoritatively close omitted rows. Reject the + // whole response when any item is ownerless or belongs to another gateway; + // filtering those items first would turn malformed input into false omissions. + let allApprovalOwnersMatch = message.approvals.allSatisfy { approval in + WatchApprovalID.exact(approval.id) != nil + && Self.gatewayIDsMatch(approval.gatewayStableID, snapshotGatewayID) + } + guard allApprovalOwnersMatch else { return false } + } let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines) if hasSameSnapshotOwner, let snapshotID, !snapshotID.isEmpty, snapshotID == lastExecApprovalSnapshotID { - return + return false } if hasSameSnapshotOwner, let sentAtMs = message.sentAtMs, let lastSentAtMs = lastExecApprovalSnapshotSentAtMs, sentAtMs < lastSentAtMs { - return + return false } let existingRecords = self.execApprovals - let existingRecordsByID = Dictionary( - uniqueKeysWithValues: existingRecords.map { ($0.id, $0) }) - self.execApprovals = message.approvals.filter { approval in - self.acceptsGatewayOwner(approval.gatewayStableID) - }.map { approval in - self.mergedExecApprovalRecord( + var existingRecordsByOwner: [ExecApprovalOwnerKey: WatchExecApprovalRecord] = [:] + for record in existingRecords { + guard let key = Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) + else { + continue + } + existingRecordsByOwner[key] = record + } + self.pruneExecApprovalTerminalTombstones(now: Date()) + let incomingApprovals = message.approvals.filter { approval in + WatchApprovalID.exact(approval.id) != nil + && Self.gatewayIDsMatch(approval.gatewayStableID, snapshotGatewayID) + && self.acceptsGatewayOwner(approval.gatewayStableID) + && !self.isExecApprovalTerminal( + approvalId: approval.id, + gatewayStableID: approval.gatewayStableID) + } + let incomingApprovalKeys = Set(incomingApprovals.compactMap { approval in + Self.execApprovalOwnerKey( + approvalId: approval.id, + gatewayStableID: approval.gatewayStableID) + }) + let retainedNewerRecords = existingRecords.filter { record in + guard let recordKey = Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID), + recordKey.gatewayID == WatchGatewayID.key(snapshotGatewayID) + else { + return true + } + guard !incomingApprovalKeys.contains(recordKey) else { return false } + // Unsolicited snapshots can come from an iPhone cache that has not yet + // read Watch-held IDs. Only the response to a canonical request may close + // approvals omitted from the snapshot. + guard hasCanonicalRequestCorrelation else { return true } + guard Self.snapshotCanReplace( + record: record, + snapshotSentAtMs: message.sentAtMs) + else { + return true + } + _ = self.recordExecApprovalTerminal( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID, + outcomeText: "Approval resolved elsewhere", + authoritativeOutcome: false) + return false + } + let mergedIncomingRecords = incomingApprovals.map { approval in + let approvalKey = Self.execApprovalOwnerKey( + approvalId: approval.id, + gatewayStableID: approval.gatewayStableID) + let existingRecord = approvalKey.flatMap { existingRecordsByOwner[$0] } + guard Self.snapshotCanReplace( + record: existingRecord, + snapshotSentAtMs: message.sentAtMs) + else { + return existingRecord! + } + return self.mergedExecApprovalRecord( approval: approval, transport: transport, - existingRecord: existingRecordsByID[approval.id]) + sourceSentAtMs: message.sentAtMs, + existingRecord: existingRecord) } + self.execApprovals = retainedNewerRecords + mergedIncomingRecords if hasSameSnapshotOwner { if let snapshotID, !snapshotID.isEmpty { self.lastExecApprovalSnapshotID = snapshotID @@ -558,13 +539,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } self.lastExecApprovalSnapshotGatewayStableID = snapshotGatewayID self.hasCompletedExecApprovalSnapshotRefreshInSession = true - if let selectedExecApprovalID, - !self.execApprovals.contains(where: { $0.id == selectedExecApprovalID }) - { - self.selectedExecApprovalID = self.sortedExecApprovals.first?.id - } else if selectedExecApprovalID == nil { - selectedExecApprovalID = self.sortedExecApprovals.first?.id - } + self.ensureValidExecApprovalSelection() self.pruneExpiredExecApprovals(nowMs: Self.nowMs()) let currentNotificationIdentifiers = Set(execApprovals.compactMap { record in Self.execApprovalNotificationIdentifier(for: record.approval) @@ -576,6 +551,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { self.removeExecApprovalNotifications(approvals: removedApprovals) self.markExecApprovalReviewLoaded() self.persistState() + return true } func consume(appSnapshot message: WatchAppSnapshotMessage) { @@ -593,7 +569,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { let previousGatewayID = Self.normalizedGatewayID(self.appSnapshot?.gatewayStableID) let nextGatewayID = Self.normalizedGatewayID(message.gatewayStableID) var merged = message - if hasExistingAppSnapshot, previousGatewayID == nextGatewayID { + if hasExistingAppSnapshot, Self.gatewayIDsMatch(previousGatewayID, nextGatewayID) { if merged.chatItems == nil { merged.chatItems = self.appSnapshot?.chatItems } @@ -604,26 +580,23 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { self.appSnapshot = merged self.appSnapshotUpdatedAt = Date() self.appSnapshotStatusText = nil - if !hasExistingAppSnapshot || previousGatewayID != nextGatewayID { - if Self.normalizedGatewayID(self.gatewayStableID) != nextGatewayID { + if !hasExistingAppSnapshot || !Self.gatewayIDsMatch(previousGatewayID, nextGatewayID) { + self.hasCompletedExecApprovalSnapshotRefreshInSession = false + if !Self.gatewayIDsMatch(self.gatewayStableID, nextGatewayID) { self.clearMessagePrompt() } let invalidatedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in guard let nextGatewayID else { return record.approval } - return Self.normalizedGatewayID(record.approval.gatewayStableID) == nextGatewayID + return Self.gatewayIDsMatch(record.approval.gatewayStableID, nextGatewayID) ? nil : record.approval } self.execApprovals.removeAll { record in guard let nextGatewayID else { return true } - return Self.normalizedGatewayID(record.approval.gatewayStableID) != nextGatewayID + return !Self.gatewayIDsMatch(record.approval.gatewayStableID, nextGatewayID) } self.removeExecApprovalNotifications(approvals: invalidatedApprovals) - if let selectedExecApprovalID, - !self.execApprovals.contains(where: { $0.id == selectedExecApprovalID }) - { - self.selectedExecApprovalID = self.sortedExecApprovals.first?.id - } + self.ensureValidExecApprovalSelection() } self.persistState() } @@ -662,8 +635,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } var hasGatewayTaggedAppSnapshot: Bool { - let gatewayStableID = self.appSnapshot?.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !gatewayStableID.isEmpty + WatchGatewayID.exact(self.appSnapshot?.gatewayStableID) != nil } func markAppCommandSending(_ command: WatchAppCommand) { @@ -689,26 +661,40 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } self.persistState() } +} +// MARK: - Exec approvals + +extension WatchInboxStore { func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) { guard self.routeGatewayPayload(.execApprovalResolved(message: message)) else { return } - self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID) - let statusText = switch message.decision { - case .allowOnce: - "Allowed once" - case .deny: - "Denied" - case nil: - "Approval resolved" + let normalizedOutcomeText = message.outcomeText? + .trimmingCharacters(in: .whitespacesAndNewlines) + let statusText = if let normalizedOutcomeText, !normalizedOutcomeText.isEmpty { + normalizedOutcomeText + } else { + switch message.decision { + case .allowOnce: + "Allowed once" + case .deny: + "Denied" + case nil: + "Approval resolved" + } } - self.lastExecApprovalOutcomeText = statusText + let terminalOutcomeText = self.recordExecApprovalTerminal( + approvalId: message.approvalId, + gatewayStableID: message.gatewayStableID, + outcomeText: statusText) ?? statusText + self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID) + self.markExecApprovalReviewLoaded() + self.lastExecApprovalOutcomeText = terminalOutcomeText self.lastExecApprovalOutcomeAt = Date() self.persistState() } func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) { guard self.routeGatewayPayload(.execApprovalExpired(message: message)) else { return } - self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID) let statusText = switch message.reason { case .expired: "Approval expired" @@ -721,48 +707,113 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { case .unavailable: "Approval unavailable" } - self.lastExecApprovalOutcomeText = statusText + let terminalOutcomeText = self.recordExecApprovalTerminal( + approvalId: message.approvalId, + gatewayStableID: message.gatewayStableID, + outcomeText: statusText) ?? statusText + self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID) + self.markExecApprovalReviewLoaded() + self.lastExecApprovalOutcomeText = terminalOutcomeText self.lastExecApprovalOutcomeAt = Date() self.persistState() } - func selectExecApproval(id: String) { - let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedID.isEmpty else { return } - guard self.execApprovals.contains(where: { $0.id == normalizedID }) else { return } - self.selectedExecApprovalID = normalizedID + /// Returns owner-scoped terminal truth for a detail screen whose live record was removed. + func terminalExecApprovalOutcomeText( + approvalId: String, + gatewayStableID: String?) -> String? + { + guard let key = Self.execApprovalOwnerKey( + approvalId: approvalId, + gatewayStableID: gatewayStableID) + else { + return nil + } + let cutoff = Date().addingTimeInterval(-Self.execApprovalTerminalTombstoneLifetime) + return self.execApprovalTerminalTombstones.last { tombstone in + tombstone.recordedAt >= cutoff + && WatchApprovalID.key(tombstone.approvalId) == key.approvalID + && WatchGatewayID.key(tombstone.gatewayStableID) == key.gatewayID + }?.outcomeText + } + + func selectExecApproval(id: String, gatewayStableID: String?) { + guard let exactKey = Self.execApprovalOwnerKey( + approvalId: id, + gatewayStableID: gatewayStableID), + let record = self.execApprovals.first(where: { $0.id == exactKey }) + else { return } + self.selectedExecApprovalID = record.approvalID + self.selectedExecApprovalGatewayStableID = record.approval.gatewayStableID self.persistState() } - func markExecApprovalSending(approvalId: String, decision: WatchExecApprovalDecision) { - guard let index = execApprovals.firstIndex(where: { $0.id == approvalId }) else { return } + func beginExecApprovalDecision( + approvalId: String, + gatewayStableID: String?, + decision: WatchExecApprovalDecision) -> String? + { + self.pruneExpiredExecApprovals(nowMs: Self.nowMs()) + guard let ownerKey = Self.execApprovalOwnerKey( + approvalId: approvalId, + gatewayStableID: gatewayStableID), + !self.isExecApprovalTerminal( + approvalId: approvalId, + gatewayStableID: gatewayStableID), + let index = execApprovals.firstIndex(where: { record in + Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) == ownerKey + }), + !self.execApprovals[index].isResolving, + execApprovals[index].approval.allowedDecisions.contains(decision) + else { return nil } + + let attemptID = UUID().uuidString self.execApprovals[index].isResolving = true self.execApprovals[index].pendingDecision = decision + self.execApprovals[index].activeResolutionAttemptID = attemptID self.execApprovals[index].statusText = "Sending \(Self.decisionLabel(decision))…" self.execApprovals[index].statusAt = Date() self.persistState() + return attemptID } - func markExecApprovalSendResult( + func completeExecApprovalDecision( approvalId: String, + gatewayStableID: String?, + attemptID: String, decision: WatchExecApprovalDecision, result: WatchReplySendResult) { - guard let index = execApprovals.firstIndex(where: { $0.id == approvalId }) else { return } - if let errorMessage = result.errorMessage, !errorMessage.isEmpty { - self.execApprovals[index].isResolving = false - self.execApprovals[index].statusText = "Failed: \(errorMessage)" - } else if result.deliveredImmediately { + guard let ownerKey = Self.execApprovalOwnerKey( + approvalId: approvalId, + gatewayStableID: gatewayStableID), + let index = execApprovals.firstIndex(where: { record in + Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) == ownerKey + }), + let activeResolutionAttemptID = execApprovals[index].activeResolutionAttemptID, + WatchOpaqueUTF8Key(activeResolutionAttemptID) == WatchOpaqueUTF8Key(attemptID), + execApprovals[index].pendingDecision == decision + else { return } + + switch result.delivery { + case .delivered: self.execApprovals[index].isResolving = true self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent" - } else if result.queuedForDelivery { + case .queued: self.execApprovals[index].isResolving = true self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): queued" - } else { - self.execApprovals[index].isResolving = true - self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent" + case .notSent: + // Only a definitive pre-dispatch failure unlocks locally. Uncertain sends stay + // frozen until a canonical retry reset or terminal event arrives. + self.execApprovals[index].isResolving = false + self.execApprovals[index].activeResolutionAttemptID = nil + self.execApprovals[index].statusText = "Couldn't reach iPhone. Tap to retry." } - self.execApprovals[index].pendingDecision = result.errorMessage == nil ? decision : nil + self.execApprovals[index].pendingDecision = result.delivery == .notSent ? nil : decision self.execApprovals[index].statusAt = Date() self.persistState() } @@ -770,13 +821,35 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { private func upsertExecApproval( _ approval: WatchExecApprovalItem, transport: String, + sourceSentAtMs: Int64?, keepSelectionIfPossible: Bool, - resetResolvingState: Bool = false) + resetResolutionAttemptID: String? = nil) -> Bool { - if let index = execApprovals.firstIndex(where: { $0.id == approval.id }) { + guard let ownerKey = Self.execApprovalOwnerKey( + approvalId: approval.id, + gatewayStableID: approval.gatewayStableID) + else { return false } + if let index = execApprovals.firstIndex(where: { record in + Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) == ownerKey + }) { + guard Self.snapshotCanReplace( + record: self.execApprovals[index], + snapshotSentAtMs: sourceSentAtMs) + else { return false } + let resetResolvingState = if let resetResolutionAttemptID, + let activeResolutionAttemptID = + self.execApprovals[index].activeResolutionAttemptID + { + WatchOpaqueUTF8Key(resetResolutionAttemptID) == WatchOpaqueUTF8Key(activeResolutionAttemptID) + } else { + false + } self.execApprovals[index] = self.mergedExecApprovalRecord( approval: approval, transport: transport, + sourceSentAtMs: sourceSentAtMs, existingRecord: self.execApprovals[index], resetResolvingState: resetResolvingState) } else { @@ -784,66 +857,121 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { self.mergedExecApprovalRecord( approval: approval, transport: transport, - existingRecord: nil, - resetResolvingState: resetResolvingState)) + sourceSentAtMs: sourceSentAtMs, + existingRecord: nil)) } - if !keepSelectionIfPossible || self.selectedExecApprovalID == nil { + if !keepSelectionIfPossible || Self.execApprovalOwnerKey( + approvalId: self.selectedExecApprovalID ?? "", + gatewayStableID: self.selectedExecApprovalGatewayStableID) == nil + { self.selectedExecApprovalID = approval.id + self.selectedExecApprovalGatewayStableID = approval.gatewayStableID } self.persistState() + return true } private func mergedExecApprovalRecord( approval: WatchExecApprovalItem, transport: String, + sourceSentAtMs: Int64?, existingRecord: WatchExecApprovalRecord?, resetResolvingState: Bool = false) -> WatchExecApprovalRecord { // Preserve in-flight state across ordinary snapshot/prompt refreshes so duplicate - // submissions stay disabled, but clear it when the iPhone explicitly republishes a - // prompt after a failed resolve so the watch can retry. + // submissions stay disabled. Only the iPhone readback for the same attempt may clear it. let isResolving = resetResolvingState ? false : (existingRecord?.isResolving ?? false) let pendingDecision = resetResolvingState ? nil : existingRecord?.pendingDecision + let activeResolutionAttemptID = resetResolvingState ? nil : existingRecord?.activeResolutionAttemptID let statusText = resetResolvingState ? nil : existingRecord?.statusText let statusAt = resetResolvingState ? nil : existingRecord?.statusAt return WatchExecApprovalRecord( approval: approval, transport: transport, + sourceSentAtMs: sourceSentAtMs ?? existingRecord?.sourceSentAtMs, updatedAt: Date(), isResolving: isResolving, pendingDecision: pendingDecision, + activeResolutionAttemptID: activeResolutionAttemptID, statusText: statusText, statusAt: statusAt) } + private static func snapshotCanReplace( + record: WatchExecApprovalRecord?, + snapshotSentAtMs: Int64?) -> Bool + { + guard let record else { return true } + guard let snapshotSentAtMs else { + // Missing cross-transport ordering evidence cannot safely remove or replace a + // live prompt. Its expiry or a terminal event will eventually close it. + return false + } + // Records persisted before source timestamps were added yield to a timestamped + // canonical snapshot instead of remaining actionable indefinitely. + guard let recordSentAtMs = record.sourceSentAtMs else { return true } + return snapshotSentAtMs >= recordSentAtMs + } + + private func isExecApprovalPromptSupersededBySnapshot( + _ message: WatchExecApprovalPromptMessage) -> Bool + { + let promptGatewayID = Self.normalizedGatewayID(message.approval.gatewayStableID) + let snapshotGatewayID = Self.normalizedGatewayID( + self.lastExecApprovalSnapshotGatewayStableID) + guard Self.gatewayIDsMatch(promptGatewayID, snapshotGatewayID), + let snapshotSentAtMs = lastExecApprovalSnapshotSentAtMs + else { + return false + } + let hasLiveRecord = self.execApprovals.contains { record in + Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) == Self.execApprovalOwnerKey( + approvalId: message.approval.id, + gatewayStableID: message.approval.gatewayStableID) + } + guard !hasLiveRecord else { return false } + guard let promptSentAtMs = message.sentAtMs else { + // Once an owner snapshot has closed an ID, an undated prompt cannot prove it is newer. + return true + } + return promptSentAtMs <= snapshotSentAtMs + } + private func removeExecApproval(id: String, gatewayStableID: String?) { - let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedID.isEmpty else { return } - let normalizedGatewayID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let exactKey = Self.execApprovalOwnerKey( + approvalId: id, + gatewayStableID: gatewayStableID) + else { return } let removedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in - guard record.id == normalizedID else { return nil } - // Legacy ownerless lifecycle messages may only close legacy ownerless prompts. - return record.approval.gatewayStableID == normalizedGatewayID ? record.approval : nil + record.id == exactKey ? record.approval : nil } self.execApprovals.removeAll { record in - guard record.id == normalizedID else { return false } - // Legacy ownerless lifecycle messages may only close legacy ownerless prompts. - return record.approval.gatewayStableID == normalizedGatewayID + record.id == exactKey } self.removeExecApprovalNotifications(approvals: removedApprovals) - if self.selectedExecApprovalID == normalizedID { - self.selectedExecApprovalID = self.sortedExecApprovals.first?.id + if Self.execApprovalOwnerKey( + approvalId: self.selectedExecApprovalID ?? "", + gatewayStableID: self.selectedExecApprovalGatewayStableID) == exactKey + { + self.selectedExecApprovalID = self.sortedExecApprovals.first?.approvalID + self.selectedExecApprovalGatewayStableID = self.sortedExecApprovals.first?.approval.gatewayStableID } self.persistState() } +} +// MARK: - Gateway routing and persistence + +extension WatchInboxStore { private func routeGatewayPayload(_ payload: DeferredGatewayPayload) -> Bool { guard let incomingGatewayID = Self.normalizedGatewayID(payload.gatewayStableID) else { return false } guard let activeSnapshot = appSnapshot else { return true } let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID) - guard incomingGatewayID != activeGatewayID else { return true } + guard !Self.gatewayIDsMatch(incomingGatewayID, activeGatewayID) else { return true } if let payloadSentAtMs = payload.sentAtMs, let snapshotSentAtMs = activeSnapshot.sentAtMs, payloadSentAtMs <= snapshotSentAtMs @@ -870,10 +998,11 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { guard let incomingGatewayID = Self.normalizedGatewayID(gatewayStableID) else { return false } guard let activeSnapshot = appSnapshot else { return true } guard let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID) else { return false } - return incomingGatewayID == activeGatewayID + return Self.gatewayIDsMatch(incomingGatewayID, activeGatewayID) } - func replayDeferredGatewayPayloads() { + @discardableResult + func replayDeferredGatewayPayloads() -> [WatchExecApprovalSnapshotMessage] { guard let activeGatewayID = Self.normalizedGatewayID(appSnapshot?.gatewayStableID) else { let snapshotSentAtMs = self.appSnapshot?.sentAtMs let nowMs = Self.nowMs() @@ -886,7 +1015,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { snapshotSentAtMs: snapshotSentAtMs) } self.persistState() - return + return [] } let snapshotSentAtMs = self.appSnapshot?.sentAtMs @@ -902,8 +1031,10 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { { continue } - if Self.normalizedGatewayID(payload.gatewayStableID) == activeGatewayID { - let isPreexistingApprovalPayload = approvalSnapshotGatewayID == activeGatewayID + if Self.gatewayIDsMatch(payload.gatewayStableID, activeGatewayID) { + let isPreexistingApprovalPayload = Self.gatewayIDsMatch( + approvalSnapshotGatewayID, + activeGatewayID) && WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot( payloadSentAtMs: payload.sentAtMs, snapshotSentAtMs: self.lastExecApprovalSnapshotSentAtMs) @@ -914,9 +1045,13 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { } if isPreexistingApprovalPayload, let approval = payload.approvalPrompt, + let approvalOwnerKey = Self.execApprovalOwnerKey( + approvalId: approval.id, + gatewayStableID: approval.gatewayStableID), !self.execApprovals.contains(where: { record in - record.id == approval.id - && Self.normalizedGatewayID(record.approval.gatewayStableID) == activeGatewayID + Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) == approvalOwnerKey }) { continue @@ -934,6 +1069,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { let replayOrder = WatchDeferredPayloadOrdering.indicesOldestFirst( for: ready.map(\.sentAtMs)) + var appliedExecApprovalSnapshots: [WatchExecApprovalSnapshotMessage] = [] for index in replayOrder { let payload = ready[index] switch payload { @@ -946,9 +1082,12 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable { case let .execApprovalExpired(message): self.consume(execApprovalExpired: message) case let .execApprovalSnapshot(message, transport): - self.consume(execApprovalSnapshot: message, transport: transport) + if self.consume(execApprovalSnapshot: message, transport: transport) { + appliedExecApprovalSnapshots.append(message) + } } } + return appliedExecApprovalSnapshots } } @@ -979,11 +1118,38 @@ extension WatchInboxStore { } private func removeExecApprovalNotifications(approvals: [WatchExecApprovalItem]) { - self.removeLocalNotifications(identifiers: approvals.compactMap { approval in - Self.execApprovalNotificationIdentifier(for: approval) + self.removeLocalNotifications(identifiers: approvals.flatMap { approval in + var identifiers = Self.execApprovalNotificationIdentifier(for: approval).map { [$0] } ?? [] + if let legacyIdentifier = Self.legacyExecApprovalNotificationIdentifier(for: approval), + !self.hasLiveLegacyNotificationCollision( + identifier: legacyIdentifier, + excluding: approval) + { + identifiers.append(legacyIdentifier) + } + return identifiers }) } + private func hasLiveLegacyNotificationCollision( + identifier: String, + excluding approval: WatchExecApprovalItem) -> Bool + { + let identifierKey = WatchOpaqueUTF8Key(identifier) + let excludedKey = Self.execApprovalOwnerKey( + approvalId: approval.id, + gatewayStableID: approval.gatewayStableID) + return self.execApprovals.contains { record in + let recordKey = Self.execApprovalOwnerKey( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) + guard recordKey != excludedKey, + let candidate = Self.legacyExecApprovalNotificationIdentifier(for: record.approval) + else { return false } + return WatchOpaqueUTF8Key(candidate) == identifierKey + } + } + private func removeLocalNotifications(identifiers: [String]) { guard !identifiers.isEmpty else { return } let center = UNUserNotificationCenter.current() @@ -992,22 +1158,119 @@ extension WatchInboxStore { } private nonisolated static func normalizedGatewayID(_ gatewayStableID: String?) -> String? { - let normalized = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return normalized.isEmpty ? nil : normalized + WatchGatewayID.exact(gatewayStableID) + } + + private nonisolated static func gatewayIDsMatch(_ lhs: String?, _ rhs: String?) -> Bool { + WatchGatewayID.key(lhs) == WatchGatewayID.key(rhs) } private nonisolated static func onlyGatewayStableID(in approvals: [WatchExecApprovalItem]) -> String? { - let gatewayIDs = Set(approvals.compactMap { self.normalizedGatewayID($0.gatewayStableID) }) - return gatewayIDs.count == 1 ? gatewayIDs.first : nil + var gatewaysByKey: [WatchGatewayID.Key: String] = [:] + for approval in approvals { + guard let gatewayID = self.normalizedGatewayID(approval.gatewayStableID), + let gatewayKey = WatchGatewayID.key(gatewayID) + else { continue } + gatewaysByKey[gatewayKey] = gatewayID + } + return gatewaysByKey.count == 1 ? gatewaysByKey.values.first : nil } private static func execApprovalNotificationIdentifier(for approval: WatchExecApprovalItem) -> String? { - guard let gatewayStableID = normalizedGatewayID(approval.gatewayStableID) else { return nil } - let approvalID = approval.id.trimmingCharacters(in: .whitespacesAndNewlines) - guard !approvalID.isEmpty else { return nil } + guard let gatewayKey = WatchGatewayID.key(approval.gatewayStableID) else { return nil } + guard let approvalKey = WatchApprovalID.key(approval.id) else { return nil } + return "watch.execApproval.\(gatewayKey.notificationComponent).\(approvalKey.notificationComponent)" + } + + private static func legacyExecApprovalNotificationIdentifier(for approval: WatchExecApprovalItem) -> String? { + guard let gatewayStableID = WatchGatewayID.exact(approval.gatewayStableID), + let approvalID = WatchApprovalID.exact(approval.id) + else { return nil } return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)" } + private static func execApprovalOwnerKey( + approvalId: String, + gatewayStableID: String?) -> ExecApprovalOwnerKey? + { + guard let approvalKey = WatchApprovalID.key(approvalId), + let gatewayKey = WatchGatewayID.key(gatewayStableID) + else { + return nil + } + return ExecApprovalOwnerKey( + gatewayID: gatewayKey, + approvalID: approvalKey) + } + + private func isExecApprovalTerminal(approvalId: String, gatewayStableID: String?) -> Bool { + guard let key = Self.execApprovalOwnerKey( + approvalId: approvalId, + gatewayStableID: gatewayStableID) + else { + return false + } + return self.execApprovalTerminalTombstones.contains { tombstone in + WatchApprovalID.key(tombstone.approvalId) == key.approvalID + && WatchGatewayID.key(tombstone.gatewayStableID) == key.gatewayID + } + } + + @discardableResult + private func recordExecApprovalTerminal( + approvalId: String, + gatewayStableID: String?, + outcomeText: String, + authoritativeOutcome: Bool = true) -> String? + { + guard let exactApprovalID = WatchApprovalID.exact(approvalId), + let exactGatewayID = WatchGatewayID.exact(gatewayStableID), + let key = Self.execApprovalOwnerKey( + approvalId: approvalId, + gatewayStableID: gatewayStableID) + else { + return nil + } + self.pruneExecApprovalTerminalTombstones(now: Date()) + let normalizedOutcomeText = outcomeText.trimmingCharacters(in: .whitespacesAndNewlines) + let boundedOutcomeText = String( + normalizedOutcomeText.prefix(Self.maxExecApprovalTerminalOutcomeCharacters)) + guard !boundedOutcomeText.isEmpty else { return nil } + if let existingIndex = execApprovalTerminalTombstones.lastIndex(where: { tombstone in + WatchApprovalID.key(tombstone.approvalId) == key.approvalID + && WatchGatewayID.key(tombstone.gatewayStableID) == key.gatewayID + }) { + if authoritativeOutcome, + self.execApprovalTerminalTombstones[existingIndex].outcomeIsAuthoritative != true + { + var upgraded = self.execApprovalTerminalTombstones.remove(at: existingIndex) + upgraded.outcomeText = boundedOutcomeText + upgraded.outcomeIsAuthoritative = true + upgraded.recordedAt = Date() + self.execApprovalTerminalTombstones.append(upgraded) + return upgraded.outcomeText + } + return self.execApprovalTerminalTombstones[existingIndex].outcomeText + } + self.execApprovalTerminalTombstones.append(ExecApprovalTerminalTombstone( + approvalId: exactApprovalID, + gatewayStableID: exactGatewayID, + outcomeText: boundedOutcomeText, + outcomeIsAuthoritative: authoritativeOutcome, + recordedAt: Date())) + self.pruneExecApprovalTerminalTombstones(now: Date()) + return boundedOutcomeText + } + + private func pruneExecApprovalTerminalTombstones(now: Date) { + let cutoff = now.addingTimeInterval(-Self.execApprovalTerminalTombstoneLifetime) + let retained = self.execApprovalTerminalTombstones.filter { tombstone in + tombstone.recordedAt >= cutoff + } + self.execApprovalTerminalTombstones = Array( + retained.suffix(Self.maxExecApprovalTerminalTombstones)) + } + private func pruneExpiredExecApprovals(nowMs: Int64) { let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil } @@ -1018,14 +1281,22 @@ extension WatchInboxStore { return expiresAtMs <= nowMs } self.removeExecApprovalNotifications(approvals: expiredApprovals) - if let selectedExecApprovalID, - !self.execApprovals.contains(where: { $0.id == selectedExecApprovalID }) - { - self.selectedExecApprovalID = self.sortedExecApprovals.first?.id - } + self.ensureValidExecApprovalSelection() self.persistState() } + private func ensureValidExecApprovalSelection() { + if let selectedKey = Self.execApprovalOwnerKey( + approvalId: self.selectedExecApprovalID ?? "", + gatewayStableID: self.selectedExecApprovalGatewayStableID), + self.execApprovals.contains(where: { $0.id == selectedKey }) + { + return + } + self.selectedExecApprovalID = self.sortedExecApprovals.first?.approvalID + self.selectedExecApprovalGatewayStableID = self.sortedExecApprovals.first?.approval.gatewayStableID + } + private func restorePersistedState() { guard let data = defaults.data(forKey: Self.persistedStateKey), let state = try? JSONDecoder().decode(PersistedState.self, from: data) @@ -1048,10 +1319,13 @@ extension WatchInboxStore { self.actions = state.actions ?? [] self.replyStatusText = state.replyStatusText self.replyStatusAt = state.replyStatusAt - let ownerlessApprovals = state.execApprovals.filter { record in + let validApprovals = state.execApprovals.filter { record in + WatchApprovalID.exact(record.approvalID) != nil + } + let ownerlessApprovals = validApprovals.filter { record in Self.normalizedGatewayID(record.approval.gatewayStableID) == nil } - let taggedApprovals = state.execApprovals.filter { record in + let taggedApprovals = validApprovals.filter { record in Self.normalizedGatewayID(record.approval.gatewayStableID) != nil } let activeGatewayID = state.appSnapshot.flatMap { snapshot in @@ -1060,16 +1334,17 @@ extension WatchInboxStore { let invalidatedApprovals: [WatchExecApprovalRecord] if state.appSnapshot != nil { self.execApprovals = taggedApprovals.filter { record in - Self.normalizedGatewayID(record.approval.gatewayStableID) == activeGatewayID + Self.gatewayIDsMatch(record.approval.gatewayStableID, activeGatewayID) } invalidatedApprovals = taggedApprovals.filter { record in - Self.normalizedGatewayID(record.approval.gatewayStableID) != activeGatewayID + !Self.gatewayIDsMatch(record.approval.gatewayStableID, activeGatewayID) } } else { self.execApprovals = taggedApprovals invalidatedApprovals = [] } - selectedExecApprovalID = state.selectedExecApprovalID + self.selectedExecApprovalID = state.selectedExecApprovalID + self.selectedExecApprovalGatewayStableID = state.selectedExecApprovalGatewayStableID self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID self.lastExecApprovalSnapshotGatewayStableID = state.lastExecApprovalSnapshotGatewayStableID self.lastExecApprovalSnapshotSentAtMs = state.lastExecApprovalSnapshotSentAtMs @@ -1081,19 +1356,30 @@ extension WatchInboxStore { self.appCommandStatusText = state.appCommandStatusText self.deferredGatewayPayloads = Array( (state.deferredGatewayPayloads ?? []).suffix(Self.maxDeferredGatewayPayloads)) + self.execApprovalTerminalTombstones = state.execApprovalTerminalTombstones ?? [] + self.pruneExecApprovalTerminalTombstones(now: Date()) + let restoredTerminalApprovals = self.execApprovals.compactMap { record in + self.isExecApprovalTerminal( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) + ? record.approval + : nil + } + self.execApprovals.removeAll { record in + self.isExecApprovalTerminal( + approvalId: record.approvalID, + gatewayStableID: record.approval.gatewayStableID) + } + self.removeExecApprovalNotifications(approvals: restoredTerminalApprovals) if state.appSnapshot != nil, - Self.normalizedGatewayID(self.lastExecApprovalSnapshotGatewayStableID) != activeGatewayID + !Self.gatewayIDsMatch(self.lastExecApprovalSnapshotGatewayStableID, activeGatewayID) { self.lastExecApprovalSnapshotID = nil self.lastExecApprovalSnapshotGatewayStableID = nil self.lastExecApprovalSnapshotSentAtMs = nil } - if let selectedExecApprovalID, - !self.execApprovals.contains(where: { $0.id == selectedExecApprovalID }) - { - self.selectedExecApprovalID = self.sortedExecApprovals.first?.id - } + self.ensureValidExecApprovalSelection() self.removeExecApprovalNotifications(approvals: invalidatedApprovals.map(\.approval)) guard !ownerlessApprovals.isEmpty else { return } @@ -1102,13 +1388,17 @@ extension WatchInboxStore { self.lastExecApprovalSnapshotID = nil self.lastExecApprovalSnapshotGatewayStableID = nil self.lastExecApprovalSnapshotSentAtMs = nil - self.removeLocalNotifications(identifiers: ownerlessApprovals.compactMap { record in - let approvalID = record.id.trimmingCharacters(in: .whitespacesAndNewlines) - return approvalID.isEmpty ? nil : "watch.execApproval.\(approvalID)" + self.removeLocalNotifications(identifiers: ownerlessApprovals.flatMap { record -> [String] in + guard let approvalKey = WatchApprovalID.key(record.approvalID) else { return [] } + return [ + "watch.execApproval.\(approvalKey.notificationComponent)", + "watch.execApproval.\(record.approvalID)", + ] }) } private func persistState() { + self.pruneExecApprovalTerminalTombstones(now: Date()) let updatedAt = self.updatedAt ?? self.lastExecApprovalOutcomeAt ?? Date() let state = PersistedState( title: title, @@ -1128,6 +1418,7 @@ extension WatchInboxStore { replyStatusAt: replyStatusAt, execApprovals: execApprovals, selectedExecApprovalID: selectedExecApprovalID, + selectedExecApprovalGatewayStableID: selectedExecApprovalGatewayStableID, lastExecApprovalSnapshotID: lastExecApprovalSnapshotID, lastExecApprovalSnapshotGatewayStableID: lastExecApprovalSnapshotGatewayStableID, lastExecApprovalSnapshotSentAtMs: lastExecApprovalSnapshotSentAtMs, @@ -1137,7 +1428,8 @@ extension WatchInboxStore { appSnapshotUpdatedAt: appSnapshotUpdatedAt, appSnapshotStatusText: appSnapshotStatusText, appCommandStatusText: appCommandStatusText, - deferredGatewayPayloads: deferredGatewayPayloads) + deferredGatewayPayloads: deferredGatewayPayloads, + execApprovalTerminalTombstones: execApprovalTerminalTombstones) guard let data = try? JSONEncoder().encode(state) else { return } self.defaults.set(data, forKey: Self.persistedStateKey) } diff --git a/apps/ios/WatchApp/Sources/WatchInboxView.swift b/apps/ios/WatchApp/Sources/WatchInboxView.swift index c5f9b9ba642e..69005d424ff5 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxView.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxView.swift @@ -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? { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift index b1ee7aa7c99f..84294703c544 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceAuthStore.swift @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 9e9e212b8b99..9112a0157d2c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -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 @@ -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? private var serverCapabilities: Set? private var snapshotWaiters: [CheckedContinuation] = [] // `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) { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift index e0fe6ded0a8a..9cff2c38bc1d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayPush.swift @@ -6,6 +6,11 @@ public enum GatewayServerCapability: String, CaseIterable, Sendable { } extension HelloOk { + func advertisedServerMethods() -> Set { + 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 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index 0938dc3206e3..3be9538ed5c1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -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() { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift index dba3f3f8793b..b31a2eb65423 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift @@ -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) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift index 7a561771a0cd..29ff5c12e009 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift @@ -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 } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift index a880b1d7c699..6fe9a5a65fbe 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -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" diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 6519c781ee2a..2fe7a59c1931 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -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() diff --git a/docs/platforms/android.md b/docs/platforms/android.md index a3fb75594f1e..ec9b9ec17f02 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -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 ``" matches the app's declared App Actions query pattern and hands the prompt into the chat composer without auto-sending it. diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index a0328dfcf240..0adb0e90010c 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -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. diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index 393e6be72201..5f2ba7ce1832 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -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 ``` diff --git a/scripts/apple-app-i18n.ts b/scripts/apple-app-i18n.ts index a1c46b4f6ab0..005e2c1f57c8 100644 --- a/scripts/apple-app-i18n.ts +++ b/scripts/apple-app-i18n.ts @@ -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", diff --git a/test/scripts/native-app-i18n.test.ts b/test/scripts/native-app-i18n.test.ts index 266f3160cdd9..bcdd69595b8c 100644 --- a/test/scripts/native-app-i18n.test.ts +++ b/test/scripts/native-app-i18n.test.ts @@ -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(