mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
8f364a7e80
commit
1141d7dd73
3 changed files with 91 additions and 59 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue