refactor(apps): drop more pre-July-2026 app migrations (#163347)

Android now requires the Gateway's explicit device-token retry decision. Remove code/advice-only inference last needed before v2026.3.11; July 2026 and newer Gateways keep their existing trusted, bounded retry.

This also prevents an explicit denial from arming a stored token that manual Retry would send on the next connection. The expanded real WebSocket test fails on the original implementation and passes with the fix.

Validation: 173 Android tests; Kotlin lint; check-changed; both import-cycle checks at zero; independent review; exact-head hosted CI including both native Android Access configurations.
This commit is contained in:
Peter Steinberger 2026-10-02 02:33:48 -05:00 • committed by GitHub
parent 8f364a7e80
commit 1141d7dd73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 91 additions and 59 deletions

View file

@ -70,6 +70,14 @@ selects fresh bootstrap before a stored device token. The wire regression is
`connect_prefersFreshBootstrapTokenOverStoredDeviceToken` in
[`GatewaySessionInvokeTest.kt`](app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt).
Stored-device-token retry requires `canRetryWithDeviceToken: true` from the
Gateway, plus the existing endpoint trust, token-presence, and retry-budget
checks. All supported July 2026 and newer Gateways supply this decision.
Missing or false permission cannot be overridden by a mismatch code or retry
advice, including after a manual reconnect. The two-connect wire regression is
`connect_requiresExplicitDeviceTokenRetryPermission`; its allowed fixture uses
the `v2026.7.1-beta.1` rejection shape.
[`DeviceAuthStore.kt`](app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt)
commits each role token and its metadata together and returns the actual durable
write result. The session records the final write result for each role in this
@ -130,7 +138,7 @@ would change other Android authentication paths, outside this fix's scope:
Swift preserves explicitly requested scopes and suppresses stored-token retry
for scope upgrades beyond a nonempty stored grant.
- Android's retry trust includes local cleartext hosts and existing TLS pins,
and accepts the legacy `retry_with_device_token` advice. Swift uses strict
and requires the boolean retry permission. Swift uses strict
loopback or a trusted WSS session, plus the boolean hint or mismatch code.
- Android keeps retrying a bootstrap node request with no scopes when the
Gateway reports `not-paired` and explicitly advises waiting. Swift's channel

View file

@ -2524,12 +2524,7 @@ class GatewaySession(
if (attemptedDeviceTokenRetry) return false
if (explicitGatewayToken == null || storedToken == null) return false
if (!isTrustedDeviceRetryEndpoint(target.endpoint, target.tls)) return false
val detailCode = error.details?.code
val recommendedNextStep = error.details?.recommendedNextStep
// New gateways set canRetryWithDeviceToken; older builds expose equivalent string codes.
return error.details?.canRetryWithDeviceToken == true ||
recommendedNextStep == "retry_with_device_token" ||
detailCode == "AUTH_TOKEN_MISMATCH"
return error.details?.canRetryWithDeviceToken == true
}
private fun shouldPauseReconnectAfterAuthFailure(

View file

@ -962,67 +962,96 @@ class GatewaySessionInvokeTest {
}
@Test
fun connect_retriesWithStoredDeviceTokenAfterSharedTokenMismatch() =
fun connect_requiresExplicitDeviceTokenRetryPermission() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val firstConnectAuth = CompletableDeferred<JsonObject?>()
val secondConnectAuth = CompletableDeferred<JsonObject?>()
val connectAttempts = AtomicInteger(0)
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
val auth = frame["params"]?.jsonObject?.get("auth")?.jsonObject
when (connectAttempts.incrementAndGet()) {
1 -> {
if (!firstConnectAuth.isCompleted) {
firstConnectAuth.complete(auth)
val cases =
listOf(
Triple(
"explicit denial survives manual reconnect",
"""{"code":"AUTH_TOKEN_MISMATCH","authReason":"token_mismatch","canRetryWithDeviceToken":false,"recommendedNextStep":"update_auth_credentials"}""",
false,
),
Triple(
"explicit denial overrides retry advice",
"""{"code":"AUTH_TOKEN_MISMATCH","canRetryWithDeviceToken":false,"recommendedNextStep":"retry_with_device_token"}""",
false,
),
Triple(
"July 2026 Gateway permits trusted retry",
// v2026.7.1-beta.1 rejectUnauthorized emits this token-mismatch detail shape.
"""{"code":"AUTH_TOKEN_MISMATCH","authReason":"token_mismatch","canRetryWithDeviceToken":true,"recommendedNextStep":"retry_with_device_token"}""",
true,
),
Triple("pre-March code-only response", """{"code":"AUTH_TOKEN_MISMATCH"}""", false),
Triple("retry advice without permission", """{"recommendedNextStep":"retry_with_device_token"}""", false),
)
for ((caseName, errorDetails, retryAllowed) in cases) {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val authFailure = CompletableDeferred<Boolean>()
val firstConnectAuth = CompletableDeferred<JsonObject?>()
val secondConnectAuth = CompletableDeferred<JsonObject?>()
val connectAttempts = AtomicInteger(0)
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
val auth = frame["params"]?.jsonObject?.get("auth")?.jsonObject
when (connectAttempts.incrementAndGet()) {
1 -> {
if (!firstConnectAuth.isCompleted) {
firstConnectAuth.complete(auth)
}
webSocket.send(
"""{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unauthorized","details":$errorDetails}}""",
)
webSocket.close(1000, "retry")
}
webSocket.send(
"""{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unauthorized","details":{"code":"AUTH_TOKEN_MISMATCH","canRetryWithDeviceToken":true,"recommendedNextStep":"retry_with_device_token"}}}""",
)
webSocket.close(1000, "retry")
}
else -> {
if (!secondConnectAuth.isCompleted) {
secondConnectAuth.complete(auth)
else -> {
if (!secondConnectAuth.isCompleted) {
secondConnectAuth.complete(auth)
}
webSocket.send(connectResponseFrame(id))
}
webSocket.send(connectResponseFrame(id))
}
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
onConnectFailure = { _, pauseReconnect -> authFailure.complete(pauseReconnect) },
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
val deviceId = testDeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
harness.deviceAuthStore.saveToken(gatewayIdForPort(server.port), deviceId, "node", "stored-device-token")
connectNodeSession(
session = harness.session,
port = server.port,
token = "shared-auth-token",
bootstrapToken = null,
)
assertEquals(caseName, !retryAllowed, withTimeout(TEST_TIMEOUT_MS) { authFailure.await() })
if (!retryAllowed) harness.session.reconnect()
awaitConnectedOrThrow(connected, lastDisconnect, server)
val firstAuth = withTimeout(TEST_TIMEOUT_MS) { firstConnectAuth.await() }
val secondAuth = withTimeout(TEST_TIMEOUT_MS) { secondConnectAuth.await() }
assertEquals(caseName, "shared-auth-token", firstAuth?.get("token")?.jsonPrimitive?.content)
assertNull(caseName, firstAuth?.get("deviceToken"))
assertEquals(caseName, "shared-auth-token", secondAuth?.get("token")?.jsonPrimitive?.content)
assertEquals(caseName, if (retryAllowed) "stored-device-token" else null, secondAuth?.get("deviceToken")?.jsonPrimitive?.content)
assertEquals(caseName, 2, connectAttempts.get())
assertEquals(caseName, "stored-device-token", harness.deviceAuthStore.loadToken(gatewayIdForPort(server.port), deviceId, "node"))
} finally {
shutdownHarness(harness, server)
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
val deviceId = testDeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
harness.deviceAuthStore.saveToken(gatewayIdForPort(server.port), deviceId, "node", "stored-device-token")
connectNodeSession(
session = harness.session,
port = server.port,
token = "shared-auth-token",
bootstrapToken = null,
)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val firstAuth = withTimeout(TEST_TIMEOUT_MS) { firstConnectAuth.await() }
val secondAuth = withTimeout(TEST_TIMEOUT_MS) { secondConnectAuth.await() }
assertEquals("shared-auth-token", firstAuth?.get("token")?.jsonPrimitive?.content)
assertNull(firstAuth?.get("deviceToken"))
assertEquals("shared-auth-token", secondAuth?.get("token")?.jsonPrimitive?.content)
assertEquals("stored-device-token", secondAuth?.get("deviceToken")?.jsonPrimitive?.content)
} finally {
shutdownHarness(harness, server)
}
}