diff --git a/README.md b/README.md
index fbb9273..904da6b 100644
--- a/README.md
+++ b/README.md
@@ -94,9 +94,29 @@ per-file limit, and 32 MiB total virtual filesystem limit.
./gradlew testDebugUnitTest # run JVM unit tests
./gradlew installDebug # install on a connected device/emulator
```
-2. Run the app, fill in the gateway WebSocket URL and your SealGate API key
- (from the dashboard), and tap **Start tunnel**. Settings persist across
- restarts; the ongoing notification shows the live connection state.
+2. Run the app, confirm (or edit) the gateway WebSocket URL, and tap
+ **Sign in with SealGate**. The app runs the OAuth 2.0 device-authorization
+ flow (RFC 8628, with PKCE): it shows a short code and opens the dashboard's
+ device page, where you approve the phone with one click. On approval the app
+ receives a scoped `ewc_` tunnel credential (never a human API key) bound to a
+ backend-issued device id, stores it, and starts the tunnel. Settings persist
+ across restarts; the ongoing notification shows the live connection state.
+
+ Pasting a SealGate API key under **Or connect with an API key** and tapping
+ **Connect** still works as an alternative to signing in.
+
+ Sign-in reuses the shared device-auth flow the desktop daemon uses, under a
+ dedicated `mobile` client id; the backend side lives in `edison-watch`
+ (`src/api/v1/routes/device_auth.py`, `dev-docs/architecture/mobile-hardware-gateway-design.md`).
+
+ The credential (and the in-flight PKCE verifier of an interrupted sign-in) is
+ stored encrypted at rest with an AES-256-GCM key held in the AndroidKeyStore
+ (`SecretCipher`), so a prefs dump or a backup restored to another phone cannot
+ lift it. To disconnect, open settings and tap **Sign out**: the app stops the
+ tunnel, forgets the local credential, and revokes the installation in the
+ dashboard (`POST /api/v1/auth/device/revoke`). If the gateway later revokes the
+ credential itself, the tunnel stops reconnecting and the app asks you to sign
+ in again instead of looping.
While the tunnel is running, pull down from the top of the app screen to
close the current socket and reconnect immediately with the saved settings.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8355278..9564678 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -51,6 +51,16 @@
android:name="android.hardware.usb.host"
android:required="false" />
+
+
+
+
+
+
+
+
+ android:exported="true"
+ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
diff --git a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt
index d53c336..4890eb0 100644
--- a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt
+++ b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt
@@ -1,16 +1,21 @@
package ai.sealgate.stdiod
import android.Manifest
+import android.content.ActivityNotFoundException
import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.content.pm.PackageManager
+import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.content.Intent
import android.provider.Settings
import android.view.HapticFeedbackConstants
import android.view.View
+import android.widget.TextView
+import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
@@ -19,9 +24,16 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import ai.sealgate.stdiod.databinding.ActivityMainBinding
+import ai.sealgate.stdiod.tunnel.DeviceAuthClient
+import ai.sealgate.stdiod.tunnel.DeviceIdentityStore
+import ai.sealgate.stdiod.tunnel.GatewayUrls
+import ai.sealgate.stdiod.tunnel.PendingGrant
import ai.sealgate.stdiod.tunnel.TunnelState
+import ai.sealgate.stdiod.tunnel.TunnelStopReason
import ai.sealgate.stdiod.mcp.ComputerAccessibilityService
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -34,6 +46,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var tunnelState: TunnelState? = null
+ private var signInJob: Job? = null
private var syncingComputerControlSwitch = false
private var syncingCameraSwitch = false
private val computerUsePreferenceListener =
@@ -146,16 +159,24 @@ class MainActivity : AppCompatActivity() {
.show()
}
+ binding.signInButton.setOnClickListener {
+ binding.signInButton.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
+ startDeviceSignIn()
+ }
+
+ binding.signOutButton.setOnClickListener {
+ binding.signOutButton.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
+ confirmSignOut()
+ }
+ updateSignOutVisibility()
+
binding.tunnelButton.setOnClickListener {
binding.tunnelButton.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
if (tunnelState != null) {
TunnelService.stop(this)
return@setOnClickListener
}
- val config = TunnelConfig(
- gatewayUrl = binding.gatewayUrlInput.text?.toString()?.trim().orEmpty(),
- authToken = binding.apiKeyInput.text?.toString()?.trim().orEmpty(),
- )
+ val config = configFromInputs()
binding.gatewayUrlLayout.error = null
binding.apiKeyLayout.error = null
if (!config.isValid()) {
@@ -170,6 +191,7 @@ class MainActivity : AppCompatActivity() {
}
TunnelSettings.save(this, config)
binding.settingsPanel.visibility = View.GONE
+ updateSignOutVisibility()
TunnelService.start(this, config)
}
@@ -184,6 +206,7 @@ class MainActivity : AppCompatActivity() {
TunnelState.Connected -> getString(R.string.tunnel_state_connected)
TunnelState.Connecting -> getString(R.string.tunnel_state_connecting)
TunnelState.Disconnected -> getString(R.string.tunnel_state_disconnected)
+ is TunnelState.Unauthorized -> getString(stopReasonStatus(state.reason))
null -> getString(R.string.status_stopped)
}
renderState(state, text)
@@ -205,6 +228,10 @@ class MainActivity : AppCompatActivity() {
.registerOnSharedPreferenceChangeListener(computerUsePreferenceListener)
renderComputerControl()
}
+ // Resume a sign-in that a process kill interrupted mid-approval. Guarded
+ // (no-op when a poll is already running or no grant is stored), so the
+ // common foreground-return case does nothing.
+ if (::binding.isInitialized) maybeResumeSignIn()
}
override fun onStop() {
@@ -263,7 +290,7 @@ class MainActivity : AppCompatActivity() {
when (state) {
TunnelState.Connected -> R.color.circuit_green
TunnelState.Connecting -> R.color.signal_amber
- TunnelState.Disconnected, null -> R.color.infra_red
+ TunnelState.Disconnected, is TunnelState.Unauthorized, null -> R.color.infra_red
},
)
binding.statusText.text = text
@@ -285,11 +312,24 @@ class MainActivity : AppCompatActivity() {
TunnelState.Connected -> R.string.tunnel_visual_connected
TunnelState.Connecting -> R.string.tunnel_visual_connecting
TunnelState.Disconnected -> R.string.tunnel_visual_reconnecting
+ is TunnelState.Unauthorized -> R.string.tunnel_visual_sign_in_required
null -> R.string.tunnel_visual_stopped
},
)
+ // A terminal auth failure needs the user to act: surface the panel that
+ // holds Sign in (and the gateway/API-key fields) so the fix is one tap away.
+ if (state is TunnelState.Unauthorized) {
+ binding.settingsPanel.visibility = View.VISIBLE
+ }
}
+ private fun stopReasonStatus(reason: TunnelStopReason): Int =
+ when (reason) {
+ TunnelStopReason.CREDENTIAL_REJECTED -> R.string.tunnel_state_sign_in_required
+ TunnelStopReason.PROTOCOL_UNSUPPORTED -> R.string.tunnel_state_update_required
+ TunnelStopReason.ORG_NOT_ENABLED -> R.string.tunnel_state_org_not_enabled
+ }
+
private fun maybeRequestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted = ContextCompat.checkSelfPermission(
@@ -315,6 +355,238 @@ class MainActivity : AppCompatActivity() {
if (wanted.isNotEmpty()) requestBluetoothPermissions.launch(wanted.toTypedArray())
}
+ /**
+ * Build a [TunnelConfig] from the visible inputs, carrying the OAuth device
+ * id forward when the credential and gateway are unchanged from what was
+ * saved. Editing either field is treated as a fresh (API-key) credential
+ * with no bound device id.
+ */
+ private fun configFromInputs(): TunnelConfig {
+ val gatewayUrl = binding.gatewayUrlInput.text?.toString()?.trim().orEmpty()
+ val authToken = binding.apiKeyInput.text?.toString()?.trim().orEmpty()
+ val stored = TunnelSettings.load(this)
+ // The OAuth device id is bound to the `ewc_` credential, not the endpoint,
+ // so keep it as long as the saved credential is still the one in the field.
+ // Editing the gateway URL must not drop it (that would leave the credential
+ // unusable); a manually pasted API key simply has no bound device id.
+ val deviceId = if (authToken == stored.authToken && authToken.startsWith("ewc_")) {
+ stored.deviceId
+ } else {
+ null
+ }
+ return TunnelConfig(gatewayUrl = gatewayUrl, authToken = authToken, deviceId = deviceId)
+ }
+
+ /**
+ * Start the OAuth device-authorization flow: request a code, persist the
+ * grant (so a process death mid-approval can resume), then poll.
+ */
+ private fun startDeviceSignIn() {
+ if (signInJob?.isActive == true) return
+ val gatewayUrl = binding.gatewayUrlInput.text?.toString()?.trim().orEmpty()
+ binding.gatewayUrlLayout.error = null
+ val apiBase = GatewayUrls.apiBaseFromWs(gatewayUrl)
+ if (apiBase == null) {
+ binding.gatewayUrlLayout.error = getString(R.string.error_gateway_url)
+ binding.settingsPanel.visibility = View.VISIBLE
+ return
+ }
+
+ val client = DeviceAuthClient(apiBase)
+ val label = DeviceIdentityStore.deviceLabel()
+ val existingInstallation = TunnelSettings.clientInstallationId(this)
+ binding.signInButton.isEnabled = false
+ Toast.makeText(this, R.string.sign_in_starting, Toast.LENGTH_SHORT).show()
+
+ signInJob = lifecycleScope.launch {
+ val grant = try {
+ client.requestDeviceCode(label, BuildConfig.VERSION_NAME, existingInstallation)
+ } catch (e: DeviceAuthClient.DeviceAuthException) {
+ binding.signInButton.isEnabled = true
+ showSignInError(e.message)
+ return@launch
+ }
+ PendingSignInStore.save(
+ this@MainActivity,
+ gatewayUrl,
+ grant,
+ System.currentTimeMillis() + grant.expiresInSeconds.toLong() * 1000L,
+ )
+ pollGrantToTunnel(client, gatewayUrl, grant, openBrowser = true)
+ }
+ }
+
+ /**
+ * Resume a sign-in whose poll was killed by process death while the user was
+ * approving in the browser. No-op when there is no live grant. The browser is
+ * not reopened - the user was already there.
+ */
+ private fun maybeResumeSignIn() {
+ if (signInJob?.isActive == true) return
+ val saved = PendingSignInStore.load(this) ?: return
+ val apiBase = GatewayUrls.apiBaseFromWs(saved.gatewayUrl)
+ if (apiBase == null) {
+ PendingSignInStore.clear(this)
+ return
+ }
+ binding.signInButton.isEnabled = false
+ signInJob = lifecycleScope.launch {
+ pollGrantToTunnel(DeviceAuthClient(apiBase), saved.gatewayUrl, saved.grant, openBrowser = false)
+ }
+ }
+
+ /**
+ * Show the approval dialog, poll to completion, and on success persist the
+ * credential and start the tunnel. The persisted grant is cleared on success
+ * and on terminal failure here, and on explicit user cancel in the dialog
+ * handlers; it is deliberately kept on lifecycle cancellation and process
+ * kill so [maybeResumeSignIn] can pick it up on the next launch.
+ */
+ private suspend fun pollGrantToTunnel(
+ client: DeviceAuthClient,
+ gatewayUrl: String,
+ grant: PendingGrant,
+ openBrowser: Boolean,
+ ) {
+ try {
+ val dialog = showSignInDialog(grant)
+ if (openBrowser) openUri(grant.verificationUriComplete)
+ val result = try {
+ client.pollForToken(grant)
+ } finally {
+ dialog.dismiss()
+ }
+ TunnelSettings.saveOAuthResult(
+ context = this,
+ gatewayUrl = gatewayUrl,
+ accessToken = result.accessToken,
+ deviceId = result.deviceId,
+ clientInstallationId = result.clientInstallationId,
+ )
+ binding.gatewayUrlInput.setText(gatewayUrl)
+ binding.apiKeyInput.setText(result.accessToken)
+ binding.apiKeyLayout.error = null
+ binding.settingsPanel.visibility = View.GONE
+ updateSignOutVisibility()
+ Toast.makeText(this, R.string.sign_in_success, Toast.LENGTH_SHORT).show()
+ TunnelService.start(this, TunnelConfig(gatewayUrl, result.accessToken, result.deviceId))
+ PendingSignInStore.clear(this)
+ } catch (e: CancellationException) {
+ // Lifecycle cancellation (e.g. the activity is destroyed): leave the
+ // grant persisted so it can resume. Explicit user cancel clears it in
+ // the dialog handlers before cancelling the job.
+ throw e
+ } catch (e: DeviceAuthClient.DeviceAuthException) {
+ PendingSignInStore.clear(this)
+ showSignInError(e.message)
+ } finally {
+ binding.signInButton.isEnabled = true
+ }
+ }
+
+ private fun showSignInDialog(grant: PendingGrant): AlertDialog {
+ val view = layoutInflater.inflate(R.layout.dialog_device_sign_in, null)
+ view.findViewById(R.id.signInUri).text = grant.verificationUri
+ view.findViewById(R.id.signInCode).text = grant.userCode
+ val dialog = MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.sign_in_dialog_title)
+ .setView(view)
+ .setPositiveButton(R.string.action_open_dashboard, null)
+ .setNegativeButton(R.string.action_cancel) { _, _ -> cancelSignIn() }
+ .setOnCancelListener { cancelSignIn() }
+ .create()
+ // Keep the dialog open when "Open dashboard" is tapped so the user can
+ // return and watch it flip to connected.
+ dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
+ openUri(grant.verificationUriComplete)
+ }
+ }
+ dialog.show()
+ return dialog
+ }
+
+ /** User aborted sign-in: drop the persisted grant so it is not resumed, then stop the poll. */
+ private fun cancelSignIn() {
+ PendingSignInStore.clear(this)
+ signInJob?.cancel()
+ }
+
+ /** Show the sign-out button only while a credential is stored. */
+ private fun updateSignOutVisibility() {
+ val hasCredential = TunnelSettings.load(this).authToken.isNotBlank()
+ binding.signOutButton.visibility = if (hasCredential) View.VISIBLE else View.GONE
+ }
+
+ private fun confirmSignOut() {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.sign_out_dialog_title)
+ .setMessage(R.string.sign_out_dialog_message)
+ .setPositiveButton(R.string.action_sign_out) { _, _ -> signOut() }
+ .setNegativeButton(R.string.action_cancel, null)
+ .show()
+ }
+
+ /**
+ * Stop the tunnel, forget the local credential, and best-effort revoke it in
+ * the dashboard. Local sign-out always completes; the revoke is attempted only
+ * for an OAuth (`ewc_`) credential and its failure only downgrades the toast.
+ */
+ private fun signOut() {
+ val stored = TunnelSettings.load(this)
+ val token = stored.authToken
+ val apiBase = GatewayUrls.apiBaseFromWs(stored.gatewayUrl)
+ // A pasted API key is not ours to revoke through the device endpoint; only
+ // an OAuth `ewc_` credential is. (apiBase != null is checked below, both to
+ // guard the call and to smart-cast it for the request.)
+ val canRevoke = token.startsWith("ewc_")
+
+ // Tear down every trace of the session on this device first, so the UI is
+ // honestly signed out even if the revoke call below never returns.
+ signInJob?.cancel()
+ PendingSignInStore.clear(this)
+ TunnelService.stop(this)
+ TunnelSettings.clearCredential(this)
+ binding.apiKeyInput.setText("")
+ binding.apiKeyLayout.error = null
+ binding.settingsPanel.visibility = View.VISIBLE
+ updateSignOutVisibility()
+
+ if (!canRevoke || apiBase == null) {
+ Toast.makeText(this, R.string.sign_out_done, Toast.LENGTH_SHORT).show()
+ return
+ }
+ Toast.makeText(this, R.string.sign_out_in_progress, Toast.LENGTH_SHORT).show()
+ lifecycleScope.launch {
+ val revoked = DeviceAuthClient(apiBase).revokeCredential(token)
+ Toast.makeText(
+ this@MainActivity,
+ if (revoked) R.string.sign_out_done else R.string.sign_out_revoke_failed,
+ if (revoked) Toast.LENGTH_SHORT else Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+
+ private fun showSignInError(message: String?) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.sign_in_failed_title)
+ .setMessage(message ?: getString(R.string.sign_in_failed_title))
+ .setPositiveButton(R.string.action_close, null)
+ .show()
+ }
+
+ private fun openUri(uri: String) {
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
+ } catch (e: ActivityNotFoundException) {
+ Toast.makeText(
+ this,
+ getString(R.string.sign_in_no_browser, uri),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+
companion object {
private const val REFRESH_INDICATOR_MILLIS = 650L
}
diff --git a/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt b/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt
new file mode 100644
index 0000000..1ce41d9
--- /dev/null
+++ b/app/src/main/java/ai/sealgate/stdiod/PendingSignInStore.kt
@@ -0,0 +1,101 @@
+package ai.sealgate.stdiod
+
+import ai.sealgate.stdiod.tunnel.PendingGrant
+import android.content.Context
+
+/**
+ * Persists an in-flight OAuth device grant so a sign-in that is interrupted by
+ * process death (the OS reclaiming the app while the user is approving in the
+ * browser) can resume on next launch instead of silently orphaning the grant.
+ *
+ * Rotation and other configuration changes are already handled by
+ * `android:configChanges` on MainActivity, so this store only earns its keep
+ * for true process death. The grant is short-lived (minutes) and the stored
+ * fields carry the same trust level as the credential they redeem, so they
+ * live in the app's private SharedPreferences and are cleared the moment the
+ * poll reaches any terminal state. The two secrets that redeem the grant - the
+ * PKCE verifier and the device code - are [SecretCipher]-encrypted at rest, so
+ * an interrupted sign-in is no weaker on disk than a completed one.
+ */
+object PendingSignInStore {
+
+ /** A resumable grant, with [grant] re-expressed against the time remaining. */
+ data class Saved(val gatewayUrl: String, val grant: PendingGrant)
+
+ fun save(context: Context, gatewayUrl: String, grant: PendingGrant, expiresAtMillis: Long) {
+ context.prefs()
+ .edit()
+ .putString(KEY_GATEWAY_URL, gatewayUrl)
+ .putString(KEY_DEVICE_CODE, SecretCipher.encrypt(grant.deviceCode))
+ .putString(KEY_USER_CODE, grant.userCode)
+ .putString(KEY_VERIFY_URI, grant.verificationUri)
+ .putString(KEY_VERIFY_URI_COMPLETE, grant.verificationUriComplete)
+ .putInt(KEY_INTERVAL, grant.intervalSeconds)
+ .putString(KEY_CODE_VERIFIER, SecretCipher.encrypt(grant.codeVerifier))
+ .putLong(KEY_EXPIRES_AT, expiresAtMillis)
+ .apply()
+ }
+
+ /**
+ * Return the persisted grant with its lifetime rebased on the time left, or
+ * null when there is none or it has (all but) expired. Clears an expired or
+ * malformed record so a stale grant is never resumed.
+ */
+ fun load(context: Context, nowMillis: Long = System.currentTimeMillis()): Saved? {
+ val prefs = context.prefs()
+ val gatewayUrl = prefs.getString(KEY_GATEWAY_URL, null)
+ val deviceCode = prefs.getString(KEY_DEVICE_CODE, null)?.let { SecretCipher.decrypt(it) }
+ val userCode = prefs.getString(KEY_USER_CODE, null)
+ val verifyUri = prefs.getString(KEY_VERIFY_URI, null)
+ val verifyUriComplete = prefs.getString(KEY_VERIFY_URI_COMPLETE, null)
+ val codeVerifier = prefs.getString(KEY_CODE_VERIFIER, null)?.let { SecretCipher.decrypt(it) }
+ val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L)
+ val interval = prefs.getInt(KEY_INTERVAL, 0)
+ if (
+ gatewayUrl == null || deviceCode == null || userCode == null ||
+ verifyUri == null || verifyUriComplete == null || codeVerifier == null
+ ) {
+ // When a record was stored but a secret no longer decrypts, the grant
+ // is unredeemable: drop it so it is never resumed. An empty store (no
+ // gateway url) needs no write.
+ if (gatewayUrl != null) clear(context)
+ return null
+ }
+ val remainingSeconds = ((expiresAt - nowMillis) / 1000L).toInt()
+ if (remainingSeconds < MIN_RESUME_SECONDS) {
+ clear(context)
+ return null
+ }
+ return Saved(
+ gatewayUrl = gatewayUrl,
+ grant = PendingGrant(
+ deviceCode = deviceCode,
+ userCode = userCode,
+ verificationUri = verifyUri,
+ verificationUriComplete = verifyUriComplete,
+ expiresInSeconds = remainingSeconds,
+ intervalSeconds = interval.coerceAtLeast(1),
+ codeVerifier = codeVerifier,
+ ),
+ )
+ }
+
+ fun clear(context: Context) {
+ context.prefs().edit().clear().apply()
+ }
+
+ private fun Context.prefs() = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ // Too little time left is not worth resuming: the poll interval alone could
+ // outlast it, so the user would just watch it expire.
+ private const val MIN_RESUME_SECONDS = 15
+ private const val PREFS = "pending_sign_in"
+ private const val KEY_GATEWAY_URL = "gateway_url"
+ private const val KEY_DEVICE_CODE = "device_code"
+ private const val KEY_USER_CODE = "user_code"
+ private const val KEY_VERIFY_URI = "verification_uri"
+ private const val KEY_VERIFY_URI_COMPLETE = "verification_uri_complete"
+ private const val KEY_INTERVAL = "interval"
+ private const val KEY_CODE_VERIFIER = "code_verifier"
+ private const val KEY_EXPIRES_AT = "expires_at"
+}
diff --git a/app/src/main/java/ai/sealgate/stdiod/SecretCipher.kt b/app/src/main/java/ai/sealgate/stdiod/SecretCipher.kt
new file mode 100644
index 0000000..2ea7703
--- /dev/null
+++ b/app/src/main/java/ai/sealgate/stdiod/SecretCipher.kt
@@ -0,0 +1,95 @@
+package ai.sealgate.stdiod
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import android.util.Log
+import java.security.GeneralSecurityException
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * Encrypts small secrets (the tunnel credential, the in-flight PKCE verifier)
+ * at rest using an AES-256-GCM key held in the AndroidKeyStore.
+ *
+ * The key never leaves the Keystore (hardware-backed on devices with a TEE or
+ * StrongBox), so the ciphertext stored in SharedPreferences is useless off the
+ * device: it cannot be read from a backup restored to another phone, nor from a
+ * `run-as`/root dump of the prefs file without also compromising the Keystore.
+ * That is a deliberate step up from storing the `ewc_` bearer token in plain
+ * text, and it makes an `allowBackup` copy of the prefs inert.
+ *
+ * Values are stored as `v1:` + base64(iv ‖ ciphertext‖GCM-tag). A stored value
+ * without the prefix is treated as legacy plaintext and returned as-is, so an
+ * existing sign-in survives the upgrade and is re-encrypted the next time it is
+ * saved. A value that fails to decrypt (e.g. the Keystore key is gone after a
+ * cross-device restore) yields null, which the callers treat as "signed out" -
+ * the correct outcome for a credential that must not survive off its device.
+ */
+object SecretCipher {
+
+ /** Wrap a plaintext secret for storage. */
+ fun encrypt(plaintext: String): String {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey())
+ val iv = cipher.iv
+ val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
+ val blob = ByteArray(iv.size + ciphertext.size)
+ System.arraycopy(iv, 0, blob, 0, iv.size)
+ System.arraycopy(ciphertext, 0, blob, iv.size, ciphertext.size)
+ return PREFIX + Base64.encodeToString(blob, Base64.NO_WRAP)
+ }
+
+ /**
+ * Unwrap a value produced by [encrypt]. Returns a legacy (unprefixed)
+ * plaintext value unchanged, and null when a prefixed value cannot be
+ * decrypted (corrupt, or the key is no longer available).
+ */
+ fun decrypt(stored: String): String? {
+ if (!stored.startsWith(PREFIX)) return stored
+ return try {
+ val blob = Base64.decode(stored.substring(PREFIX.length), Base64.NO_WRAP)
+ if (blob.size <= IV_LENGTH) return null
+ val iv = blob.copyOfRange(0, IV_LENGTH)
+ val ciphertext = blob.copyOfRange(IV_LENGTH, blob.size)
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_LENGTH_BITS, iv))
+ String(cipher.doFinal(ciphertext), Charsets.UTF_8)
+ } catch (e: GeneralSecurityException) {
+ Log.w(TAG, "failed to decrypt stored secret; treating as signed out", e)
+ null
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "stored secret was not valid base64", e)
+ null
+ }
+ }
+
+ private fun secretKey(): SecretKey {
+ val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
+ (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .build(),
+ )
+ return generator.generateKey()
+ }
+
+ private const val TAG = "SecretCipher"
+ private const val KEYSTORE = "AndroidKeyStore"
+ private const val KEY_ALIAS = "sealgate_secret_v1"
+ private const val TRANSFORMATION = "AES/GCM/NoPadding"
+ // AES-GCM standard nonce length; the Keystore generates it on encrypt.
+ private const val IV_LENGTH = 12
+ private const val TAG_LENGTH_BITS = 128
+ private const val PREFIX = "v1:"
+}
diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt
index eda65a2..75733cf 100644
--- a/app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt
+++ b/app/src/main/java/ai/sealgate/stdiod/TunnelConfig.kt
@@ -10,8 +10,19 @@ package ai.sealgate.stdiod
data class TunnelConfig(
/** Gateway WebSocket endpoint, e.g. `wss://gateway.sealgate.ai/tunnel`. */
val gatewayUrl: String,
- /** Bearer token used to authenticate the tunnel with the gateway. */
+ /**
+ * Bearer token used to authenticate the tunnel with the gateway. Either a
+ * SealGate API key or, after OAuth sign-in, a scoped `ewc_` client
+ * credential.
+ */
val authToken: String,
+ /**
+ * Backend-issued device id (`ewd_...`) from OAuth sign-in. The gateway
+ * requires the tunnel's `X-SealGate-Device-Id` to equal the id bound to an
+ * `ewc_` credential, so it must be sent verbatim. Null in API-key mode,
+ * where the locally minted device id is used instead.
+ */
+ val deviceId: String? = null,
) {
/** True when the config is complete enough to attempt a connection. */
fun isValid(): Boolean =
@@ -21,5 +32,6 @@ data class TunnelConfig(
companion object {
const val EXTRA_GATEWAY_URL = "ai.sealgate.stdiod.extra.GATEWAY_URL"
const val EXTRA_AUTH_TOKEN = "ai.sealgate.stdiod.extra.AUTH_TOKEN"
+ const val EXTRA_DEVICE_ID = "ai.sealgate.stdiod.extra.DEVICE_ID"
}
}
diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt
index 66fd45a..a8524d5 100644
--- a/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt
+++ b/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt
@@ -36,6 +36,7 @@ import ai.sealgate.stdiod.mcp.WifiModule
import ai.sealgate.stdiod.tunnel.DeviceIdentityStore
import ai.sealgate.stdiod.tunnel.TunnelClient
import ai.sealgate.stdiod.tunnel.TunnelState
+import ai.sealgate.stdiod.tunnel.TunnelStopReason
import ai.sealgate.stdiod.ui.NotificationTunnelArtwork
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -82,6 +83,7 @@ class TunnelService : LifecycleService() {
TunnelConfig(
gatewayUrl = it.getStringExtra(TunnelConfig.EXTRA_GATEWAY_URL).orEmpty(),
authToken = it.getStringExtra(TunnelConfig.EXTRA_AUTH_TOKEN).orEmpty(),
+ deviceId = it.getStringExtra(TunnelConfig.EXTRA_DEVICE_ID)?.ifBlank { null },
)
}
@@ -119,7 +121,7 @@ class TunnelService : LifecycleService() {
private fun startClient(config: TunnelConfig) {
Log.i(TAG, "Tunnel starting -> ${config.gatewayUrl}")
- val identity = DeviceIdentityStore.load(this, BuildConfig.VERSION_NAME)
+ val identity = DeviceIdentityStore.load(this, BuildConfig.VERSION_NAME, config.deviceId)
val capabilityModules = buildList {
add(DeviceInfoModule(AndroidDeviceInfo))
add(BatteryModule(AndroidBatterySource(this@TunnelService)))
@@ -166,7 +168,8 @@ class TunnelService : LifecycleService() {
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationAnimationJob?.cancel()
manager.notify(NOTIFICATION_ID, buildNotification(state, frame = 0))
- if (state != TunnelState.Disconnected && systemAnimationsEnabled()) {
+ val animated = state != TunnelState.Disconnected && state !is TunnelState.Unauthorized
+ if (animated && systemAnimationsEnabled()) {
notificationAnimationJob = animateNotification(state, manager)
}
}
@@ -281,6 +284,17 @@ class TunnelService : LifecycleService() {
status = R.string.tunnel_state_disconnected,
color = R.color.infra_red,
)
+ is TunnelState.Unauthorized -> NotificationPresentation(
+ status = stopReasonStatus(state.reason),
+ color = R.color.infra_red,
+ )
+ }
+
+ private fun stopReasonStatus(reason: TunnelStopReason): Int =
+ when (reason) {
+ TunnelStopReason.CREDENTIAL_REJECTED -> R.string.tunnel_state_sign_in_required
+ TunnelStopReason.PROTOCOL_UNSUPPORTED -> R.string.tunnel_state_update_required
+ TunnelStopReason.ORG_NOT_ENABLED -> R.string.tunnel_state_org_not_enabled
}
private fun systemAnimationsEnabled(): Boolean =
@@ -329,6 +343,7 @@ class TunnelService : LifecycleService() {
val intent = Intent(context, TunnelService::class.java).apply {
putExtra(TunnelConfig.EXTRA_GATEWAY_URL, config.gatewayUrl)
putExtra(TunnelConfig.EXTRA_AUTH_TOKEN, config.authToken)
+ putExtra(TunnelConfig.EXTRA_DEVICE_ID, config.deviceId)
}
context.startForegroundService(intent)
}
diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt
index d3c4c88..f24e4cb 100644
--- a/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt
+++ b/app/src/main/java/ai/sealgate/stdiod/TunnelSettings.kt
@@ -4,9 +4,16 @@ import android.content.Context
/**
* Persisted connection settings, so the tunnel can be configured from the
- * screen instead of by editing code. SharedPreferences is enough for two
- * strings; the API key never leaves the device except as the tunnel's
- * bearer header.
+ * screen instead of by editing code. SharedPreferences is enough for a few
+ * strings; the credential never leaves the device except as the tunnel's
+ * bearer header, and is stored [SecretCipher]-encrypted at rest so a prefs
+ * dump or backup cannot lift it.
+ *
+ * Two auth modes share this store: a pasted API key, or an OAuth `ewc_` client
+ * credential from device sign-in. OAuth additionally persists the
+ * backend-issued device id (sent as the tunnel's device id) and the client
+ * installation id (passed back on re-authentication so the same device row is
+ * rotated instead of a new one being created).
*/
object TunnelSettings {
@@ -20,23 +27,79 @@ object TunnelSettings {
fun load(context: Context): TunnelConfig {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ // A stored credential that no longer decrypts (e.g. restored to another
+ // device where the Keystore key does not exist) reads as empty: the user
+ // is signed out, which is the right outcome for an off-device credential.
+ val authToken = prefs.getString(KEY_AUTH_TOKEN, null)?.let { SecretCipher.decrypt(it) }
return TunnelConfig(
gatewayUrl = prefs.getString(KEY_GATEWAY_URL, null) ?: DEFAULT_GATEWAY_URL,
- authToken = prefs.getString(KEY_AUTH_TOKEN, null).orEmpty(),
+ authToken = authToken.orEmpty(),
+ deviceId = prefs.getString(KEY_DEVICE_ID, null)?.ifBlank { null },
)
}
+ /** The client installation id from the last OAuth sign-in, if any. */
+ fun clientInstallationId(context: Context): String? =
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getString(KEY_CLIENT_INSTALLATION_ID, null)
+ ?.ifBlank { null }
+
fun save(context: Context, config: TunnelConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_GATEWAY_URL, config.gatewayUrl)
- .putString(KEY_AUTH_TOKEN, config.authToken)
+ .putString(KEY_AUTH_TOKEN, SecretCipher.encrypt(config.authToken))
+ .apply {
+ if (config.deviceId.isNullOrBlank()) {
+ remove(KEY_DEVICE_ID)
+ } else {
+ putString(KEY_DEVICE_ID, config.deviceId)
+ }
+ }
.remove(LEGACY_KEY_BASH_MODE)
.apply()
}
+ /**
+ * Persist the outcome of an OAuth sign-in: the gateway used, the `ewc_`
+ * credential, the backend device id, and the client installation id for
+ * later re-authentication.
+ */
+ fun saveOAuthResult(
+ context: Context,
+ gatewayUrl: String,
+ accessToken: String,
+ deviceId: String,
+ clientInstallationId: String,
+ ) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_GATEWAY_URL, gatewayUrl)
+ .putString(KEY_AUTH_TOKEN, SecretCipher.encrypt(accessToken))
+ .putString(KEY_DEVICE_ID, deviceId)
+ .putString(KEY_CLIENT_INSTALLATION_ID, clientInstallationId)
+ .remove(LEGACY_KEY_BASH_MODE)
+ .apply()
+ }
+
+ /**
+ * Forget the stored credential and OAuth identity (sign out). The gateway
+ * URL is kept so the user can sign in again to the same endpoint without
+ * retyping it.
+ */
+ fun clearCredential(context: Context) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .remove(KEY_AUTH_TOKEN)
+ .remove(KEY_DEVICE_ID)
+ .remove(KEY_CLIENT_INSTALLATION_ID)
+ .apply()
+ }
+
private const val PREFS = "tunnel_settings"
private const val KEY_GATEWAY_URL = "gateway_url"
private const val KEY_AUTH_TOKEN = "auth_token"
+ private const val KEY_DEVICE_ID = "device_id"
+ private const val KEY_CLIENT_INSTALLATION_ID = "client_installation_id"
private const val LEGACY_KEY_BASH_MODE = "bash_mode"
}
diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt
new file mode 100644
index 0000000..bcddbb7
--- /dev/null
+++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceAuthClient.kt
@@ -0,0 +1,317 @@
+package ai.sealgate.stdiod.tunnel
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonArray
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.Base64
+
+/**
+ * Client for SealGate's OAuth 2.0 Device Authorization Grant (RFC 8628, with
+ * PKCE), the same flow the desktop daemon uses. The phone requests a short
+ * user code, the human approves it in the dashboard, and the phone polls until
+ * it receives a scoped `ewc_` tunnel credential bound to a backend-minted
+ * device id. See `src/api/v1/routes/device_auth.py` in edison-watch and
+ * `dev-docs/architecture/mobile-hardware-gateway-design.md`.
+ *
+ * This class deliberately avoids Android framework types so its logic is
+ * exercised by plain JVM unit tests (there is no Android SDK in CI's unit
+ * test task).
+ */
+class DeviceAuthClient(
+ private val apiBaseUrl: String,
+ private val httpClient: OkHttpClient = OkHttpClient(),
+ private val json: Json = defaultJson,
+) {
+ /** The `ewc_` credential and identity the app persists after a successful pairing. */
+ data class PairingResult(
+ val accessToken: String,
+ val deviceId: String,
+ val clientInstallationId: String,
+ )
+
+ /** A user-facing failure with a message safe to show verbatim. */
+ class DeviceAuthException(message: String) : Exception(message)
+
+ /**
+ * Ask the backend for a device code. Returns the pending grant plus the
+ * PKCE verifier the caller must keep to redeem it in [pollForToken].
+ */
+ suspend fun requestDeviceCode(
+ deviceLabel: String,
+ clientVersion: String,
+ clientInstallationId: String?,
+ ): PendingGrant {
+ val pkce = Pkce.generate()
+ val body = buildJsonObject {
+ put("client_id", CLIENT_ID)
+ putJsonArray("scope") { add(SCOPE_TUNNEL_CONNECT) }
+ put("code_challenge", pkce.challenge)
+ put("code_challenge_method", "S256")
+ put("device_label", deviceLabel)
+ put("platform", PLATFORM_ANDROID)
+ put("client_version", clientVersion)
+ if (clientInstallationId != null) put("client_installation_id", clientInstallationId)
+ }.toString()
+ val response = post("$apiBaseUrl$PATH_CODE", body)
+ if (!response.isSuccessful) {
+ throw DeviceAuthException(describeCodeError(response.code, response.body))
+ }
+ val code = try {
+ json.decodeFromString(response.body)
+ } catch (e: Exception) {
+ throw DeviceAuthException("The gateway returned an unexpected sign-in response.")
+ }
+ return PendingGrant(
+ deviceCode = code.deviceCode,
+ userCode = code.userCode,
+ verificationUri = code.verificationUri,
+ verificationUriComplete = code.verificationUriComplete,
+ expiresInSeconds = code.expiresIn,
+ intervalSeconds = code.interval,
+ codeVerifier = pkce.verifier,
+ )
+ }
+
+ /**
+ * Poll the token endpoint until the grant is approved, denied, or expires.
+ * Honours the server's `interval` and `slow_down` back-pressure.
+ */
+ suspend fun pollForToken(grant: PendingGrant): PairingResult {
+ var intervalSeconds = grant.intervalSeconds.coerceAtLeast(1)
+ val deadlineMillis = System.currentTimeMillis() + grant.expiresInSeconds.toLong() * 1000L
+ val body = buildJsonObject {
+ put("client_id", CLIENT_ID)
+ put("device_code", grant.deviceCode)
+ put("code_verifier", grant.codeVerifier)
+ }.toString()
+ while (true) {
+ delay(intervalSeconds.toLong() * 1000L)
+ if (System.currentTimeMillis() >= deadlineMillis) {
+ throw DeviceAuthException(EXPIRED_MESSAGE)
+ }
+ val response = post("$apiBaseUrl$PATH_TOKEN", body)
+ if (response.isSuccessful) {
+ val token = try {
+ json.decodeFromString(response.body)
+ } catch (e: Exception) {
+ throw DeviceAuthException("The gateway returned an unexpected token response.")
+ }
+ return PairingResult(
+ accessToken = token.accessToken,
+ deviceId = token.deviceId,
+ clientInstallationId = token.clientInstallationId,
+ )
+ }
+ when (val action = pollActionFor(response.code, response.body, intervalSeconds)) {
+ is PollAction.Retry -> intervalSeconds = action.intervalSeconds
+ is PollAction.Fail -> throw DeviceAuthException(action.message)
+ }
+ }
+ }
+
+ /**
+ * Revoke the client installation behind an `ewc_` credential, so the phone's
+ * server row is dropped and its grants denied even if the local credential
+ * later leaks. Best-effort: returns true when the gateway confirmed the
+ * revocation (or the credential was already invalid), false when it could not
+ * be reached. Local sign-out should proceed either way.
+ */
+ suspend fun revokeCredential(accessToken: String): Boolean {
+ val response = try {
+ postAuthorized("$apiBaseUrl$PATH_REVOKE", accessToken)
+ } catch (e: DeviceAuthException) {
+ return false
+ }
+ return revocationSucceeded(response.code)
+ }
+
+ /**
+ * Whether a revoke response means the credential is gone. 2xx is a fresh
+ * revocation; 401 means the gateway already considers it invalid/revoked,
+ * which is the same end state. Pure, so it is unit-tested.
+ */
+ internal fun revocationSucceeded(statusCode: Int): Boolean =
+ statusCode in 200..299 || statusCode == 401
+
+ private suspend fun postAuthorized(url: String, bearer: String): HttpResult =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url(url)
+ .header("Accept", "application/json")
+ .header("Authorization", "Bearer $bearer")
+ .post(ByteArray(0).toRequestBody(JSON_MEDIA_TYPE))
+ .build()
+ try {
+ httpClient.newCall(request).execute().use { raw ->
+ HttpResult(raw.code, raw.body?.string().orEmpty())
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ throw DeviceAuthException(
+ "Could not reach the SealGate gateway. Check the URL and your connection.",
+ )
+ }
+ }
+
+ private suspend fun post(url: String, jsonBody: String): HttpResult = withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url(url)
+ .header("Accept", "application/json")
+ .post(jsonBody.toRequestBody(JSON_MEDIA_TYPE))
+ .build()
+ try {
+ httpClient.newCall(request).execute().use { raw ->
+ HttpResult(raw.code, raw.body?.string().orEmpty())
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ throw DeviceAuthException(
+ "Could not reach the SealGate gateway. Check the URL and your connection.",
+ )
+ }
+ }
+
+ /** Decode a token-endpoint error and decide what to do next. Pure, so it is unit-tested. */
+ internal fun pollActionFor(statusCode: Int, body: String, intervalSeconds: Int): PollAction =
+ when (val error = parseErrorCode(body)) {
+ "authorization_pending" -> PollAction.Retry(intervalSeconds)
+ // The server widens its own interval on slow_down; mirror that locally.
+ "slow_down" -> PollAction.Retry(intervalSeconds + SLOW_DOWN_BACKOFF_SECONDS)
+ "access_denied" -> PollAction.Fail("Sign-in was denied in the dashboard.")
+ "expired_token" -> PollAction.Fail(EXPIRED_MESSAGE)
+ "invalid_scope" -> PollAction.Fail("This app requested a scope the gateway does not allow.")
+ "invalid_client" -> PollAction.Fail("The gateway does not recognise this app as a client.")
+ else -> PollAction.Fail(
+ "Sign-in failed (${error ?: "HTTP $statusCode"}). Please try again.",
+ )
+ }
+
+ private fun describeCodeError(statusCode: Int, body: String): String =
+ when (parseErrorCode(body)) {
+ "invalid_client" ->
+ "This gateway does not support mobile sign-in yet. " +
+ "Update the SealGate backend or use an API key."
+ "invalid_scope" -> "This app requested a scope the gateway does not allow."
+ else -> "Could not start sign-in (HTTP $statusCode). Check the gateway URL."
+ }
+
+ private fun parseErrorCode(body: String): String? =
+ try {
+ json.decodeFromString(body).error
+ } catch (e: Exception) {
+ null
+ }
+
+ sealed interface PollAction {
+ data class Retry(val intervalSeconds: Int) : PollAction
+ data class Fail(val message: String) : PollAction
+ }
+
+ private data class HttpResult(val code: Int, val body: String) {
+ val isSuccessful: Boolean get() = code in 200..299
+ }
+
+ companion object {
+ const val CLIENT_ID = "mobile"
+ const val SCOPE_TUNNEL_CONNECT = "tunnel:connect"
+ const val PLATFORM_ANDROID = "android"
+ const val PATH_CODE = "/api/v1/auth/device/code"
+ const val PATH_TOKEN = "/api/v1/auth/device/token"
+ const val PATH_REVOKE = "/api/v1/auth/device/revoke"
+ private const val SLOW_DOWN_BACKOFF_SECONDS = 5
+ private const val EXPIRED_MESSAGE = "The sign-in code expired. Please try again."
+ private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
+
+ val defaultJson = Json { ignoreUnknownKeys = true }
+ }
+}
+
+/** A device grant awaiting human approval; carries the PKCE verifier to redeem it. */
+data class PendingGrant(
+ val deviceCode: String,
+ val userCode: String,
+ val verificationUri: String,
+ val verificationUriComplete: String,
+ val expiresInSeconds: Int,
+ val intervalSeconds: Int,
+ val codeVerifier: String,
+)
+
+/** PKCE (RFC 7636) verifier/challenge pair generation. */
+object Pkce {
+ data class Pair(val verifier: String, val challenge: String)
+
+ fun generate(random: SecureRandom = SecureRandom()): Pair {
+ // 32 random bytes -> 43-char base64url verifier, within the 43..128 the
+ // backend accepts (device_auth.py DeviceTokenRequest.code_verifier).
+ val verifierBytes = ByteArray(32)
+ random.nextBytes(verifierBytes)
+ val verifier = base64Url(verifierBytes)
+ val digest = MessageDigest.getInstance("SHA-256")
+ .digest(verifier.toByteArray(Charsets.US_ASCII))
+ return Pair(verifier, base64Url(digest))
+ }
+
+ private fun base64Url(bytes: ByteArray): String =
+ Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
+}
+
+/** Turns a tunnel WebSocket URL into the HTTP origin its REST API is served from. */
+object GatewayUrls {
+ /**
+ * `wss://host[:port]/api/v1/stdio-tunnel/ws` -> `https://host[:port]`.
+ * Returns null when [wsUrl] is not a ws/wss URL with a host.
+ */
+ fun apiBaseFromWs(wsUrl: String): String? {
+ val trimmed = wsUrl.trim()
+ val scheme = when {
+ trimmed.startsWith("wss://", ignoreCase = true) -> "https"
+ trimmed.startsWith("ws://", ignoreCase = true) -> "http"
+ else -> return null
+ }
+ val afterScheme = trimmed.substringAfter("://", "")
+ // Authority ends at the first '/', '?' or '#'.
+ val authority = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#')
+ if (authority.isBlank() || authority.startsWith(":")) return null
+ return "$scheme://$authority"
+ }
+}
+
+@Serializable
+private data class DeviceCodeResponse(
+ @SerialName("device_code") val deviceCode: String,
+ @SerialName("user_code") val userCode: String,
+ @SerialName("verification_uri") val verificationUri: String,
+ @SerialName("verification_uri_complete") val verificationUriComplete: String,
+ @SerialName("expires_in") val expiresIn: Int,
+ val interval: Int,
+)
+
+// Only the fields the app consumes; the token endpoint also returns token_type,
+// scope, user_id, org_id and api_key (always null for mobile), ignored here.
+@Serializable
+private data class DeviceTokenResponse(
+ @SerialName("access_token") val accessToken: String,
+ @SerialName("client_installation_id") val clientInstallationId: String,
+ @SerialName("device_id") val deviceId: String,
+)
+
+@Serializable
+private data class OAuthError(val error: String? = null)
diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt
index 5be8d15..118eaa2 100644
--- a/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt
+++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/DeviceIdentityStore.kt
@@ -14,28 +14,49 @@ import java.util.UUID
*/
object DeviceIdentityStore {
- fun load(context: Context, clientVersion: String): DeviceIdentity {
+ /**
+ * @param preferredDeviceId when non-blank (an OAuth `ewd_...` id), it is
+ * authoritative: the gateway requires the tunnel's device id to match the
+ * one bound to the `ewc_` credential. It is persisted so it stays stable
+ * across restarts. When null/blank (API-key mode), a locally minted UUID
+ * is used, matching the desktop daemon's config-dir identity behaviour.
+ */
+ fun load(
+ context: Context,
+ clientVersion: String,
+ preferredDeviceId: String? = null,
+ ): DeviceIdentity {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- var deviceId = prefs.getString(KEY_DEVICE_ID, null)
- if (deviceId == null) {
- deviceId = UUID.randomUUID().toString()
+ val supplied = preferredDeviceId?.ifBlank { null }
+ val stored = prefs.getString(KEY_DEVICE_ID, null)
+ // A caller-supplied (OAuth `ewd_`) id is authoritative and already persisted
+ // by TunnelSettings, so it must NOT overwrite the locally minted UUID here -
+ // otherwise a later switch back to API-key mode would present the backend's
+ // device id as its own. Only persist an id we mint ourselves.
+ val deviceId = supplied ?: stored ?: UUID.randomUUID().toString()
+ if (supplied == null && deviceId != stored) {
prefs.edit().putString(KEY_DEVICE_ID, deviceId).apply()
}
val model = Build.MODEL ?: "Android device"
- val manufacturer = Build.MANUFACTURER ?: ""
- val label = if (manufacturer.isBlank() || model.startsWith(manufacturer, ignoreCase = true)) {
- model
- } else {
- "$manufacturer $model"
- }
return DeviceIdentity(
deviceId = deviceId,
hostname = model,
- label = label,
+ label = deviceLabel(),
clientVersion = clientVersion,
)
}
+ /** Human-readable device name (e.g. "Google Pixel 8"); no side effects. */
+ fun deviceLabel(): String {
+ val model = Build.MODEL ?: "Android device"
+ val manufacturer = Build.MANUFACTURER ?: ""
+ return if (manufacturer.isBlank() || model.startsWith(manufacturer, ignoreCase = true)) {
+ model
+ } else {
+ "$manufacturer $model"
+ }
+ }
+
private const val PREFS = "tunnel_identity"
private const val KEY_DEVICE_ID = "device_id"
}
diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt
index e72d78d..d7523fc 100644
--- a/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt
+++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt
@@ -40,6 +40,25 @@ sealed interface TunnelState {
/** `server_hello` received; the tunnel is live. */
data object Connected : TunnelState
+
+ /**
+ * Terminal: the gateway refused this credential for good, so the reconnect
+ * loop has stopped. Reconnecting with the same credential would only loop,
+ * so the UI turns this into a call to action (see [reason]).
+ */
+ data class Unauthorized(val reason: TunnelStopReason) : TunnelState
+}
+
+/** Why the gateway refused the tunnel for good, and thus what the user must do. */
+enum class TunnelStopReason {
+ /** Credential invalid, revoked, or bound to a different device: sign in again. */
+ CREDENTIAL_REJECTED,
+
+ /** The client's protocol version is outside the gateway's window: update the app. */
+ PROTOCOL_UNSUPPORTED,
+
+ /** stdio tunnel is not enabled for this org: contact an admin. */
+ ORG_NOT_ENABLED,
}
/**
@@ -121,11 +140,19 @@ class TunnelClient(
var backoffMillis = INITIAL_BACKOFF_MILLIS
while (true) {
_state.value = TunnelState.Connecting
- val sessionSawHello = runOneConnection()
+ val outcome = runOneConnection()
+ if (outcome.terminalReason != null) {
+ // The gateway rejected the credential for good. Stop reconnecting
+ // (retrying the same credential would just loop every backoff) and
+ // publish a terminal state the UI turns into a call to action.
+ Log.w(TAG, "tunnel stopped, credential no longer usable: ${outcome.terminalReason}")
+ _state.value = TunnelState.Unauthorized(outcome.terminalReason)
+ return
+ }
_state.value = TunnelState.Disconnected
// A handshake that completed earns a fresh backoff; a connection
// refused/dropped before server_hello keeps climbing toward the cap.
- backoffMillis = if (sessionSawHello) {
+ backoffMillis = if (outcome.sawServerHello) {
INITIAL_BACKOFF_MILLIS
} else {
(backoffMillis * 2).coerceAtMost(MAX_BACKOFF_MILLIS)
@@ -136,8 +163,14 @@ class TunnelClient(
}
}
- /** Runs one WebSocket session to completion. Returns true if `server_hello` arrived. */
- private suspend fun runOneConnection(): Boolean = suspendCancellableCoroutine { cont ->
+ /** The result of one WebSocket session: whether it handshook, and any terminal refusal. */
+ private data class ConnectionOutcome(
+ val sawServerHello: Boolean,
+ val terminalReason: TunnelStopReason?,
+ )
+
+ /** Runs one WebSocket session to completion. */
+ private suspend fun runOneConnection(): ConnectionOutcome = suspendCancellableCoroutine { cont ->
val sessionActive = AtomicBoolean(true)
val dispatcher = McpRequestDispatcher()
activeDispatcher.getAndSet(dispatcher)?.close()
@@ -148,8 +181,9 @@ class TunnelClient(
.build()
val listener = object : WebSocketListener() {
- // OkHttp delivers reader callbacks sequentially, so this needs no lock.
+ // OkHttp delivers reader callbacks sequentially, so these need no lock.
var sawServerHello = false
+ var terminalReason: TunnelStopReason? = null
override fun onOpen(webSocket: WebSocket, response: Response) {
if (stopped.get()) {
@@ -220,14 +254,22 @@ class TunnelClient(
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "tunnel socket failure (http=${response?.code})", t)
+ // A rejected upgrade (the gateway closes before `accept`, e.g. an
+ // invalid or revoked credential) reaches us as an HTTP status, not
+ // a WS close frame; treat 401/403 as terminal.
+ if (terminalReason == null) terminalReason = terminalReasonForFailure(response)
finish()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+ // onClosing carries the peer's close code/reason; capture it here
+ // before echoing our own close (onClosed reports the same peer code).
+ terminalReason = terminalReasonForClose(code, reason)
webSocket.close(NORMAL_CLOSURE, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ if (terminalReason == null) terminalReason = terminalReasonForClose(code, reason)
finish()
}
@@ -237,7 +279,7 @@ class TunnelClient(
activeDispatcher.compareAndSet(dispatcher, null)
this@TunnelClient.webSocket = null
modulesByServerId.clear()
- if (cont.isActive) cont.resume(sawServerHello)
+ if (cont.isActive) cont.resume(ConnectionOutcome(sawServerHello, terminalReason))
}
}
@@ -316,10 +358,42 @@ class TunnelClient(
companion object {
private const val TAG = "TunnelClient"
private const val NORMAL_CLOSURE = 1000
+ private const val POLICY_VIOLATION = 1008
private const val TRY_AGAIN_LATER = 1013
private const val INITIAL_BACKOFF_MILLIS = 1_000L
private const val MAX_BACKOFF_MILLIS = 60_000L
+ /**
+ * Classify a WS close frame. Only the gateway's own 1008 policy closes are
+ * terminal, and the reason string (a documented, stable contract - see
+ * `_authenticate_ws` and the protocol handshake in edison-watch's
+ * stdio_tunnel.py) says which. An unrecognised close (network 1006, server
+ * restart 1012, "connection replaced", ...) is transient: keep reconnecting.
+ */
+ internal fun terminalReasonForClose(code: Int, reason: String): TunnelStopReason? {
+ if (code != POLICY_VIOLATION) return null
+ val r = reason.lowercase()
+ return when {
+ "protocol_version" in r -> TunnelStopReason.PROTOCOL_UNSUPPORTED
+ "not enabled" in r -> TunnelStopReason.ORG_NOT_ENABLED
+ "revoked" in r || "identity" in r || "credential" in r ||
+ "device id" in r || "device_id" in r ->
+ TunnelStopReason.CREDENTIAL_REJECTED
+ else -> null
+ }
+ }
+
+ /**
+ * Classify a failed WS upgrade. The gateway rejects a bad/revoked
+ * credential before `accept`, which OkHttp surfaces as an HTTP status
+ * rather than a close frame; 401/403 mean the credential was refused.
+ */
+ internal fun terminalReasonForFailure(response: Response?): TunnelStopReason? =
+ when (response?.code) {
+ 401, 403 -> TunnelStopReason.CREDENTIAL_REJECTED
+ else -> null
+ }
+
private fun defaultHttpClient(): OkHttpClient = OkHttpClient.Builder()
// One WS, no request/response cycle: no read timeout, but do
// fail dead links: OkHttp pings keep NAT mappings warm and
diff --git a/app/src/main/java/ai/sealgate/stdiod/ui/NotificationTunnelArtwork.kt b/app/src/main/java/ai/sealgate/stdiod/ui/NotificationTunnelArtwork.kt
index a73b62b..ecc2780 100644
--- a/app/src/main/java/ai/sealgate/stdiod/ui/NotificationTunnelArtwork.kt
+++ b/app/src/main/java/ai/sealgate/stdiod/ui/NotificationTunnelArtwork.kt
@@ -22,7 +22,8 @@ object NotificationTunnelArtwork {
@Synchronized
fun render(context: Context, state: TunnelState, frame: Int = 0): Bitmap {
- val frameIndex = if (state == TunnelState.Disconnected) 0 else frame % FRAME_COUNT
+ val isStatic = state == TunnelState.Disconnected || state is TunnelState.Unauthorized
+ val frameIndex = if (isStatic) 0 else frame % FRAME_COUNT
val key = FrameKey(state, frameIndex)
if (cachedState != state) {
frameCache.clear()
@@ -36,13 +37,14 @@ object NotificationTunnelArtwork {
val route = when (state) {
TunnelState.Connected -> context.getColor(R.color.circuit_green)
TunnelState.Connecting -> context.getColor(R.color.signal_amber)
- TunnelState.Disconnected -> context.getColor(R.color.infra_red)
+ TunnelState.Disconnected, is TunnelState.Unauthorized ->
+ context.getColor(R.color.infra_red)
}
canvas.drawColor(context.getColor(R.color.baseline_black))
drawGrid(canvas, paint, context.getColor(R.color.grid_dark))
drawRoute(canvas, paint, route, frameIndex)
- if (state == TunnelState.Disconnected) {
+ if (isStatic) {
drawDisconnectedPlug(context, canvas, paint, route)
} else {
drawSecureLock(canvas, paint, route, context.getColor(R.color.baseline_black))
diff --git a/app/src/main/java/ai/sealgate/stdiod/ui/TunnelVisualView.kt b/app/src/main/java/ai/sealgate/stdiod/ui/TunnelVisualView.kt
index e47e99a..fb2eace 100644
--- a/app/src/main/java/ai/sealgate/stdiod/ui/TunnelVisualView.kt
+++ b/app/src/main/java/ai/sealgate/stdiod/ui/TunnelVisualView.kt
@@ -97,7 +97,7 @@ class TunnelVisualView @JvmOverloads constructor(
val color = when (state) {
TunnelState.Connected -> green
TunnelState.Connecting -> amber
- TunnelState.Disconnected, null -> red
+ TunnelState.Disconnected, is TunnelState.Unauthorized, null -> red
}
val startX = 48f * density
val endX = w - 64f * density
@@ -113,7 +113,7 @@ class TunnelVisualView @JvmOverloads constructor(
)
canvas.drawLine(startX, centerY, endX, centerY, paint)
paint.pathEffect = null
- if (state == null || state == TunnelState.Disconnected) {
+ if (state == null || state == TunnelState.Disconnected || state is TunnelState.Unauthorized) {
drawDisconnectedPlug(canvas, w / 2f, centerY, color)
} else {
drawSecureLock(canvas, w / 2f, centerY, color)
@@ -256,6 +256,7 @@ class TunnelVisualView @JvmOverloads constructor(
!isAttachedToWindow ||
state == null ||
state == TunnelState.Disconnected ||
+ state is TunnelState.Unauthorized ||
!animationsEnabled()
) return
animator = ValueAnimator.ofFloat(0f, 1f).apply {
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index fb05bc1..1594cba 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -262,6 +262,32 @@
android:textSize="14sp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0c06db4..2db6fcc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -31,12 +31,33 @@
Gateway URL
SealGate API key
Use a wss:// URL (ws:// is allowed for local development)
- Paste your API key from the dashboard
+ Sign in, or paste an API key from the dashboard
+
+ Sign in with SealGate
+ Or connect with an API key
+ Starting sign-in…
+ Approve this device
+ In the SealGate dashboard, go to
+ and enter this code:
+ Waiting for approval in the dashboard…
+ Signed in. Starting tunnel…
+ Open dashboard
+ Cancel
+ No browser found. Open %1$s manually to approve.
+ Sign-in failed
+
+ Sign out
+ Sign out?
+ This stops the tunnel, forgets this device\'s credential, and revokes it in the dashboard. You will need to sign in again to reconnect.
+ Signing out…
+ Signed out.
+ Signed out on this device. Could not reach the gateway to revoke it — revoke it from the dashboard if needed.
Diagram: this phone is ready to connect securely to the SealGate gateway.
Diagram: this phone is creating a secure connection to the SealGate gateway.
Diagram: this phone has a secure connection to the SealGate gateway.
Diagram: the secure connection between this phone and the SealGate gateway was interrupted and is reconnecting automatically.
+ Diagram: this phone is disconnected from the SealGate gateway because its credential is no longer valid; sign in again to reconnect.
Mobile Tunnel
Ongoing status of the Mobile Tunnel secure bridge.
@@ -46,4 +67,7 @@
Connected · Mobile Bash
Opening tunnel
Reconnecting
+ Sign-in required
+ Update required
+ Not enabled for your org
diff --git a/app/src/test/java/ai/sealgate/stdiod/TunnelConfigTest.kt b/app/src/test/java/ai/sealgate/stdiod/TunnelConfigTest.kt
index 188e364..4194732 100644
--- a/app/src/test/java/ai/sealgate/stdiod/TunnelConfigTest.kt
+++ b/app/src/test/java/ai/sealgate/stdiod/TunnelConfigTest.kt
@@ -1,6 +1,8 @@
package ai.sealgate.stdiod
+import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -32,4 +34,18 @@ class TunnelConfigTest {
)
assertFalse(config.isValid())
}
+
+ @Test
+ fun `device id defaults to null and does not affect validity`() {
+ val apiKey = TunnelConfig(
+ gatewayUrl = "wss://gateway.sealgate.ai/tunnel",
+ authToken = "ew_key",
+ )
+ assertNull(apiKey.deviceId)
+ assertTrue(apiKey.isValid())
+
+ val oauth = apiKey.copy(authToken = "ewc_token", deviceId = "ewd_device")
+ assertEquals("ewd_device", oauth.deviceId)
+ assertTrue(oauth.isValid())
+ }
}
diff --git a/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt b/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt
new file mode 100644
index 0000000..484bfaf
--- /dev/null
+++ b/app/src/test/java/ai/sealgate/stdiod/tunnel/DeviceAuthClientTest.kt
@@ -0,0 +1,115 @@
+package ai.sealgate.stdiod.tunnel
+
+import java.security.MessageDigest
+import java.util.Base64
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GatewayUrlsTest {
+
+ @Test
+ fun `derives https origin from wss tunnel url`() {
+ assertEquals(
+ "https://demo-dashboard.sealgate.ai",
+ GatewayUrls.apiBaseFromWs("wss://demo-dashboard.sealgate.ai/api/v1/stdio-tunnel/ws"),
+ )
+ }
+
+ @Test
+ fun `derives http origin from ws url and preserves port`() {
+ assertEquals(
+ "http://10.0.2.2:8000",
+ GatewayUrls.apiBaseFromWs("ws://10.0.2.2:8000/api/v1/stdio-tunnel/ws"),
+ )
+ }
+
+ @Test
+ fun `trims surrounding whitespace and ignores query and fragment`() {
+ assertEquals(
+ "https://host.example",
+ GatewayUrls.apiBaseFromWs(" wss://host.example/ws?x=1#frag "),
+ )
+ }
+
+ @Test
+ fun `rejects non websocket schemes and empty authority`() {
+ assertNull(GatewayUrls.apiBaseFromWs("https://host/ws"))
+ assertNull(GatewayUrls.apiBaseFromWs("host/ws"))
+ assertNull(GatewayUrls.apiBaseFromWs("wss:///ws"))
+ assertNull(GatewayUrls.apiBaseFromWs(""))
+ }
+}
+
+class PkceTest {
+
+ @Test
+ fun `verifier is 43 char base64url and challenge matches sha256`() {
+ val pair = Pkce.generate()
+ assertEquals(43, pair.verifier.length)
+ assertTrue(pair.verifier.all { it.isLetterOrDigit() || it == '-' || it == '_' })
+
+ val expected = Base64.getUrlEncoder().withoutPadding().encodeToString(
+ MessageDigest.getInstance("SHA-256").digest(pair.verifier.toByteArray(Charsets.US_ASCII)),
+ )
+ assertEquals(expected, pair.challenge)
+ // 43 chars is exactly a 32-byte base64url payload (no padding).
+ assertEquals(43, pair.challenge.length)
+ }
+
+ @Test
+ fun `successive verifiers differ`() {
+ assertTrue(Pkce.generate().verifier != Pkce.generate().verifier)
+ }
+}
+
+class DeviceAuthPollActionTest {
+
+ private val client = DeviceAuthClient(apiBaseUrl = "https://example.test")
+
+ @Test
+ fun `authorization_pending retries at the same interval`() {
+ val action = client.pollActionFor(400, """{"error":"authorization_pending"}""", 7)
+ assertEquals(DeviceAuthClient.PollAction.Retry(7), action)
+ }
+
+ @Test
+ fun `slow_down backs off the interval`() {
+ val action = client.pollActionFor(400, """{"error":"slow_down"}""", 7)
+ assertEquals(DeviceAuthClient.PollAction.Retry(12), action)
+ }
+
+ @Test
+ fun `access_denied and expired_token are terminal`() {
+ assertTrue(
+ client.pollActionFor(400, """{"error":"access_denied"}""", 5)
+ is DeviceAuthClient.PollAction.Fail,
+ )
+ assertTrue(
+ client.pollActionFor(400, """{"error":"expired_token"}""", 5)
+ is DeviceAuthClient.PollAction.Fail,
+ )
+ }
+
+ @Test
+ fun `unrecognised error body still fails cleanly`() {
+ val action = client.pollActionFor(500, "not json", 5)
+ assertTrue(action is DeviceAuthClient.PollAction.Fail)
+ }
+
+ @Test
+ fun `revocation counts 2xx and an already-invalid 401 as done`() {
+ assertTrue(client.revocationSucceeded(200))
+ assertTrue(client.revocationSucceeded(204))
+ assertTrue(client.revocationSucceeded(401))
+ }
+
+ @Test
+ fun `revocation treats other failures as unconfirmed`() {
+ assertFalse(client.revocationSucceeded(403))
+ assertFalse(client.revocationSucceeded(500))
+ assertFalse(client.revocationSucceeded(0))
+ }
+}
diff --git a/app/src/test/java/ai/sealgate/stdiod/tunnel/TunnelClientCloseClassificationTest.kt b/app/src/test/java/ai/sealgate/stdiod/tunnel/TunnelClientCloseClassificationTest.kt
new file mode 100644
index 0000000..20a4a73
--- /dev/null
+++ b/app/src/test/java/ai/sealgate/stdiod/tunnel/TunnelClientCloseClassificationTest.kt
@@ -0,0 +1,107 @@
+package ai.sealgate.stdiod.tunnel
+
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * The reconnect loop must stop only on the gateway's terminal refusals, and keep
+ * retrying everything else. These assert the classification of the close codes
+ * and reasons `edison-watch`'s stdio_tunnel.py emits.
+ */
+class TunnelClientCloseClassificationTest {
+
+ private fun classifyClose(code: Int, reason: String) =
+ TunnelClient.terminalReasonForClose(code, reason)
+
+ @Test
+ fun revokedInstallationCloseIsCredentialRejected() {
+ assertEquals(
+ TunnelStopReason.CREDENTIAL_REJECTED,
+ classifyClose(1008, "client installation revoked"),
+ )
+ }
+
+ @Test
+ fun changedIdentityCloseIsCredentialRejected() {
+ assertEquals(
+ TunnelStopReason.CREDENTIAL_REJECTED,
+ classifyClose(1008, "client identity changed"),
+ )
+ }
+
+ @Test
+ fun deviceIdMismatchCloseIsCredentialRejected() {
+ assertEquals(
+ TunnelStopReason.CREDENTIAL_REJECTED,
+ classifyClose(1008, "device id does not match client credential"),
+ )
+ assertEquals(
+ TunnelStopReason.CREDENTIAL_REJECTED,
+ classifyClose(1008, "device_id mismatch between header and client_hello"),
+ )
+ }
+
+ @Test
+ fun protocolMismatchCloseIsProtocolUnsupported() {
+ assertEquals(
+ TunnelStopReason.PROTOCOL_UNSUPPORTED,
+ classifyClose(1008, "protocol_version mismatch (client=1, server supports 2-3)"),
+ )
+ }
+
+ @Test
+ fun orgDisabledCloseIsOrgNotEnabled() {
+ assertEquals(
+ TunnelStopReason.ORG_NOT_ENABLED,
+ classifyClose(1008, "stdio_tunnel not enabled for this org"),
+ )
+ }
+
+ @Test
+ fun connectionReplacedCloseIsTransient() {
+ // Another connection took over; reconnecting is legitimate, not terminal.
+ assertNull(classifyClose(1008, "connection replaced"))
+ }
+
+ @Test
+ fun normalAndTransientCloseCodesAreNotTerminal() {
+ assertNull(classifyClose(1000, "client stopping"))
+ assertNull(classifyClose(1006, "abnormal closure"))
+ assertNull(classifyClose(1011, "server error"))
+ assertNull(classifyClose(1012, "service restart"))
+ // A 1008 with an unrecognised reason stays transient rather than bricking
+ // the tunnel on a reason string we did not anticipate.
+ assertNull(classifyClose(1008, "policy violation"))
+ }
+
+ @Test
+ fun rejectedUpgradeStatusesAreCredentialRejected() {
+ assertEquals(
+ TunnelStopReason.CREDENTIAL_REJECTED,
+ TunnelClient.terminalReasonForFailure(responseWithCode(403)),
+ )
+ assertEquals(
+ TunnelStopReason.CREDENTIAL_REJECTED,
+ TunnelClient.terminalReasonForFailure(responseWithCode(401)),
+ )
+ }
+
+ @Test
+ fun networkFailureWithoutResponseIsTransient() {
+ assertNull(TunnelClient.terminalReasonForFailure(null))
+ // A 5xx upgrade failure is the server hiccupping, not a refused credential.
+ assertNull(TunnelClient.terminalReasonForFailure(responseWithCode(503)))
+ }
+
+ private fun responseWithCode(code: Int): Response =
+ Response.Builder()
+ .request(Request.Builder().url("https://gateway.example/api/v1/stdio-tunnel/ws").build())
+ .protocol(Protocol.HTTP_1_1)
+ .code(code)
+ .message("test")
+ .build()
+}