diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index a037eb5d..ab83b31c 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -311,3 +311,10 @@ owns; reopen the terminal before judging the final live state. platform-independent test that exercises both certificate and keychain paths. - A changelog search conflated the stable and prerelease lines. Verify published package code before assuming a release contains the upstream patch. + +## 2026-09-06 — cloud Node engines + +- `package.json` engines require Node `>=22.19`. Some cloud images still expose + `/exec-daemon/node` at 22.14.0. Put `~/.nvm/versions/node/v22.22.2/bin` first + on `PATH` before `npm install` or `npm test`. + diff --git a/README.md b/README.md index a40d0fe7..c5b70797 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,11 @@ npm install npm run dev ``` -The native Aiden On The Go iPhone and iPad client lives in [`ios/`](ios/README.md). It is not part of the Electron development command; open `ios/AidenOnTheGo.xcodeproj` or use the documented physical-device `xcodebuild` commands separately. +The native Aiden On The Go iPhone and iPad client lives in [`ios/`](ios/README.md). The Android validation client lives in [`android/`](android/README.md). Neither is part of the Electron development command; open `ios/AidenOnTheGo.xcodeproj` or the Android Gradle project separately. ### Mobile distribution -The iPhone and iPad app is distributed through **TestFlight only**. GitHub releases do not publish an IPA; [`ios/README.md`](ios/README.md) documents local development and device validation. Android validation remains separate from the macOS release. Pull requests run the Android verification gates without retaining an installable artifact; relevant merges to `main` publish the debug APK and its checksum. +The iPhone and iPad app is distributed through **TestFlight only**. GitHub releases do not publish an IPA; [`ios/README.md`](ios/README.md) documents local development and device validation. Android validation remains separate from the macOS release. Pull requests run the Android verification gates without retaining an installable artifact; relevant merges to `main` publish the debug APK and its checksum. Public Android availability is planned for **October 2026**. The development launcher prepares a cached, ad-hoc-signed **Aiden Agent Dev** runtime that can run beside the installed **Aiden Agent** app. Development uses separate Application Support, Chromium session, log, crash, and `~/.aiden-dev` roots; it does not copy production data, register global shortcuts, or check the production update feed by default. Set `AIDEN_DEV_GLOBAL_SHORTCUTS=1` only when a development run intentionally needs the global bindings. @@ -122,6 +122,8 @@ The checked-in models.dev snapshot is refreshed only through `npm run models:ref Aiden Agent is a beta macOS release. Signed DMG and ZIP builds, checksums, and automatic-update metadata are published through [GitHub Releases](https://github.com/sambitcreate/aiden-agent/releases). The release workflow is fail-closed: it verifies signing, notarization, package contents, updater metadata, and version monotonicity before publishing. See [the release guide](docs/releasing.md) for the complete process. +macOS 1.0 and the Android companion are planned for **October 2026**. Until then macOS stays on the signed GitHub beta, Android stays a `main`-published debug APK for validation, and iPhone/iPad stays Internal TestFlight-only. + The canonical website download is the stable [`Aiden-Agent-Beta-arm64.dmg`](https://github.com/sambitcreate/aiden-agent/releases/latest/download/Aiden-Agent-Beta-arm64.dmg) alias from the latest public release. The historical diff --git a/android/README.md b/android/README.md new file mode 100644 index 00000000..298ebbf8 --- /dev/null +++ b/android/README.md @@ -0,0 +1,27 @@ +# Aiden On The Go (Android) + +Android is a debug-APK validation client for Aiden Agent on macOS. The Mac owns execution, persistence, providers, workspaces, and permissions. This app is a paired remote-control surface over a local network or Tailscale. + +Public Android availability is planned for **October 2026**. Until then, pull requests run the Android verification gates without retaining an installable artifact; relevant merges to `main` publish the debug APK and its checksum. Do not treat this tree as a Play Store release. + +## Current contract + +- `applicationId` `sbtbiswas.AidenOnTheGo`, `versionName` `0.1.0`, `versionCode` `1` +- `AidenAppVersion.NAME` is `BuildConfig.VERSION_NAME` (Gradle `versionName` is the source of truth) +- Release minify stays off +- Pairing `deviceType` remains `iphone` or `ipad` until the OpenAPI contract, Mac, and iOS change together +- Manual setup codes use Crockford Base32 without `I` or `L` + +Open the Gradle project in Android Studio, or from this directory run: + +```sh +./gradlew :app:testDebugUnitTest :app:lintDebug +``` + +Pairing needs a reachable Mac HTTPS endpoint such as a Tailscale Serve URL. Do not use `127.0.0.1` on the phone; that address is the Android device itself. + +The product and protocol sources of truth are: + +- [`../docs/plans/aiden-on-the-go-plan.md`](../docs/plans/aiden-on-the-go-plan.md) +- [`../docs/aiden-remote-api-v1.md`](../docs/aiden-remote-api-v1.md) +- [`../protocol/aiden-remote/v1/openapi.json`](../protocol/aiden-remote/v1/openapi.json) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e01831a0..50d9018b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,7 +29,7 @@ android { buildFeatures { compose = true aidl = false - buildConfig = false + buildConfig = true shaders = false } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..2ce08914 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Add project-specific ProGuard rules here. +# Release minify remains off until a signed Play package is prepared. diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/AidenAppVersion.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/AidenAppVersion.kt new file mode 100644 index 00000000..8eb4e8e1 --- /dev/null +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/AidenAppVersion.kt @@ -0,0 +1,5 @@ +package sbtbiswas.AidenOnTheGo + +object AidenAppVersion { + val NAME: String = BuildConfig.VERSION_NAME +} diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt index ab75319a..2103c903 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt @@ -46,7 +46,7 @@ fun AidenPairingScreen( val activeId by installationStore.activeInstallationId.collectAsState() var manualCode by remember { mutableStateOf("") } - var endpointUrl by remember { mutableStateOf("https://127.0.0.1:8765/api/aiden/v1") } + var endpointUrl by remember { mutableStateOf("") } var qrJsonInput by remember { mutableStateOf("") } var selectedTab by remember { mutableStateOf(0) } // 0: Scan QR, 1: Setup Code, 2: Paste JSON var isPairing by remember { mutableStateOf(false) } @@ -54,12 +54,13 @@ fun AidenPairingScreen( var installationPendingRemoval by remember { mutableStateOf(null) } fun formatCrockfordCode(input: String): String { - val clean = input.uppercase().replace("-", "").filter { it in "0123456789ABCDEFGHJKMNPQRSTVWXYZIL" }.take(20) + val clean = input.uppercase().replace("-", "").filter { it in "0123456789ABCDEFGHJKMNPQRSTVWXYZ" }.take(20) val chunks = clean.chunked(4) return chunks.joinToString("-") } fun handleScannedQRCode(scannedText: String) { + if (isPairing) return scope.launch { isPairing = true errorMessage = null @@ -284,12 +285,13 @@ fun AidenPairingScreen( when (selectedTab) { 0 -> { - // Live Camera QR Code Scanner - AidenQRCodeScanner( - onCodeScanned = { scanned -> - handleScannedQRCode(scanned) - } - ) + if (!isPairing) { + AidenQRCodeScanner( + onCodeScanned = { scanned -> + handleScannedQRCode(scanned) + } + ) + } if (isPairing) { Spacer(modifier = Modifier.height(12.dp)) Row( @@ -326,6 +328,7 @@ fun AidenPairingScreen( value = endpointUrl, onValueChange = { endpointUrl = it }, label = { Text("Mac Address (HTTPS Endpoint)") }, + placeholder = { Text("https://your-mac/api/aiden/v1") }, singleLine = true, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth() @@ -348,25 +351,12 @@ fun AidenPairingScreen( } } }, - enabled = manualCode.replace("-", "").length == 20 && !isPairing, + enabled = manualCode.replace("-", "").length == 20 && endpointUrl.isNotBlank() && !isPairing, colors = ButtonDefaults.buttonColors(containerColor = palette.accent), shape = RoundedCornerShape(12.dp), modifier = Modifier .fillMaxWidth() - .tactilePress { - scope.launch { - isPairing = true - errorMessage = null - try { - coordinator.pairWithManualCode(manualCode, endpointUrl) - onDismiss() - } catch (e: Exception) { - errorMessage = e.message ?: "Failed to pair with setup code" - } finally { - isPairing = false - } - } - } + .tactilePress() ) { if (isPairing) { CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp)) @@ -399,7 +389,7 @@ fun AidenPairingScreen( shape = RoundedCornerShape(12.dp), modifier = Modifier .fillMaxWidth() - .tactilePress { handleScannedQRCode(qrJsonInput) } + .tactilePress() ) { if (isPairing) { CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp)) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt index 061ae1fc..841e381e 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt @@ -17,7 +17,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,7 +34,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -42,6 +47,7 @@ import com.google.mlkit.vision.common.InputImage import sbtbiswas.AidenOnTheGo.ui.theme.AidenTheme import sbtbiswas.AidenOnTheGo.ui.theme.tactilePress import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean /** * High-fidelity CameraX and MLKit QR Code Scanner with Viewfinder Overlay. @@ -102,7 +108,7 @@ fun AidenQRCodeScanner( shape = RoundedCornerShape(12.dp), modifier = Modifier .fillMaxWidth() - .tactilePress { permissionLauncher.launch(Manifest.permission.CAMERA) } + .tactilePress() ) { Icon(Icons.Default.Videocam, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(8.dp)) @@ -131,7 +137,8 @@ private fun CameraPreview( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - var deliveredCode by remember { mutableStateOf(false) } + val closed = remember { AtomicBoolean(false) } + val delivered = remember { AtomicBoolean(false) } val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } val scanner = remember { @@ -143,6 +150,20 @@ private fun CameraPreview( val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + DisposableEffect(lifecycleOwner) { + closed.set(false) + onDispose { + closed.set(true) + cameraExecutor.shutdownNow() + scanner.close() + runCatching { + if (cameraProviderFuture.isDone) { + cameraProviderFuture.get().unbindAll() + } + } + } + } + AndroidView( factory = { ctx -> val previewView = PreviewView(ctx).apply { @@ -150,6 +171,7 @@ private fun CameraPreview( } cameraProviderFuture.addListener({ + if (closed.get()) return@addListener val cameraProvider = cameraProviderFuture.get() val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) @@ -161,7 +183,7 @@ private fun CameraPreview( imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy -> val mediaImage = imageProxy.image - if (mediaImage != null && !deliveredCode) { + if (mediaImage != null && !closed.get() && !delivered.get()) { val image = InputImage.fromMediaImage( mediaImage, imageProxy.imageInfo.rotationDegrees @@ -169,8 +191,7 @@ private fun CameraPreview( scanner.process(image) .addOnSuccessListener { barcodes -> val qr = barcodes.firstOrNull()?.rawValue - if (qr != null && !deliveredCode) { - deliveredCode = true + if (qr != null && !closed.get() && delivered.compareAndSet(false, true)) { onCodeScanned(qr) } } @@ -183,6 +204,10 @@ private fun CameraPreview( } try { + if (closed.get()) { + cameraProvider.unbindAll() + return@addListener + } cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt index 5be3b30b..13d565e7 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import sbtbiswas.AidenOnTheGo.AidenAppVersion import sbtbiswas.AidenOnTheGo.intents.AidenIntentCatalogStore import sbtbiswas.AidenOnTheGo.intents.AidenIntentInstallationRecord import sbtbiswas.AidenOnTheGo.intents.AidenIntentWorkspaceRecord @@ -303,7 +304,8 @@ class AidenRemoteCoordinator( val exchange = AidenRemoteClient.pair( payload = payload, deviceName = deviceName, - deviceType = AidenDeviceType.ANDROID_PHONE + deviceType = AidenDeviceType.ANDROID_PHONE, + clientVersion = AidenAppVersion.NAME ) val installation = installationStore.addInstallation(exchange, payload.trust) refreshClient() @@ -315,7 +317,8 @@ class AidenRemoteCoordinator( manualCode = code, endpoint = endpoint, deviceName = deviceName, - deviceType = AidenDeviceType.ANDROID_PHONE + deviceType = AidenDeviceType.ANDROID_PHONE, + clientVersion = AidenAppVersion.NAME ) val installation = installationStore.addInstallation(result.exchange, result.payload.trust) refreshClient() diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt index 8f80150b..f65fda2a 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt @@ -20,6 +20,7 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.ResponseBody +import sbtbiswas.AidenOnTheGo.AidenAppVersion import sbtbiswas.AidenOnTheGo.models.* import sbtbiswas.AidenOnTheGo.protocol.* import sbtbiswas.AidenOnTheGo.diagnostics.AidenDiagnosticArea @@ -141,7 +142,7 @@ class AidenRemoteClient( payload: AidenPairingPayload, deviceName: String, deviceType: AidenDeviceType, - clientVersion: String = "0.1.0", + clientVersion: String = AidenAppVersion.NAME, acceptsBotCapabilities: Boolean = true, customOkHttpClient: OkHttpClient? = null ): AidenPairingExchange = withContext(Dispatchers.IO) { @@ -222,7 +223,7 @@ class AidenRemoteClient( endpoint: String, deviceName: String, deviceType: AidenDeviceType, - clientVersion: String = "0.1.0", + clientVersion: String = AidenAppVersion.NAME, acceptsBotCapabilities: Boolean = true, customOkHttpClient: OkHttpClient? = null ): PairResult = withContext(Dispatchers.IO) { diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenMotion.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenMotion.kt index b06ee234..802c0d6b 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenMotion.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenMotion.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import kotlin.math.pow @@ -68,12 +69,19 @@ fun Modifier.tactilePress( this .scale(scale) .pointerInput(onClick) { - awaitEachGesture { - awaitFirstDown().also { isPressed = true } - val up = waitForUpOrCancellation() - isPressed = false - if (up != null && onClick != null) { - onClick() + if (onClick == null) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Final) + isPressed = event.changes.any { it.pressed } + } + } + } else { + awaitEachGesture { + awaitFirstDown().also { isPressed = true } + val up = waitForUpOrCancellation() + isPressed = false + if (up != null) onClick() } } } diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenAppVersionTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenAppVersionTest.kt new file mode 100644 index 00000000..d694a8f0 --- /dev/null +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenAppVersionTest.kt @@ -0,0 +1,11 @@ +package sbtbiswas.AidenOnTheGo + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AidenAppVersionTest { + @Test + fun testClientVersionMatchesGradleVersionName() { + assertEquals(BuildConfig.VERSION_NAME, AidenAppVersion.NAME) + } +} diff --git a/docs/aiden-on-the-go-remote-access.md b/docs/aiden-on-the-go-remote-access.md index 958dd183..9ad894af 100644 --- a/docs/aiden-on-the-go-remote-access.md +++ b/docs/aiden-on-the-go-remote-access.md @@ -1,6 +1,8 @@ # Aiden On The Go remote access -Aiden Agent can expose a small authenticated API to Aiden On The Go on iPhone and iPad. Remote Access is off by default. Aiden must remain running on the Mac, although its window may be closed. +Aiden Agent can expose a small authenticated API to Aiden On The Go on iPhone, iPad, and Android. Remote Access is off by default. Aiden must remain running on the Mac, although its window may be closed. + +iPhone and iPad remain Internal TestFlight-only. Android is a debug-APK validation client; public Android availability is planned for October 2026. ## Local Network setup diff --git a/docs/plans/README.md b/docs/plans/README.md index 5f00da2d..6e94fa6b 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -7,7 +7,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | Plan | Status | Current state | | -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | -| [Aiden On The Go](aiden-on-the-go-plan.md) | Active | Version 0.1.0 build 22 is `VALID` and `IN_BETA_TESTING` for Internal Testers. Android matches iOS's app-icon switcher, Workspace hierarchy, warm scoped Bots/Usage/SSE lifecycle, Usage dashboard, image showcase/gallery, keyboard-safe elevated composer, and split Photo/File pickers. Both clients support native in-process dictation or bounded no-retention transcription by the paired Mac's local Parakeet model. iOS also ships progressive onboarding, bidirectional media, reliable mobile approvals, typed activity timelines, semantic haptics, and one-chat-per-Bot conversations with companion vision for text-only models. Physical iPad/manual permission-system-UI acceptance, privacy publication, final store assets, and external/public-release decisions remain open. | +| [Aiden On The Go](aiden-on-the-go-plan.md) | Active | Version 0.1.0 build 22 is `VALID` and `IN_BETA_TESTING` for Internal Testers. Android matches iOS's app-icon switcher, Workspace hierarchy, warm scoped Bots/Usage/SSE lifecycle, Usage dashboard, image showcase/gallery, keyboard-safe elevated composer, and split Photo/File pickers. Both clients support native in-process dictation or bounded no-retention transcription by the paired Mac's local Parakeet model. iOS also ships progressive onboarding, bidirectional media, reliable mobile approvals, typed activity timelines, semantic haptics, and one-chat-per-Bot conversations with companion vision for text-only models. Physical iPad/manual permission-system-UI acceptance, privacy publication, and final store assets remain open. **macOS 1.0 and Android public availability are planned for October 2026**; iPhone/iPad stays TestFlight-only until a separate App Review decision. | | [Unified Workspace Sidebar](unified-workspace-sidebar-plan.md) | Active | Phases 1 and 2 ship the unified workspace/chat outline plus a feature-negotiated, transcript-free paginated summary read on Electron, iOS/iPadOS, and Android; physical-device performance acceptance remains open. | | [Bot-First Aiden On The Go](bot-first-aiden-on-the-go-plan.md) | Active | Phases 0–9 are implemented. Every Bot has one persistent chat and one contact row; Favorites are a pinned placement, Bot chat reuses the shared runtime with Messages-inspired identity/bubbles and Aiden's existing composer, and New/Edit Bot exclusively own its durable model. Remote open-or-create, immediate exact-cache chat entry, optimistic favorites, shaped skeleton loading, stable photos, atomic desktop creation, fresh-inventory save retries, conflict-safe Mac/iOS draft rebasing, final-only Bot replies with expandable progress, native-or-companion image handling, and internal TestFlight build 22 are green. Eligible Apple Intelligence hardware, physical iPad, multi-device/Mac, packaged rollback, live Telegram, wider staged TestFlight, Xcode 27, accessibility, and App Store owner gates remain open. | | [Aiden Manual Pairing](aiden-manual-pairing-plan.md) | Implemented | The reviewed 100-bit setup-code path, shared one-use QR window, staged iOS activation, and adversarial coverage ship; hands-on LAN/Tailscale UI and physical-iPad acceptance remain open. | diff --git a/docs/plans/aiden-on-the-go-plan.md b/docs/plans/aiden-on-the-go-plan.md index c758c21c..7abf572f 100644 --- a/docs/plans/aiden-on-the-go-plan.md +++ b/docs/plans/aiden-on-the-go-plan.md @@ -1,6 +1,6 @@ # Aiden On The Go Plan -Status: Active foundation — Phases 0–4, 7, 9, 10, and 11 are complete; Phases 5/6/8 are implemented, LAN and real Tailscale are proven on a physical iPhone, and version 0.1.0 build 21 is `VALID` and `IN_BETA_TESTING` for Internal Testers with final-only Bot replies and expandable intermediate activity; physical-iPad and external/public-release acceptance remain open. The approved bot-first extension is now governed by `bot-first-aiden-on-the-go-plan.md`. +Status: Active foundation — Phases 0–4, 7, 9, 10, and 11 are complete; Phases 5/6/8 are implemented, LAN and real Tailscale are proven on a physical iPhone, and version 0.1.0 build 22 is `VALID` and `IN_BETA_TESTING` for Internal Testers with final-only Bot replies and expandable intermediate activity. Physical-iPad acceptance remains open. **macOS 1.0 and Android public availability are planned for October 2026**; iPhone/iPad stays TestFlight-only until a separate App Review decision. The approved bot-first extension is now governed by `bot-first-aiden-on-the-go-plan.md`. Date: 2026-08-18 Owners: Aiden Electron main process, the SwiftUI app under `ios/`, and the Jetpack Compose app under `android/` diff --git a/docs/public-readiness.md b/docs/public-readiness.md index 0f965276..5bd1d55a 100644 --- a/docs/public-readiness.md +++ b/docs/public-readiness.md @@ -4,20 +4,20 @@ This checklist tracks the remaining owner decisions and GitHub settings required ## Current GitHub state -As audited on 2026-07-22, the source repository is private and has no GitHub topics. The current tree has no root `LICENSE`, `SECURITY.md`, or `CONTRIBUTING.md`. The `private: true` package flag is intentional protection against accidental npm publication; it does not control GitHub repository visibility. +As audited on 2026-09-06, the source repository is private and has no GitHub topics. The tree includes a root MIT `LICENSE` (`Copyright (c) 2026 Sambit Biswas`). `SECURITY.md` and `CONTRIBUTING.md` are still absent at the repository root; iOS-only copies live under `ios/` and do not substitute for project-wide contribution or vulnerability policy. The `private: true` package flag is intentional protection against accidental npm publication; it does not control GitHub repository visibility. ## Ready in the repository - Public-facing README with a real product screenshot, concise feature overview, privacy boundary, setup, verification, and release links. - Consistent Aiden Agent name and canonical repository URL in the primary metadata. - Package metadata that identifies the project without enabling accidental npm publication. +- Root MIT `LICENSE`. - Public documentation uses repository links instead of developer-specific absolute checkout paths. Remaining `/Users/...` strings are synthetic path-sanitization and environment fixtures in tests. - No tracked credential files or obvious private keys found in the documentation and metadata audit. ## Owner decisions before changing visibility -- **Choose and add a source license.** No public license is currently granted. Do not label the project open source or accept outside contributions until the intended terms are explicit. -- **Confirm the public source model.** Aiden will publish its signed beta assets directly in this repository's GitHub Releases. Its DMG download and auto-update feed are therefore public only after repository visibility changes. +- **Confirm the public source model.** Aiden will publish its signed beta assets directly in this repository's GitHub Releases. Its DMG download and auto-update feed are therefore public only after repository visibility changes. macOS 1.0 and Android public availability are planned for October 2026; iPhone/iPad remains TestFlight-only until a separate App Review decision. - **Define contribution expectations.** Add `CONTRIBUTING.md`, `SECURITY.md`, and a code of conduct only if outside issues or contributions will be accepted. Avoid empty policy templates. - **Review repository history.** Run a full-history secret scan before changing visibility; checking only the current tree cannot rule out credentials in older commits. - **Configure GitHub protections.** Require CI on `main`, restrict release-environment deployment to trusted branches, protect release tags, and keep signing/notarization secrets scoped to the protected `release` environment. diff --git a/docs/releasing.md b/docs/releasing.md index f196bcf6..f1b3ebe0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -139,6 +139,12 @@ pre-release updates. The release notes visibly identify the Beta channel. Never place an Apple private key, certificate password, or notarization credential in `package.json`, workflow logs, or an application resource. +## October 2026 public cutover + +macOS 1.0 and public Android availability are planned for October 2026. Until that cutover, keep the present-tense **Beta** release process in this document: publish `Aiden-Agent-Beta-arm64.dmg`, keep `package.json` `"title": "Aiden Agent Beta"`, and leave the website and Homebrew consumers on the Beta alias. + +Rename Beta artifacts only at GA, and only after the `sambitcreate/aiden-website` and `sambitcreate/homebrew-tap` consumers are updated and deployed first. iPhone and iPad stay Internal TestFlight-only; App Store GA is not part of the October 2026 Mac/Android window. + ## Version-line changes For every planned release, change the complete version in `package.json` and `package-lock.json` diff --git a/ios/README.md b/ios/README.md index 7b578224..f96493f8 100644 --- a/ios/README.md +++ b/ios/README.md @@ -2,6 +2,8 @@ Aiden On The Go is the native SwiftUI companion for Aiden Agent on macOS. The Mac owns execution, persistence, providers, workspaces, and permissions; iPhone and iPad provide an authenticated remote control surface over a local network or Tailscale. +iPhone and iPad stay **Internal TestFlight-only**. App Store GA is not part of the October 2026 macOS 1.0 and Android public window. + The product and protocol sources of truth are: - [`PROJECT_SPEC.md`](PROJECT_SPEC.md) diff --git a/main/handlers/aiden-remote.test.ts b/main/handlers/aiden-remote.test.ts index dea69e4a..e3dce030 100644 --- a/main/handlers/aiden-remote.test.ts +++ b/main/handlers/aiden-remote.test.ts @@ -52,3 +52,29 @@ test("saved endpoint repair is an explicit IPC action", async () => { assert.match(source, /ipcMain\.handle\("remote:moveToAvailablePort"/u); assert.match(source, /service\.moveToAvailablePort\(\)/u); }); + +test("pairing and Tailscale mutations return structured results instead of rejecting IPC", async () => { + const source = await readFile(new URL("./aiden-remote.ts", import.meta.url), "utf8"); + const ipc = await readFile(new URL("../../renderer/lib/ipc.ts", import.meta.url), "utf8"); + for (const channel of [ + "remote:beginPairing", + "remote:tailscaleConnect", + "remote:tailscaleDisconnect", + "remote:tailscaleReconcile", + "remote:tailscaleReviewTakeover", + "remote:tailscaleTakeOver", + ]) { + const start = source.indexOf(`ipcMain.handle("${channel}"`); + assert.ok(start >= 0, channel); + const slice = source.slice(start, start + 420); + assert.match(slice, /remoteDesktopResult\(/u); + assert.match(ipc, new RegExp(String.raw`"${channel}"[\s\S]{0,180}?unwrapAidenRemoteDesktopResult`, "u")); + } + assert.doesNotMatch(source, /add your handlers/u); +}); + +test("home-folder approval uses a typed confirmation error", async () => { + const source = await readFile(new URL("./aiden-remote.ts", import.meta.url), "utf8"); + assert.match(source, /AidenRemoteHomeDirectoryConfirmationRequiredError/u); + assert.doesNotMatch(source, /error\.message\.includes\("entire home directory"\)/u); +}); diff --git a/main/handlers/aiden-remote.ts b/main/handlers/aiden-remote.ts index 938a46f3..ebc731da 100644 --- a/main/handlers/aiden-remote.ts +++ b/main/handlers/aiden-remote.ts @@ -1,6 +1,8 @@ import { BrowserWindow, dialog, ipcMain } from "../platform.js"; import { getAidenRemoteRuntime } from "../services/aiden-remote-service-main.js"; import type { AidenRemoteSettingsSnapshot } from "../../renderer/shared/aiden-remote.js"; +import { remoteDesktopResult } from "../services/aiden-remote-desktop-errors.js"; +import { AidenRemoteHomeDirectoryConfirmationRequiredError } from "../services/aiden-remote-approved-roots.js"; import { rendererDocumentOwner } from "../services/renderer-document-owner.js"; import { parseAidenRemoteConnectionMode, @@ -104,42 +106,53 @@ export function registerAidenRemoteHandlers(): void { }); ipcMain.handle("remote:tailscaleConnect", async () => { - await (await getAidenRemoteRuntime()).service.connectTailscale(); - return settingsSnapshot(); + return remoteDesktopResult(async () => { + await (await getAidenRemoteRuntime()).service.connectTailscale(); + return settingsSnapshot(); + }); }); ipcMain.handle("remote:tailscaleDisconnect", async () => { - await (await getAidenRemoteRuntime()).service.disconnectTailscale(); - return settingsSnapshot(); + return remoteDesktopResult(async () => { + await (await getAidenRemoteRuntime()).service.disconnectTailscale(); + return settingsSnapshot(); + }); }); ipcMain.handle("remote:tailscaleReconcile", async () => { - await (await getAidenRemoteRuntime()).service.reconcileTailscale(); - return settingsSnapshot(); + return remoteDesktopResult(async () => { + await (await getAidenRemoteRuntime()).service.reconcileTailscale(); + return settingsSnapshot(); + }); }); ipcMain.handle("remote:tailscaleReviewTakeover", async () => { - return (await getAidenRemoteRuntime()).service.reviewTailscaleTakeover(); + return remoteDesktopResult(async () => { + return (await getAidenRemoteRuntime()).service.reviewTailscaleTakeover(); + }); }); ipcMain.handle("remote:tailscaleTakeOver", async (_event, token: unknown) => { - await (await getAidenRemoteRuntime()).service.takeOverTailscale( - parseAidenRemoteTakeoverToken(token), - ); - return settingsSnapshot(); + const takeoverToken = parseAidenRemoteTakeoverToken(token); + return remoteDesktopResult(async () => { + await (await getAidenRemoteRuntime()).service.takeOverTailscale(takeoverToken); + return settingsSnapshot(); + }); }); ipcMain.handle("remote:beginPairing", async (_event, transport: unknown) => { const selectedTransport = parseAidenRemoteTransport(transport); - const service = (await getAidenRemoteRuntime()).service; - const pairing = await service.beginPairing(selectedTransport); - return { - ...pairing.bootstrap, - pairingSessionId: pairing.sessionId, - qrPayload: pairing.qrPayload - ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport), - manualCode: pairing.manualCode, - }; + return remoteDesktopResult(async () => { + const service = (await getAidenRemoteRuntime()).service; + const pairing = await service.beginPairing(selectedTransport); + return { + ...pairing.bootstrap, + pairingSessionId: pairing.sessionId, + qrPayload: pairing.qrPayload + ?? service.pairingQrPayload(pairing.bootstrap, selectedTransport), + manualCode: pairing.manualCode, + }; + }); }); ipcMain.handle("remote:closePairing", async (_event, sessionId: unknown) => { @@ -175,7 +188,7 @@ export function registerAidenRemoteHandlers(): void { try { await runtime.approvedRoots.addLocalFolder(selectedPath); } catch (error) { - if (!(error instanceof Error) || !error.message.includes("entire home directory")) throw error; + if (!(error instanceof AidenRemoteHomeDirectoryConfirmationRequiredError)) throw error; const warning = parent ? await dialog.showMessageBox(parent, { type: "warning", diff --git a/main/handlers/app.ts b/main/handlers/app.ts index 73d0d61f..c38ca57c 100644 --- a/main/handlers/app.ts +++ b/main/handlers/app.ts @@ -1,28 +1,11 @@ /** - * App Handlers - Application-level IPC methods - * - * This is where you add your app-specific backend logic - * - * Register handlers using the ipcMain API: - * - * @example - * ```typescript - * import { ipcMain } from '../platform.js'; - * - * ipcMain.handle('app:myMethod', async (event, arg1, arg2) => { - * // Your logic here - * return { result: 'success' }; - * }); - * ``` + * Application-level IPC methods owned by the desktop shell. */ - import { app, logger } from "../platform.js"; import { currentRuntimeProfile } from "../runtime-profile.js"; import { subagentsEnabled } from "../services/subagents/feature-flag.js"; -// App handlers - these are the methods your app provides to the frontend export const appHandlers = { - // Example: Get app information getInfo: async () => { logger.info("app", "App info requested"); return { @@ -34,10 +17,4 @@ export const appHandlers = { }, }; }, - - // TODO: Add your app handlers here - // Example: - // myMethod: async (params: { arg1: string }) => { - // return { result: 'success' }; - // } }; diff --git a/main/handlers/index.ts b/main/handlers/index.ts index 8f593751..2e4d70e5 100644 --- a/main/handlers/index.ts +++ b/main/handlers/index.ts @@ -1,9 +1,6 @@ /** - * Handler Registration - * - * Register all your IPC handlers here + * Register every desktop IPC handler used by the renderer. */ - import { appHandlers } from "./app.js"; import { registerProviderHandlers } from "./providers.js"; import { registerChatHistoryHandlers } from "./chats.js"; @@ -35,7 +32,6 @@ import { ipcMain, logger } from "../platform.js"; export function registerHandlers(): void { logger.info("handlers", "Registering IPC handlers..."); - // Register app handlers using ipcMain API ipcMain.handle("app:getInfo", async (_event) => { return await appHandlers.getInfo(); }); @@ -43,7 +39,6 @@ export function registerHandlers(): void { registerDiagnosticHandlers(); initializeAdvisorRuntime(); - // AI chat client handlers registerProviderHandlers(); registerChatHistoryHandlers(); registerChatGenerationHandlers(); @@ -67,12 +62,5 @@ export function registerHandlers(): void { registerBotHandlers(); registerBtwHandlers(); - logger.info("handlers", "✓ IPC handlers registered"); - - // TODO: Add more handlers here using ipcMain.handle() - // Example: - // ipcMain.handle('file:read', async (event, path) => { - // const fs = await import('fs/promises'); - // return await fs.readFile(path, 'utf-8'); - // }); + logger.info("handlers", "IPC handlers registered"); } diff --git a/main/index.ts b/main/index.ts index 3bd2abba..ece0c760 100644 --- a/main/index.ts +++ b/main/index.ts @@ -129,8 +129,10 @@ import { initializeBotApplicationService } from "./services/bot-application-serv import { botSkillContentWatcher } from "./services/bot-capability-services-main.js"; import { geminiLiveTranscription } from "./services/gemini-live-transcription.js"; import { mainWindowState } from "./services/main-window-state.js"; +import { applyLinuxWaylandChromiumFlags } from "./linux-chromium-flags.js"; registerGenerativeUiScheme(); +applyLinuxWaylandChromiumFlags(app.commandLine); const ownsSingleInstanceLock = app.requestSingleInstanceLock(); diff --git a/main/linux-chromium-flags.test.ts b/main/linux-chromium-flags.test.ts new file mode 100644 index 00000000..600d8a36 --- /dev/null +++ b/main/linux-chromium-flags.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyLinuxWaylandChromiumFlags, linuxWaylandVulkanDisableFeatures } from "./linux-chromium-flags.js"; + +test("Vulkan is disabled only for Linux Wayland sessions", () => { + assert.equal( + linuxWaylandVulkanDisableFeatures({ + platform: "darwin", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }, + }), + null, + ); + assert.equal( + linuxWaylandVulkanDisableFeatures({ + platform: "linux", + env: { XDG_SESSION_TYPE: "x11" }, + }), + null, + ); + assert.equal( + linuxWaylandVulkanDisableFeatures({ + platform: "linux", + env: { XDG_SESSION_TYPE: "wayland" }, + }), + "Vulkan", + ); + assert.equal( + linuxWaylandVulkanDisableFeatures({ + platform: "linux", + env: { WAYLAND_DISPLAY: "wayland-0" }, + existingDisableFeatures: "UseChromeOSDirectVideoDecoder", + }), + "UseChromeOSDirectVideoDecoder,Vulkan", + ); +}); + +test("Wayland Chromium flags merge Vulkan into disable-features", () => { + const switches: Array<{ name: string; value?: string }> = []; + const applied = applyLinuxWaylandChromiumFlags( + { + appendSwitch: (name, value) => { + switches.push({ name, value }); + }, + getSwitchValue: () => "", + }, + { XDG_SESSION_TYPE: "wayland" }, + "linux", + ); + assert.equal(applied, true); + assert.deepEqual(switches, [{ name: "disable-features", value: "Vulkan" }]); +}); diff --git a/main/linux-chromium-flags.ts b/main/linux-chromium-flags.ts new file mode 100644 index 00000000..102d72c6 --- /dev/null +++ b/main/linux-chromium-flags.ts @@ -0,0 +1,40 @@ +export interface ChromiumCommandLine { + appendSwitch: (name: string, value?: string) => void; + getSwitchValue?: (name: string) => string; +} + +export function linuxWaylandVulkanDisableFeatures(input: { + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + existingDisableFeatures?: string; +}): string | null { + if (input.platform !== "linux") return null; + const session = (input.env.XDG_SESSION_TYPE ?? "").trim().toLowerCase(); + const waylandDisplay = input.env.WAYLAND_DISPLAY?.trim(); + const ozone = (input.env.ELECTRON_OZONE_PLATFORM ?? "").trim().toLowerCase(); + const wayland = session === "wayland" || Boolean(waylandDisplay) || ozone === "wayland"; + if (!wayland) return null; + const existing = (input.existingDisableFeatures ?? "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + if (!existing.some((feature) => feature.toLowerCase() === "vulkan")) { + existing.push("Vulkan"); + } + return existing.join(","); +} + +export function applyLinuxWaylandChromiumFlags( + commandLine: ChromiumCommandLine, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): boolean { + const features = linuxWaylandVulkanDisableFeatures({ + platform, + env, + existingDisableFeatures: commandLine.getSwitchValue?.("disable-features") ?? "", + }); + if (!features) return false; + commandLine.appendSwitch("disable-features", features); + return true; +} diff --git a/main/runtime-profile-bootstrap.test.ts b/main/runtime-profile-bootstrap.test.ts index d39188a7..234d308e 100644 --- a/main/runtime-profile-bootstrap.test.ts +++ b/main/runtime-profile-bootstrap.test.ts @@ -11,6 +11,9 @@ test("runtime identity is configured before the main module can take its lock", assert.ok(configure >= 0 && loadMain > configure); assert.match(main, /app\.requestSingleInstanceLock\(\)/u); assert.doesNotMatch(main, /app\.setName\(/u); + const waylandFlags = main.indexOf("applyLinuxWaylandChromiumFlags(app.commandLine)"); + const lock = main.indexOf("app.requestSingleInstanceLock()"); + assert.ok(waylandFlags >= 0 && lock > waylandFlags); }); test("the Electron build enters through the profile bootstrap", () => { diff --git a/main/services/aiden-remote-approved-roots.test.ts b/main/services/aiden-remote-approved-roots.test.ts index de54f436..c261424f 100644 --- a/main/services/aiden-remote-approved-roots.test.ts +++ b/main/services/aiden-remote-approved-roots.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { AidenRemoteApprovedRootService } from "./aiden-remote-approved-roots.js"; +import { AidenRemoteApprovedRootService, AidenRemoteHomeDirectoryConfirmationRequiredError } from "./aiden-remote-approved-roots.js"; import { AidenRemoteStateRegistry, createDefaultAidenRemoteState } from "./aiden-remote-state.js"; async function fixture() { @@ -62,7 +62,7 @@ test("approving an entire home folder requires a separate local confirmation", a try { await assert.rejects( app.service.addLocalFolder(app.directory), - /requires local confirmation/u, + (error: unknown) => error instanceof AidenRemoteHomeDirectoryConfirmationRequiredError, ); const root = await app.service.addLocalFolder(app.directory, { confirmHomeDirectory: true }); assert.equal(root.folderPath, await fs.realpath(app.directory)); diff --git a/main/services/aiden-remote-approved-roots.ts b/main/services/aiden-remote-approved-roots.ts index 193b63f9..ecb80a9d 100644 --- a/main/services/aiden-remote-approved-roots.ts +++ b/main/services/aiden-remote-approved-roots.ts @@ -9,6 +9,13 @@ import type { export const AIDEN_REMOTE_ROOT_POLICY_REVISION = "remote-browser-v1:no-hidden-system"; +export class AidenRemoteHomeDirectoryConfirmationRequiredError extends Error { + constructor() { + super("Approving the entire home directory requires local confirmation."); + this.name = "AidenRemoteHomeDirectoryConfirmationRequiredError"; + } +} + export interface AidenRemoteApprovedRootDependencies { now(): number; randomBytes(size: number): Buffer; @@ -49,7 +56,7 @@ export class AidenRemoteApprovedRootService { } const canonicalHome = await fs.realpath(this.dependencies.homeDirectory()); if (canonicalPath === canonicalHome && options.confirmHomeDirectory !== true) { - throw new Error("Approving the entire home directory requires local confirmation."); + throw new AidenRemoteHomeDirectoryConfirmationRequiredError(); } const existing = (await this.state.snapshot()).approvedRoots; diff --git a/main/services/aiden-remote-desktop-errors.test.ts b/main/services/aiden-remote-desktop-errors.test.ts new file mode 100644 index 00000000..e7144b2f --- /dev/null +++ b/main/services/aiden-remote-desktop-errors.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + classifyAidenRemoteDesktopFailure, + isAidenRemoteTlsTimeoutError, + isTailscalePermissionDeniedError, + remoteDesktopResult, +} from "./aiden-remote-desktop-errors.js"; +import { unwrapAidenRemoteDesktopResult } from "../../renderer/shared/aiden-remote.js"; + +test("TLS timeout errors map to a stable desktop pairing code", () => { + const timeout = new Error("Aiden Remote TLS endpoint timed out."); + timeout.name = "AidenRemoteTlsTimeoutError"; + assert.equal(isAidenRemoteTlsTimeoutError(timeout), true); + const classified = classifyAidenRemoteDesktopFailure(timeout); + assert.equal(classified.code, "tls_endpoint_timeout"); + assert.match(classified.message, /couldn't reach this Mac's Tailscale HTTPS endpoint in time/u); +}); + +test("Tailscale operator denials map to a stable permission code", () => { + const denied = Object.assign(new Error("Command failed: tailscale serve"), { + stderr: "Access denied: failed to connect to local tailscaled; try running `sudo tailscale set --operator=$USER`", + code: 1, + }); + assert.equal(isTailscalePermissionDeniedError(denied), true); + const classified = classifyAidenRemoteDesktopFailure(denied); + assert.equal(classified.code, "tailscale_permission_denied"); + assert.match(classified.message, /sudo tailscale set --operator=\$USER/u); +}); + +test("existing Tailscale route codes stay exact for renderer mapping", () => { + const classified = classifyAidenRemoteDesktopFailure(new Error("tailscale_https_unavailable")); + assert.equal(classified.code, "tailscale_https_unavailable"); + assert.notEqual(classified.message, "tailscale_https_unavailable"); + assert.match(classified.message, /couldn't safely update the Tailscale route/u); +}); + +test("remoteDesktopResult never rejects operational failures", async () => { + const failure = await remoteDesktopResult(async () => { + throw new Error("Aiden Remote TLS endpoint timed out."); + }); + assert.equal(failure.ok, false); + if (failure.ok) throw new Error("expected failure"); + assert.equal(failure.code, "tls_endpoint_timeout"); + assert.throws( + () => unwrapAidenRemoteDesktopResult(failure), + (error: unknown) => error instanceof Error && error.name === "AidenRemoteDesktopError", + ); + + const success = await remoteDesktopResult(async () => ({ connected: true })); + assert.deepEqual(unwrapAidenRemoteDesktopResult(success), { connected: true }); +}); diff --git a/main/services/aiden-remote-desktop-errors.ts b/main/services/aiden-remote-desktop-errors.ts new file mode 100644 index 00000000..931f476a --- /dev/null +++ b/main/services/aiden-remote-desktop-errors.ts @@ -0,0 +1,121 @@ +import type { AidenRemoteDesktopErrorCode, AidenRemoteDesktopResult } from "../../renderer/shared/aiden-remote.js"; + +const TLS_TIMEOUT_MESSAGE = "Aiden Remote TLS endpoint timed out."; + +const USER_FACING_MESSAGES = { + tls_endpoint_timeout: + "Aiden couldn't reach this Mac's Tailscale HTTPS endpoint in time. Check the Serve route, then try again.", + tls_endpoint_unreachable: + "Aiden couldn't reach this Mac's Tailscale HTTPS endpoint. Check that Tailscale is running, then try again.", + tls_invalid_certificate: "Aiden couldn't verify this Mac's Tailscale HTTPS certificate.", + tailscale_permission_denied: + "Aiden needs Tailscale operator permission. Run sudo tailscale set --operator=$USER, then try again.", + tailscale_operation_failed: "Aiden couldn't safely update the Tailscale route.", + pairing_failed: "Aiden couldn't open pairing.", +} as const; + +function errorText(error: unknown): string { + if (!(error instanceof Error)) return String(error ?? ""); + const extra = error as Error & { stderr?: unknown; stdout?: unknown }; + return [error.message, extra.stderr, extra.stdout] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join("\n"); +} + +export function isAidenRemoteTlsTimeoutError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return error.name === "AidenRemoteTlsTimeoutError" || error.message === TLS_TIMEOUT_MESSAGE; +} + +export function isTailscalePermissionDeniedError(error: unknown): boolean { + const text = errorText(error); + if (/tailscale_permission_denied/u.test(text)) return true; + const extra = error !== null && typeof error === "object" + ? error as { code?: unknown } + : undefined; + if (extra?.code === "EACCES") return true; + if (/EACCES/u.test(text)) return true; + if (/permission denied/iu.test(text)) return true; + if (/access denied/iu.test(text)) return true; + return false; +} + +function isTlsUnreachableError(error: unknown): boolean { + const extra = error !== null && typeof error === "object" + ? error as { code?: unknown } + : undefined; + if ( + extra?.code === "ECONNREFUSED" + || extra?.code === "ENOTFOUND" + || extra?.code === "EHOSTUNREACH" + || extra?.code === "ENETUNREACH" + || extra?.code === "ECONNRESET" + || extra?.code === "ETIMEDOUT" + ) { + return true; + } + return /ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ECONNRESET|ETIMEDOUT/u.test(errorText(error)); +} + +function isTlsCertificateError(error: unknown): boolean { + const extra = error !== null && typeof error === "object" + ? error as { code?: unknown } + : undefined; + if ( + extra?.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" + || extra?.code === "CERT_HAS_EXPIRED" + || extra?.code === "ERR_TLS_CERT_ALTNAME_INVALID" + || extra?.code === "DEPTH_ZERO_SELF_SIGNED_CERT" + ) { + return true; + } + return /certificate|self[- ]signed|unable to verify/iu.test(errorText(error)); +} + +export function classifyAidenRemoteDesktopFailure(error: unknown): { + code: AidenRemoteDesktopErrorCode | `tailscale_${string}`; + message: string; +} { + const thrown = error instanceof Error ? error.message.trim() : ""; + if (/^tailscale_[a-z0-9_]+$/u.test(thrown)) { + if (thrown === "tailscale_permission_denied") { + return { + code: "tailscale_permission_denied", + message: USER_FACING_MESSAGES.tailscale_permission_denied, + }; + } + return { + code: thrown as `tailscale_${string}`, + message: USER_FACING_MESSAGES.tailscale_operation_failed, + }; + } + if (isAidenRemoteTlsTimeoutError(error)) { + return { code: "tls_endpoint_timeout", message: USER_FACING_MESSAGES.tls_endpoint_timeout }; + } + if (isTlsUnreachableError(error)) { + return { code: "tls_endpoint_unreachable", message: USER_FACING_MESSAGES.tls_endpoint_unreachable }; + } + if (isTlsCertificateError(error)) { + return { code: "tls_invalid_certificate", message: USER_FACING_MESSAGES.tls_invalid_certificate }; + } + if (isTailscalePermissionDeniedError(error)) { + return { + code: "tailscale_permission_denied", + message: USER_FACING_MESSAGES.tailscale_permission_denied, + }; + } + if (thrown.startsWith("Aiden Remote TLS") || thrown.includes("Tailscale")) { + return { code: "pairing_failed", message: USER_FACING_MESSAGES.pairing_failed }; + } + return { code: "tailscale_operation_failed", message: USER_FACING_MESSAGES.tailscale_operation_failed }; +} + +export async function remoteDesktopResult( + action: () => Promise, +): Promise> { + try { + return { ok: true, value: await action() }; + } catch (error) { + return { ok: false, ...classifyAidenRemoteDesktopFailure(error) }; + } +} diff --git a/main/services/aiden-remote-tailscale.test.ts b/main/services/aiden-remote-tailscale.test.ts index cdc27575..4a49c38f 100644 --- a/main/services/aiden-remote-tailscale.test.ts +++ b/main/services/aiden-remote-tailscale.test.ts @@ -348,7 +348,7 @@ function takeoverFixture(options: { incumbent?: string; healthy?: boolean; now?: number; - failMutation?: boolean; + failMutation?: boolean | "permission"; } = {}) { const incumbent = options.incumbent ?? "http://127.0.0.1:43179/api/aiden/v1"; const calls: string[][] = []; @@ -397,7 +397,14 @@ function takeoverFixture(options: { } return serialized; } - if (failMutation) throw new Error("command failed"); + if (failMutation) { + if (failMutation === "permission") { + throw Object.assign(new Error("permission denied"), { + stderr: "Access denied: failed to connect to local tailscaled; try running `sudo tailscale set --operator=$USER`", + }); + } + throw new Error("command failed"); + } const nextTarget = args[args.length - 1]; if (nextTarget === "off") { delete (serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers as Record)["/api/aiden/v1"]; @@ -533,6 +540,19 @@ test("expired takeover reviews and failed commands never persist ownership", asy assert.equal(persistCalls, 0); }); +test("an unchanged Serve route with operator denial is permission denied", async () => { + const app = takeoverFixture({ failMutation: "permission" }); + const review = await app.controller.reviewTakeover(target); + await assert.rejects( + app.controller.takeOver(target, review.token, async () => undefined), + /tailscale_permission_denied/u, + ); + assert.equal( + app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, + "http://127.0.0.1:43179/api/aiden/v1", + ); +}); + test("ownership persistence failure restores the exact stale incumbent route", async () => { const incumbent = "http://127.0.0.1:43179/api/aiden/v1"; const app = takeoverFixture({ incumbent }); diff --git a/main/services/aiden-remote-tailscale.ts b/main/services/aiden-remote-tailscale.ts index 10a31d85..3e0be88b 100644 --- a/main/services/aiden-remote-tailscale.ts +++ b/main/services/aiden-remote-tailscale.ts @@ -15,6 +15,7 @@ import { type AidenTailscaleOwnership, type AidenTailscaleStatus, } from "./aiden-remote-tailscale-route.js"; +import { isTailscalePermissionDeniedError } from "./aiden-remote-desktop-errors.js"; const execFileAsync = promisify(execFile); const TAILSCALE_CANDIDATES = [ @@ -669,12 +670,12 @@ export class AidenRemoteTailscaleController { normalizeListenerScaffolding: permitsScaffoldingChange, createdAt: this.now(), }); - let commandFailed = false; + let commandError: unknown; try { if (nextTarget) await this.setExactRoute(nextTarget); else await this.clearExactRoute(); - } catch { - commandFailed = true; + } catch (error) { + commandError = error; } const observed = await this.serveStatusAfterMutation("tailscale_route_outcome_unknown"); const observedSnapshot = aidenTailscaleCanonicalRouteSnapshot(observed); @@ -700,8 +701,11 @@ export class AidenRemoteTailscaleController { await this.outcomeStore?.clear(); } else if (observedFingerprint === serveFingerprint(before)) { await this.outcomeStore?.clear(); + if (commandError && isTailscalePermissionDeniedError(commandError)) { + throw new Error("tailscale_permission_denied"); + } } - throw new Error(commandFailed + throw new Error(commandError ? "tailscale_route_outcome_unknown" : "tailscale_route_verification_failed"); } diff --git a/main/services/aiden-remote-tls-identity.test.ts b/main/services/aiden-remote-tls-identity.test.ts index f18607d7..d92243e8 100644 --- a/main/services/aiden-remote-tls-identity.test.ts +++ b/main/services/aiden-remote-tls-identity.test.ts @@ -3,8 +3,9 @@ import { X509Certificate } from "node:crypto"; import * as fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { createServer } from "node:net"; import test from "node:test"; -import { loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js"; +import { fetchTlsServerSpkiSha256, loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js"; async function temporaryDirectory(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), "aiden-remote-tls-")); @@ -67,3 +68,23 @@ test("TLS identity fails closed instead of silently rotating an incomplete ident await fs.rm(directory, { force: true, recursive: true }); } }); + +test("a hanging TCP endpoint fails as a named TLS timeout", async () => { + const server = createServer(); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + assert.ok(address && typeof address === "object"); + await assert.rejects( + fetchTlsServerSpkiSha256("127.0.0.1", address.port, { timeoutMs: 150 }), + (error: unknown) => + error instanceof Error + && error.name === "AidenRemoteTlsTimeoutError" + && error.message === "Aiden Remote TLS endpoint timed out.", + ); + } finally { + server.close(); + } +}); diff --git a/main/services/aiden-remote-tls-identity.ts b/main/services/aiden-remote-tls-identity.ts index 06bcd687..0bc3efc0 100644 --- a/main/services/aiden-remote-tls-identity.ts +++ b/main/services/aiden-remote-tls-identity.ts @@ -85,6 +85,7 @@ function spkiDigest(value: string | Buffer): string { export async function fetchTlsServerSpkiSha256( hostname: string, port = 443, + options: { timeoutMs?: number } = {}, ): Promise { if ( !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(hostname) || @@ -95,6 +96,12 @@ export async function fetchTlsServerSpkiSha256( throw new Error("Aiden Remote TLS endpoint is invalid."); } return new Promise((resolve, reject) => { + let settled = false; + const settle = (finish: () => void): void => { + if (settled) return; + settled = true; + finish(); + }; const socket = tls.connect({ host: hostname, port, @@ -102,23 +109,26 @@ export async function fetchTlsServerSpkiSha256( rejectUnauthorized: true, }); const timeout = setTimeout(() => { - socket.destroy(new Error("Aiden Remote TLS endpoint timed out.")); - }, 5_000); + const timeoutError = new Error("Aiden Remote TLS endpoint timed out."); + timeoutError.name = "AidenRemoteTlsTimeoutError"; + settle(() => reject(timeoutError)); + socket.destroy(); + }, options.timeoutMs ?? 5_000); socket.once("secureConnect", () => { + clearTimeout(timeout); try { const certificate = socket.getPeerCertificate(true); if (!certificate.raw?.length) throw new Error("Aiden Remote TLS endpoint has no certificate."); - resolve(spkiDigest(certificate.raw)); + settle(() => resolve(spkiDigest(certificate.raw))); } catch (error) { - reject(error); + settle(() => reject(error)); } finally { - clearTimeout(timeout); socket.end(); } }); socket.once("error", (error) => { clearTimeout(timeout); - reject(error); + settle(() => reject(error)); }); }); } diff --git a/package.json b/package.json index 91758a29..6c69272e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "aiden-agent", "version": "0.38.1", "private": true, + "license": "MIT", "description": "A macOS AI workspace agent for local and hosted models", "keywords": [ "ai-agent", @@ -46,18 +47,19 @@ "generative-ui:vendor": "node scripts/vendor-generative-ui-libs.mjs", "pretest:generative-ui": "npm run build:subagent-file-mutator", "test:aiden-remote-speech": "tsx --test main/services/aiden-remote-speech.test.ts", - "pretest": "npm run build:worktree-remover && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", + "pretest": "npm run build:worktree-remover && npm run test:aiden-remote-speech && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:memory-policy && npm run test:ios-release && npm run test:android-version && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:display-image && npm run test:ask-user-question && npm run test:todo && npm run test:btw && npm run test:advisor && npm run test:generative-ui && npm run test:provider-failure && npm run test:web-search && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts && npm run test:bots && npm run test:voice && npm run test:sidebar", "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:scheduled && npm run test:memory-policy && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:display-image && npm run test:generative-ui && npm run test:compaction && npm run test:subagents && npm run test:bots:coverage", "test:preflight": "npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/components/settings/providers-settings.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/button-appearance-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/inline-metadata-hierarchy.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", "test:sidebar": "tsx --test renderer/components/chat-sidebar.test.tsx renderer/lib/sidebar-workspace-groups.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts", - "test:aiden-remote": "tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-chat-summaries.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-bot-files.test.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-ports.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", + "test:aiden-remote": "tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-chat-summaries.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-bot-files.test.ts main/services/aiden-remote-bots.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-ports.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-desktop-errors.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", "test:aiden-remote-chat-summaries": "tsx --test main/services/aiden-remote-chat-summaries.test.ts", "test:memory-policy": "tsx --test main/services/memory-policy.test.ts main/services/aiden-remote-memory-settings.test.ts renderer/components/settings/memory-settings.test.tsx", "test:aiden-service-boundary": "tsx --test main/services/chat-application-service.test.ts main/services/chat-generation-owner.test.ts main/services/workspace-application-service.test.ts main/services/workspace-environment-application-service.test.ts main/services/workspace-worktree-application-service.test.ts main/services/scheduled-task-application-service.test.ts main/services/bot-application-service.test.ts", "ios:asc-monitor": "node scripts/ios-asc-monitor.mjs", "ios:activitykit-process-proof": "node scripts/ios-live-activity-process-proof.mjs", + "test:android-version": "node --test scripts/check-android-version.test.mjs", "test:ios-release": "ruby ios/ci/select_testflight_build_number_test.rb && node --test scripts/check-ios-testflight-policy.test.mjs scripts/check-ios-app-store-metadata.test.mjs scripts/check-ios-shipping-target.test.mjs scripts/ios-asc-monitor.test.mjs scripts/ios-live-activity-process-proof.test.mjs", - "test:branding": "tsx --test main/runtime-mode.test.ts main/runtime-profile-core.test.ts main/runtime-profile-bootstrap.test.ts main/services/app-updater-core.test.ts && node --test scripts/prepare-ci-release.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/check-release-consumers.test.mjs scripts/check-ci-policy.test.mjs scripts/patch-pi-oauth-branding.test.mjs scripts/patch-electron-builder-keychain.test.mjs scripts/publish-github-release.test.mjs", + "test:branding": "tsx --test main/runtime-mode.test.ts main/runtime-profile-core.test.ts main/runtime-profile-bootstrap.test.ts main/linux-chromium-flags.test.ts main/services/app-updater-core.test.ts && node --test scripts/prepare-ci-release.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/check-release-consumers.test.mjs scripts/check-ci-policy.test.mjs scripts/patch-pi-oauth-branding.test.mjs scripts/patch-electron-builder-keychain.test.mjs scripts/publish-github-release.test.mjs", "test:scheduled": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-notification.test.ts main/services/schedule-service-core.test.ts main/services/schedule-store.test.ts main/services/schedule-script.test.ts main/services/schedule-tool.test.ts renderer/lib/scheduled-task-view.test.ts", "test:artificial-analysis": "tsx --test main/services/artificial-analysis-cache.test.ts main/services/artificial-analysis-runtime-core.test.ts main/services/artificial-analysis-catalog-core.test.ts main/services/provider-model-info-core.test.ts renderer/lib/settings-section.test.ts", "test:model-insights": "tsx --test main/services/openrouter-benchmark.test.ts main/services/models.test.ts main/services/provider-model-info-core.test.ts main/handlers/ipc-contract.test.ts", diff --git a/renderer/components/settings/remote-access-settings.test.tsx b/renderer/components/settings/remote-access-settings.test.tsx index 0f50ffad..2ed96f67 100644 --- a/renderer/components/settings/remote-access-settings.test.tsx +++ b/renderer/components/settings/remote-access-settings.test.tsx @@ -61,10 +61,19 @@ test("Tailscale setup failures retain typed actionable remediation", () => { assert.match(source, /status\.tailscaleErrorCode === "https_unavailable"/u); assert.match(source, /Open Tailscale and sign in/u); assert.match(source, /Enable HTTPS for this Tailscale device name/u); + assert.match(source, /Tailscale isn't installed/u); + assert.match(source, /tailscale_status_unavailable/u); + assert.match(source, /tailscale_takeover_unavailable/u); + assert.match(source, /tailscale_reconciliation_unavailable/u); + assert.match(source, /tailscale_target_invalid/u); + assert.match(source, /if \(code\.includes\("tailscale_"\)\) return/u); assert.match(source, / { diff --git a/renderer/components/settings/remote-access-settings.tsx b/renderer/components/settings/remote-access-settings.tsx index 86bca52f..06f859d8 100644 --- a/renderer/components/settings/remote-access-settings.tsx +++ b/renderer/components/settings/remote-access-settings.tsx @@ -49,6 +49,7 @@ import type { AidenRemoteSettingsSnapshot, AidenRemoteTailscaleTakeoverReviewView, } from "../../shared/aiden-remote"; +import { AidenRemoteDesktopError } from "../../shared/aiden-remote"; import { groupRemoteDevices, remoteConnectionSummary, @@ -127,24 +128,53 @@ function tailscaleRouteCopy(status: AidenRemoteSettingsSnapshot["status"]): { } } +function remoteErrorCode(error: unknown): string { + if (error instanceof AidenRemoteDesktopError) return error.code; + return error instanceof Error ? error.message : ""; +} + function friendlyTailscaleError(error: unknown): string { - const message = error instanceof Error ? error.message : ""; - if (message.includes("tailscale_route_live")) return "Another Aiden profile is active on this route. Nothing was changed."; - if (message.includes("tailscale_takeover_changed") || message.includes("tailscale_takeover_expired")) return "The route changed or this review expired. Review it again before taking over."; - if (message.includes("tailscale_funnel_conflict")) return "Tailscale Funnel is using this listener. Aiden did not change it."; - if (message.includes("tailscale_route_conflict")) return "This Serve path is already in use. Aiden did not change it."; - if (message.includes("tailscale_ownership_commit_failed")) return "Aiden restored the previous route because it couldn’t save ownership."; - if (message.includes("tailscale_route_recovery_failed")) return "Aiden couldn’t verify route recovery. Check Tailscale Serve before trying again."; - if (message.includes("tailscale_route_outcome_unknown")) return "Tailscale reported an uncertain route update. Aiden did not save ownership; inspect Serve before retrying."; - if (message.includes("tailscale_reconciliation_conflict")) return "The route changed after the uncertain update. Aiden left it untouched; inspect Tailscale Serve."; - if (message.includes("tailscale_reconciliation_unhealthy")) return "The route exists but this Aiden service did not answer its health check. Nothing was claimed."; - if (message.includes("tailscale_reconciliation_required")) return "Verify the previous Tailscale update before starting another route change."; - if (message.includes("tailscale_not_connected")) return "Open Tailscale and sign in before connecting Aiden."; - if (message.includes("tailscale_https_unavailable")) return "Enable HTTPS for this Tailscale device name before connecting Aiden."; - if (message.includes("tailscale_route_busy")) return "Another Aiden profile is updating this Mac’s mobile route. Wait a moment and try again."; + const code = remoteErrorCode(error); + if (code === "tailscale_permission_denied" || code.includes("tailscale_permission_denied")) { + return "Aiden needs Tailscale operator permission. Run sudo tailscale set --operator=$USER, then try again."; + } + if (code.includes("tailscale_route_live")) return "Another Aiden profile is active on this route. Nothing was changed."; + if (code.includes("tailscale_takeover_changed") || code.includes("tailscale_takeover_expired")) return "The route changed or this review expired. Review it again before taking over."; + if (code.includes("tailscale_funnel_conflict")) return "Tailscale Funnel is using this listener. Aiden did not change it."; + if (code.includes("tailscale_route_conflict")) return "This Serve path is already in use. Aiden did not change it."; + if (code.includes("tailscale_ownership_commit_failed")) return "Aiden restored the previous route because it couldn’t save ownership."; + if (code.includes("tailscale_route_recovery_failed")) return "Aiden couldn’t verify route recovery. Check Tailscale Serve before trying again."; + if (code.includes("tailscale_route_outcome_unknown")) return "Tailscale reported an uncertain route update. Aiden did not save ownership; inspect Serve before retrying."; + if (code.includes("tailscale_reconciliation_conflict")) return "The route changed after the uncertain update. Aiden left it untouched; inspect Tailscale Serve."; + if (code.includes("tailscale_reconciliation_unhealthy")) return "The route exists but this Aiden service did not answer its health check. Nothing was claimed."; + if (code.includes("tailscale_reconciliation_required")) return "Verify the previous Tailscale update before starting another route change."; + if (code.includes("tailscale_not_connected")) return "Open Tailscale and sign in before connecting Aiden."; + if (code.includes("tailscale_not_installed")) return "Tailscale isn't installed. Install Tailscale, then try again."; + if (code.includes("tailscale_https_unavailable")) return "Enable HTTPS for this Tailscale device name before connecting Aiden."; + if (code.includes("tailscale_status_unavailable")) return "Aiden couldn't read Tailscale status. Open Tailscale, then try again."; + if ( + code.includes("tailscale_takeover_unavailable") + || code.includes("tailscale_takeover_token_failed") + ) { + return "Aiden couldn't take over the Tailscale route. Check Tailscale Serve, then try again."; + } + if (code.includes("tailscale_reconciliation_unavailable")) { + return "Aiden couldn't refresh the Tailscale route. Check Tailscale Serve, then try again."; + } + if (code.includes("tailscale_target_invalid")) { + return "The Tailscale route target is invalid. Check Tailscale Serve, then try again."; + } + if (code.includes("tailscale_route_busy")) return "Another Aiden profile is updating this Mac’s mobile route. Wait a moment and try again."; + if (code.includes("tailscale_")) return "Aiden couldn’t safely update the Tailscale route."; + if (error instanceof AidenRemoteDesktopError) return error.message; return "Aiden couldn’t safely update the Tailscale route."; } +function friendlyPairingError(error: unknown): string { + if (error instanceof AidenRemoteDesktopError) return error.message; + return error instanceof Error ? error.message : "Aiden couldn't open pairing."; +} + function Disclosure({ title, summary, @@ -407,7 +437,7 @@ export function RemoteAccessSettings() { await queryClient.invalidateQueries({ queryKey: queryKeys.aidenRemote }); } catch (error) { if (mounted.current && pairingRequestGeneration.current === requestGeneration) { - toast.error(error instanceof Error ? error.message : "Aiden couldn't open pairing."); + toast.error(friendlyPairingError(error)); } } finally { if (mounted.current && pairingRequestGeneration.current === requestGeneration) { diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 9ac86617..f2716998 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -140,9 +140,12 @@ import { parseSkillCatalog, type SkillCatalogEntry } from "../shared/slash-comma import { rememberAppendReconciliationFailure } from "./append-reconciliation"; import type { AidenRemoteConnectionMode, + AidenRemoteDesktopResult, AidenRemotePairingBootstrapView, AidenRemoteSettingsSnapshot, + AidenRemoteTailscaleTakeoverReviewView, } from "../shared/aiden-remote"; +import { unwrapAidenRemoteDesktopResult } from "../shared/aiden-remote"; import { chatArtifactIdentity, parseChatArtifactEventV1, @@ -483,17 +486,25 @@ export const aidenRemoteApi = { setDisplayName: (displayName: string) => invoke("remote:setDisplayName", displayName), moveToAvailablePort: () => invoke("remote:moveToAvailablePort"), - connectTailscale: () => invoke("remote:tailscaleConnect"), - disconnectTailscale: () => invoke("remote:tailscaleDisconnect"), - reconcileTailscale: () => invoke("remote:tailscaleReconcile"), + connectTailscale: () => + invoke>("remote:tailscaleConnect") + .then(unwrapAidenRemoteDesktopResult), + disconnectTailscale: () => + invoke>("remote:tailscaleDisconnect") + .then(unwrapAidenRemoteDesktopResult), + reconcileTailscale: () => + invoke>("remote:tailscaleReconcile") + .then(unwrapAidenRemoteDesktopResult), reviewTailscaleTakeover: () => - invoke( + invoke>( "remote:tailscaleReviewTakeover", - ), + ).then(unwrapAidenRemoteDesktopResult), takeOverTailscale: (token: string) => - invoke("remote:tailscaleTakeOver", token), + invoke>("remote:tailscaleTakeOver", token) + .then(unwrapAidenRemoteDesktopResult), beginPairing: (transport: "lan" | "tailscale") => - invoke("remote:beginPairing", transport), + invoke>("remote:beginPairing", transport) + .then(unwrapAidenRemoteDesktopResult), closePairing: (pairingSessionId: string) => invoke<{ closed: boolean }>("remote:closePairing", pairingSessionId), revokeDevice: (deviceId: string) => diff --git a/renderer/shared/aiden-remote.ts b/renderer/shared/aiden-remote.ts index 907d3966..0b93bd9c 100644 --- a/renderer/shared/aiden-remote.ts +++ b/renderer/shared/aiden-remote.ts @@ -77,3 +77,29 @@ export interface AidenRemotePairingBootstrapView { /** IPC-only 100-bit setup code. It is never exposed through remote status. */ manualCode: string; } + +export type AidenRemoteDesktopErrorCode = + | "tls_endpoint_timeout" + | "tls_endpoint_unreachable" + | "tls_invalid_certificate" + | "pairing_failed" + | `tailscale_${string}`; + +export type AidenRemoteDesktopResult = + | { ok: true; value: T } + | { ok: false; code: AidenRemoteDesktopErrorCode; message: string }; + +export class AidenRemoteDesktopError extends Error { + readonly code: AidenRemoteDesktopErrorCode; + + constructor(code: AidenRemoteDesktopErrorCode, message: string) { + super(message); + this.name = "AidenRemoteDesktopError"; + this.code = code; + } +} + +export function unwrapAidenRemoteDesktopResult(result: AidenRemoteDesktopResult): T { + if (result.ok) return result.value; + throw new AidenRemoteDesktopError(result.code, result.message); +} diff --git a/scripts/check-android-version.test.mjs b/scripts/check-android-version.test.mjs new file mode 100644 index 00000000..05b5cdfd --- /dev/null +++ b/scripts/check-android-version.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { fileURLToPath, URL } from "node:url"; + +const gradlePath = fileURLToPath(new URL("../android/app/build.gradle.kts", import.meta.url)); +const versionPath = fileURLToPath( + new URL("../android/app/src/main/java/sbtbiswas/AidenOnTheGo/AidenAppVersion.kt", import.meta.url), +); +const motionPath = fileURLToPath( + new URL("../android/app/src/main/java/sbtbiswas/AidenOnTheGo/ui/theme/AidenMotion.kt", import.meta.url), +); +const pairingPath = fileURLToPath( + new URL( + "../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenPairingScreen.kt", + import.meta.url, + ), +); +const scannerPath = fileURLToPath( + new URL( + "../android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenQRCodeScanner.kt", + import.meta.url, + ), +); +const versionTestPath = fileURLToPath( + new URL("../android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenAppVersionTest.kt", import.meta.url), +); + +const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +test("Android clientVersion stays locked to the Gradle versionName", async () => { + const gradle = await readFile(gradlePath, "utf8"); + const versionSource = await readFile(versionPath, "utf8"); + const versionTest = await readFile(versionTestPath, "utf8"); + const gradleName = gradle.match(/versionName\s*=\s*"([^"]+)"/u)?.[1]; + assert.equal(typeof gradleName, "string"); + assert.match(gradle, /buildConfig\s*=\s*true/u); + assert.match(gradle, /isMinifyEnabled = false/u); + assert.match(versionSource, /val NAME:\s*String\s*=\s*BuildConfig\.VERSION_NAME/u); + assert.doesNotMatch(versionSource, /const val NAME = "/u); + assert.match(versionTest, /assertEquals\(BuildConfig\.VERSION_NAME, AidenAppVersion\.NAME\)/u); +}); + +test("Android pairing filters match the Crockford setup-code alphabet", async () => { + const pairing = await readFile(pairingPath, "utf8"); + assert.match(pairing, new RegExp(`filter \\{ it in "${CROCKFORD}" \\}`, "u")); + assert.doesNotMatch(pairing, /ABCDEFGHJKMNPQRSTVWXYZIL/u); + assert.match(pairing, /fun handleScannedQRCode[\s\S]*if \(isPairing\) return/u); + assert.match(pairing, /if \(!isPairing\) \{\s*AidenQRCodeScanner\(/u); +}); + +test("Android QR scanner cannot bind or deliver after dispose", async () => { + const scanner = await readFile(scannerPath, "utf8"); + assert.match(scanner, /androidx\.lifecycle\.compose\.LocalLifecycleOwner/u); + assert.doesNotMatch(scanner, /androidx\.compose\.ui\.platform\.LocalLifecycleOwner/u); + assert.match(scanner, /val closed = remember \{ AtomicBoolean\(false\) \}/u); + assert.match(scanner, /val delivered = remember \{ AtomicBoolean\(false\) \}/u); + assert.match(scanner, /if \(closed\.get\(\)\) return@addListener/u); + assert.match(scanner, /delivered\.compareAndSet\(false, true\)/u); + assert.match(scanner, /cameraExecutor\.shutdownNow\(\)/u); +}); + +test("visual-only tactilePress observes pointer events without consuming them", async () => { + const motion = await readFile(motionPath, "utf8"); + assert.match(motion, /PointerEventPass\.Final/u); + assert.match(motion, /if \(onClick == null\)/u); + assert.match(motion, /awaitPointerEvent\(PointerEventPass\.Final\)/u); +});