From a0c07e6ec7873f19a03eaf2316b76c2502d498a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:28:11 +0000 Subject: [PATCH 001/284] feat(android): phone protocol module + relay inbox verbs Adds android/protocol, a pure-Kotlin (JVM) implementation of everything the nodeterm phone companion puts on the wire, so an Android app can reach the same desktop the iOS app does: - NaCl box (a TweetNaCl port), HKDF session key, SAS and relay host id, pinned byte-for-byte against the desktop's own tweetnacl/node:crypto - the relay client (relay-socket.ts client role): E2EE handshake, role and anti-replay checks, no re-key after ready, legacy RPC/frame dialect - the standing phone host's vocabulary (host-service.ts): projects.list, pty.attach/kill/destroy/scroll, node.*, board verbs, git, registerNode - LAN pairing (/pair, E2EE-sealed to the QR's hostKey) with an on-device Ed25519 SSH identity - a direct-SSH transport (tmux on the node-terminal socket, v3 workspace index resolution, hook-reply answer files, read-acks, TOFU host key pin) Interop tests run the desktop's real connectHostSession and createPairingService through a local relay broker, and the SSH transport against a real SSH server with a sandboxed tmux. Desktop side: two typed relay verbs a relay-only phone was missing, approvals.answer (answer a held hook-reply approval through the same writer the canvas Approve/Deny button uses) and inbox.ack (the read-ack the ~/.nodeterm/acks sweep runs for an SSH phone). Typing "1" is not a substitute: while the hook holds the request the prompt is not on screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KCfnRQxqi75Q2abUqLMDwE --- android/.gitignore | 12 + android/protocol/build.gradle.kts | 52 +++ .../protocol/scripts/gen-crypto-vectors.cjs | 64 +++ android/protocol/settings.gradle.kts | 18 + .../dev/nodeterm/protocol/crypto/NaclBox.kt | 150 +++++++ .../dev/nodeterm/protocol/crypto/TweetNacl.kt | 376 ++++++++++++++++++ .../nodeterm/protocol/host/HostConnection.kt | 108 +++++ .../nodeterm/protocol/host/QuickActions.kt | 57 +++ .../nodeterm/protocol/host/RelayConnector.kt | 92 +++++ .../protocol/host/RelayHostConnection.kt | 309 ++++++++++++++ .../dev/nodeterm/protocol/model/Agents.kt | 76 ++++ .../dev/nodeterm/protocol/model/JsonReads.kt | 39 ++ .../dev/nodeterm/protocol/model/Models.kt | 234 +++++++++++ .../dev/nodeterm/protocol/model/PairedHost.kt | 87 ++++ .../nodeterm/protocol/model/ProjectsParser.kt | 218 ++++++++++ .../protocol/pairing/PairingClient.kt | 152 +++++++ .../protocol/pairing/PairingPayload.kt | 89 +++++ .../nodeterm/protocol/pairing/SshIdentity.kt | 56 +++ .../dev/nodeterm/protocol/relay/Framing.kt | 113 ++++++ .../protocol/relay/OkHttpRelayTransport.kt | 56 +++ .../dev/nodeterm/protocol/relay/RelayApi.kt | 95 +++++ .../nodeterm/protocol/relay/RelaySocket.kt | 355 +++++++++++++++++ .../protocol/ssh/SshHostConnection.kt | 339 ++++++++++++++++ .../dev/nodeterm/protocol/ssh/SshScripts.kt | 163 ++++++++ .../protocol/src/test/interop/host-fixture.ts | 256 ++++++++++++ .../nodeterm/protocol/CryptoVectorsTest.kt | 96 +++++ .../dev/nodeterm/protocol/InteropHarness.kt | 97 +++++ .../kotlin/dev/nodeterm/protocol/ModelTest.kt | 101 +++++ .../nodeterm/protocol/PairingInteropTest.kt | 97 +++++ .../dev/nodeterm/protocol/RelayInteropTest.kt | 263 ++++++++++++ .../protocol/RelaySocketSecurityTest.kt | 150 +++++++ .../dev/nodeterm/protocol/SshTransportTest.kt | 316 +++++++++++++++ .../src/test/resources/crypto-vectors.json | 198 +++++++++ src/main/index.ts | 54 ++- src/main/remote/host-inbox-verbs.test.ts | 134 +++++++ src/main/remote/host-service.ts | 81 +++- src/main/remote/standing-host.ts | 1 + 37 files changed, 5136 insertions(+), 18 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/protocol/build.gradle.kts create mode 100644 android/protocol/scripts/gen-crypto-vectors.cjs create mode 100644 android/protocol/settings.gradle.kts create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/NaclBox.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/TweetNacl.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHost.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/SshIdentity.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/Framing.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/OkHttpRelayTransport.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelayApi.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelaySocket.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt create mode 100644 android/protocol/src/test/interop/host-fixture.ts create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/CryptoVectorsTest.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelaySocketSecurityTest.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt create mode 100644 android/protocol/src/test/resources/crypto-vectors.json create mode 100644 src/main/remote/host-inbox-verbs.test.ts diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000000..7c8fb6c297 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,12 @@ +# Gradle / Android build output +.gradle/ +build/ +**/build/ +local.properties +.idea/ +*.iml +captures/ +.externalNativeBuild/ +.cxx/ +*.keystore +!debug.keystore diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts new file mode 100644 index 0000000000..f57913bc35 --- /dev/null +++ b/android/protocol/build.gradle.kts @@ -0,0 +1,52 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") version "2.2.0" + `java-library` +} + +group = "dev.nodeterm" +version = "0.1.0" + +java { + // Android's D8 accepts Java 17 bytecode; building with a newer JDK is fine, targeting it is not. + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + api("com.squareup.okhttp3:okhttp:4.12.0") + // Direct-SSH transport (LAN pairing). sshj brings its own BouncyCastle + EdDSA; on Android the + // app re-registers the full BouncyCastle provider at startup (see NodetermApp). + api("com.hierynomus:sshj:0.39.0") + // sshj declares EdDSA runtime-only; the phone's Ed25519 identity is built with it directly. + api("net.i2p.crypto:eddsa:0.3.0") + + testImplementation(kotlin("test")) + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") + // A real SSH server for the direct-SSH transport tests (runs /bin/sh + a sandboxed tmux). + testImplementation("org.apache.sshd:sshd-core:2.14.0") + testRuntimeOnly("org.slf4j:slf4j-simple:2.0.16") +} + +tasks.test { + useJUnitPlatform() + // Interop tests drive this repo's TypeScript host code through node. They locate the repo root + // from here and skip themselves (with a reason) when node or node_modules are missing. + systemProperty("nodeterm.repoRoot", rootDir.resolve("../..").canonicalPath) + systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn") + testLogging { + events("failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/android/protocol/scripts/gen-crypto-vectors.cjs b/android/protocol/scripts/gen-crypto-vectors.cjs new file mode 100644 index 0000000000..d09bebc2a2 --- /dev/null +++ b/android/protocol/scripts/gen-crypto-vectors.cjs @@ -0,0 +1,64 @@ +// Regenerate src/test/resources/crypto-vectors.json from the DESKTOP's own crypto: +// tweetnacl (the exact library src/main/remote/e2ee.ts uses) + node:crypto HKDF. +// node android/protocol/scripts/gen-crypto-vectors.cjs (from the repo root, after npm ci) +// Deterministic: every input comes from a seeded SHA-256 counter, so a rerun is byte-identical. +const path = require('path') +const crypto = require('crypto') +const repo = path.resolve(__dirname, '../../..') +const nacl = require(path.join(repo, 'node_modules/tweetnacl')) + +let counter = 0 +function det(n) { + const out = Buffer.alloc(n) + let off = 0 + while (off < n) { + const h = crypto.createHash('sha256').update(`nodeterm-android-vectors-${counter++}`).digest() + h.copy(out, off, 0, Math.min(32, n - off)) + off += 32 + } + return new Uint8Array(out) +} +const b64 = (u) => Buffer.from(u).toString('base64') + +const keypairs = [] +for (let i = 0; i < 5; i++) { + const sk = det(32) + const kp = nacl.box.keyPair.fromSecretKey(sk) + keypairs.push({ secretKey: b64(sk), publicKey: b64(kp.publicKey) }) +} +const before = [] +for (let i = 0; i < 4; i++) { + const a = nacl.box.keyPair.fromSecretKey(det(32)) + const b = nacl.box.keyPair.fromSecretKey(det(32)) + const shared = nacl.box.before(b.publicKey, a.secretKey) + const sas = (() => { + const h = nacl.hash(shared) + const n = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0 + const code = (n % 1_000_000).toString().padStart(6, '0') + return `${code.slice(0, 3)} ${code.slice(3)}` + })() + before.push({ aSecret: b64(a.secretKey), bPublic: b64(b.publicKey), shared: b64(shared), sas }) +} +const secretbox = [] +for (const len of [0, 1, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, 200, 1000, 4099]) { + const key = det(32) + const nonce = det(24) + const msg = det(len) + secretbox.push({ key: b64(key), nonce: b64(nonce), msg: b64(msg), box: b64(nacl.secretbox(msg, nonce, key)) }) +} +const hkdf = [] +for (let i = 0; i < 3; i++) { + const base = det(32) + const hostNonce = det(16) + const clientNonce = det(16) + const salt = Buffer.concat([Buffer.from(hostNonce), Buffer.from(clientNonce)]) + const out = crypto.hkdfSync('sha256', base, salt, Buffer.from('nodeterm-relay-session-v2'), 32) + hkdf.push({ base: b64(base), hostNonce: b64(hostNonce), clientNonce: b64(clientNonce), sessionKey: b64(new Uint8Array(out)) }) +} +const hostIds = keypairs.map((k) => ({ + publicKey: k.publicKey, + hostId: crypto.createHash('sha256').update(Buffer.from(k.publicKey, 'base64')).digest('base64url').slice(0, 22) +})) +const out = { generatedBy: 'android/protocol/scripts/gen-crypto-vectors.cjs', tweetnacl: require(path.join(repo, 'node_modules/tweetnacl/package.json')).version, keypairs, before, secretbox, hkdf, hostIds } +require('fs').writeFileSync(path.join(__dirname, '../src/test/resources/crypto-vectors.json'), JSON.stringify(out, null, 2) + '\n') +console.log('wrote', secretbox.length, 'secretbox vectors') diff --git a/android/protocol/settings.gradle.kts b/android/protocol/settings.gradle.kts new file mode 100644 index 0000000000..86ba9bd424 --- /dev/null +++ b/android/protocol/settings.gradle.kts @@ -0,0 +1,18 @@ +// The phone protocol as a standalone JVM build. It is deliberately NOT an Android module: every +// byte it puts on the wire (NaCl box, relay handshake, host RPC, pairing) is tested here on a +// plain JVM, including interop runs against this repo's own TypeScript host code — none of which +// needs the Android SDK. The app build (../settings.gradle.kts) consumes it via includeBuild. +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "protocol" diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/NaclBox.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/NaclBox.kt new file mode 100644 index 0000000000..f4a6c0d86d --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/NaclBox.kt @@ -0,0 +1,150 @@ +package dev.nodeterm.protocol.crypto + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** A NaCl box keypair (Curve25519). The secret key is the raw 32 bytes tweetnacl stores. */ +class BoxKeyPair(val publicKey: ByteArray, val secretKey: ByteArray) { + init { + require(publicKey.size == 32 && secretKey.size == 32) { "box keys are 32 bytes" } + } + + val publicKeyB64: String get() = B64.encode(publicKey) + + companion object { + fun generate(random: SecureRandom = SecureRandom()): BoxKeyPair { + val sk = ByteArray(32).also(random::nextBytes) + return fromSecretKey(sk) + } + + /** Recompute the public half, exactly as tweetnacl's `box.keyPair.fromSecretKey`. */ + fun fromSecretKey(secretKey: ByteArray): BoxKeyPair { + require(secretKey.size == 32) { "secret key must be 32 bytes" } + val pk = ByteArray(32) + TweetNacl.scalarmultBase(pk, secretKey) + return BoxKeyPair(pk, secretKey.copyOf()) + } + } +} + +/** Standard (padded) base64 — what the desktop's `publicKeyToB64` / `Buffer.toString('base64')` emit. */ +object B64 { + fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + /** Strict decode: returns null instead of throwing on malformed input. */ + fun decode(text: String): ByteArray? = try { + Base64.getDecoder().decode(text.trim()) + } catch (_: IllegalArgumentException) { + null + } + + fun encodeUrl(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + + fun decodeUrl(text: String): ByteArray? = try { + Base64.getUrlDecoder().decode(text.trim().trimEnd('=')) + } catch (_: IllegalArgumentException) { + null + } +} + +/** + * The desktop's `src/main/remote/e2ee.ts`, one function per function. Every name here maps to the + * TypeScript one it mirrors so a reader can check them side by side. + */ +object E2ee { + private val random = SecureRandom() + + /** `publicKeyFromB64`: strict — anything that is not exactly 32 bytes is refused. */ + fun publicKeyFromB64(b64: String): ByteArray { + val key = B64.decode(b64) ?: throw IllegalArgumentException("Invalid public key: not base64") + require(key.size == 32) { "Invalid public key: expected 32 bytes, got ${key.size}" } + return key + } + + /** `deriveSharedKey`: the STABLE per-device-pair ECDH precompute (`nacl.box.before`). */ + fun deriveSharedKey(theirPubB64: String, ourSecret: ByteArray): ByteArray = + TweetNacl.boxBefore(publicKeyFromB64(theirPubB64), ourSecret) + + fun deriveSharedKey(theirPub: ByteArray, ourSecret: ByteArray): ByteArray = + TweetNacl.boxBefore(theirPub, ourSecret) + + /** `randomSessionNonce`: 16 random bytes, exchanged in the handshake (NOT the 24-byte box nonce). */ + fun randomSessionNonce(): ByteArray = ByteArray(16).also(random::nextBytes) + + /** + * `deriveSessionKey` = HKDF-SHA256(ikm = baseShared, salt = hostNonce ‖ clientNonce, + * info = "nodeterm-relay-session-v2", L = 32). Host nonce FIRST in both roles. + */ + fun deriveSessionKey(baseShared: ByteArray, hostNonce: ByteArray, clientNonce: ByteArray): ByteArray = + Hkdf.sha256( + ikm = baseShared, + salt = hostNonce + clientNonce, + info = "nodeterm-relay-session-v2".toByteArray(Charsets.UTF_8), + length = 32 + ) + + /** `encrypt`: `nonce(24) ‖ box.after(plain)`. */ + fun encrypt(plain: ByteArray, shared: ByteArray): ByteArray { + val nonce = ByteArray(TweetNacl.NONCE_BYTES).also(random::nextBytes) + return nonce + TweetNacl.secretbox(plain, nonce, shared) + } + + /** `decrypt`: null on malformed input or a failed MAC — never throws. */ + fun decrypt(box: ByteArray, shared: ByteArray): ByteArray? { + if (box.size < TweetNacl.NONCE_BYTES + TweetNacl.OVERHEAD_BYTES) return null + val nonce = box.copyOfRange(0, TweetNacl.NONCE_BYTES) + return TweetNacl.secretboxOpen(box.copyOfRange(TweetNacl.NONCE_BYTES, box.size), nonce, shared) + } + + /** + * `sasFromSharedKey`: SHA-512 of the BASE key (never the session key), first four bytes read + * big-endian as an unsigned 32-bit int, mod 1,000,000, zero-padded, "NNN NNN". + */ + fun sasFromSharedKey(shared: ByteArray): String { + val h = MessageDigest.getInstance("SHA-512").digest(shared) + val n = ((h[0].toLong() and 0xff) shl 24) or + ((h[1].toLong() and 0xff) shl 16) or + ((h[2].toLong() and 0xff) shl 8) or + (h[3].toLong() and 0xff) + val code = (n % 1_000_000L).toString().padStart(6, '0') + return "${code.substring(0, 3)} ${code.substring(3)}" + } + + /** + * `hostIdFromPublicKey` (relay-id.ts): base64url(sha256(raw 32-byte pub)).slice(0, 22) — the + * relay broker room both ends register under. + */ + fun hostIdFromPublicKey(pub: ByteArray): String = + B64.encodeUrl(MessageDigest.getInstance("SHA-256").digest(pub)).take(22) + + fun hostIdFromPublicKeyB64(b64: String): String = hostIdFromPublicKey(publicKeyFromB64(b64)) +} + +/** RFC 5869 HKDF over HMAC-SHA256 (matches Node's `hkdfSync('sha256', …)`). */ +object Hkdf { + fun sha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + require(length in 1..(255 * 32)) { "bad HKDF length" } + val prk = hmac(if (salt.isEmpty()) ByteArray(32) else salt, ikm) + val out = ByteArray(length) + var t = ByteArray(0) + var pos = 0 + var counter = 1 + while (pos < length) { + t = hmac(prk, t + info + byteArrayOf(counter.toByte())) + val n = minOf(t.size, length - pos) + System.arraycopy(t, 0, out, pos, n) + pos += n + counter++ + } + return out + } + + private fun hmac(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/TweetNacl.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/TweetNacl.kt new file mode 100644 index 0000000000..8120c1e614 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/TweetNacl.kt @@ -0,0 +1,376 @@ +package dev.nodeterm.protocol.crypto + +/** + * The subset of TweetNaCl the phone protocol needs: `crypto_box` (Curve25519 + XSalsa20-Poly1305) + * and its pieces. A faithful port of the public-domain TweetNaCl reference (the same code the + * desktop runs as `tweetnacl` 1.0.3), so every box this produces opens there and vice versa — + * `TweetNaclTest` pins that against vectors generated by the desktop's own library. + * + * Why a port rather than a provider: X25519 via `KeyAgreement("XDH")` only exists on Android 12+, + * no platform provider exposes HSalsa20 (which `box.before` needs), and a hand-assembled + * BouncyCastle chain would still need HSalsa20 written out. Two hundred lines that are checked + * byte-for-byte against the other end are the smaller risk. + * + * Wire shape of `box.after` (tweetnacl-js `secretbox`): `mac(16) ‖ ciphertext`. The desktop's + * `e2ee.encrypt` prepends the 24-byte nonce — see [NaclBox]. + */ +internal object TweetNacl { + const val KEY_BYTES = 32 + const val NONCE_BYTES = 24 + const val ZERO_BYTES = 32 + const val BOX_ZERO_BYTES = 16 + const val OVERHEAD_BYTES = ZERO_BYTES - BOX_ZERO_BYTES + + private val ZERO16 = ByteArray(16) + private val NINE = ByteArray(32).also { it[0] = 9 } + private val SIGMA = "expand 32-byte k".toByteArray(Charsets.US_ASCII) + private val GF_121665 = longArrayOf(0xDB41, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + // ---- Salsa20 core --------------------------------------------------------------------- + + private fun ld32(x: ByteArray, i: Int): Int = + (x[i].toInt() and 0xff) or + ((x[i + 1].toInt() and 0xff) shl 8) or + ((x[i + 2].toInt() and 0xff) shl 16) or + ((x[i + 3].toInt() and 0xff) shl 24) + + private fun st32(x: ByteArray, j: Int, u: Int) { + var v = u + for (i in 0 until 4) { + x[j + i] = (v and 0xff).toByte() + v = v ushr 8 + } + } + + private fun rotl(x: Int, c: Int): Int = (x shl c) or (x ushr (32 - c)) + + private fun core(out: ByteArray, inp: ByteArray, inpOff: Int, k: ByteArray, c: ByteArray, h: Boolean) { + val w = IntArray(16) + val x = IntArray(16) + val y = IntArray(16) + val t = IntArray(4) + for (i in 0 until 4) { + x[5 * i] = ld32(c, 4 * i) + x[1 + i] = ld32(k, 4 * i) + x[6 + i] = ld32(inp, inpOff + 4 * i) + x[11 + i] = ld32(k, 16 + 4 * i) + } + for (i in 0 until 16) y[i] = x[i] + for (i in 0 until 20) { + for (j in 0 until 4) { + for (m in 0 until 4) t[m] = x[(5 * j + 4 * m) % 16] + t[1] = t[1] xor rotl(t[0] + t[3], 7) + t[2] = t[2] xor rotl(t[1] + t[0], 9) + t[3] = t[3] xor rotl(t[2] + t[1], 13) + t[0] = t[0] xor rotl(t[3] + t[2], 18) + for (m in 0 until 4) w[4 * j + (j + m) % 4] = t[m] + } + for (m in 0 until 16) x[m] = w[m] + } + if (h) { + for (i in 0 until 16) x[i] += y[i] + for (i in 0 until 4) { + x[5 * i] -= ld32(c, 4 * i) + x[6 + i] -= ld32(inp, inpOff + 4 * i) + } + for (i in 0 until 4) { + st32(out, 4 * i, x[5 * i]) + st32(out, 16 + 4 * i, x[6 + i]) + } + } else { + for (i in 0 until 16) st32(out, 4 * i, x[i] + y[i]) + } + } + + private fun coreSalsa20(out: ByteArray, inp: ByteArray, k: ByteArray, c: ByteArray) = + core(out, inp, 0, k, c, false) + + fun coreHSalsa20(out: ByteArray, inp: ByteArray, inpOff: Int, k: ByteArray, c: ByteArray) = + core(out, inp, inpOff, k, c, true) + + /** XOR `b` bytes of `m` (or zeros when `m` is null) with the Salsa20 stream for nonce[nOff..nOff+8). */ + private fun streamSalsa20Xor( + c: ByteArray, cposStart: Int, m: ByteArray?, mposStart: Int, bStart: Long, + n: ByteArray, nOff: Int, k: ByteArray + ) { + var b = bStart + if (b == 0L) return + val z = ByteArray(16) + val x = ByteArray(64) + var cpos = cposStart + var mpos = mposStart + for (i in 0 until 8) z[i] = n[nOff + i] + while (b >= 64) { + coreSalsa20(x, z, k, SIGMA) + for (i in 0 until 64) c[cpos + i] = ((if (m != null) m[mpos + i].toInt() else 0) xor x[i].toInt()).toByte() + var u = 1 + for (i in 8 until 16) { + u += z[i].toInt() and 0xff + z[i] = (u and 0xff).toByte() + u = u ushr 8 + } + b -= 64 + cpos += 64 + if (m != null) mpos += 64 + } + if (b > 0) { + coreSalsa20(x, z, k, SIGMA) + for (i in 0 until b.toInt()) c[cpos + i] = ((if (m != null) m[mpos + i].toInt() else 0) xor x[i].toInt()).toByte() + } + } + + private fun stream(c: ByteArray, d: Long, n: ByteArray, k: ByteArray) { + val s = ByteArray(32) + coreHSalsa20(s, n, 0, k, SIGMA) + streamSalsa20Xor(c, 0, null, 0, d, n, 16, s) + } + + private fun streamXor(c: ByteArray, m: ByteArray, d: Long, n: ByteArray, k: ByteArray) { + val s = ByteArray(32) + coreHSalsa20(s, n, 0, k, SIGMA) + streamSalsa20Xor(c, 0, m, 0, d, n, 16, s) + } + + // ---- Poly1305 (reference 17-limb form) ------------------------------------------------ + + private val MINUS_P = intArrayOf(5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252) + + private fun add1305(h: IntArray, c: IntArray) { + var u = 0 + for (j in 0 until 17) { + u += h[j] + c[j] + h[j] = u and 255 + u = u ushr 8 + } + } + + private fun onetimeauth(out: ByteArray, outPos: Int, m: ByteArray, mposStart: Int, nStart: Long, k: ByteArray) { + val x = IntArray(17) + val r = IntArray(17) + val h = IntArray(17) + val c = IntArray(17) + val g = IntArray(17) + for (j in 0 until 16) r[j] = k[j].toInt() and 0xff + r[3] = r[3] and 15; r[4] = r[4] and 252; r[7] = r[7] and 15; r[8] = r[8] and 252 + r[11] = r[11] and 15; r[12] = r[12] and 252; r[15] = r[15] and 15 + var mpos = mposStart + var n = nStart + while (n > 0) { + for (j in 0 until 17) c[j] = 0 + var j = 0 + while (j < 16 && j < n) { + c[j] = m[mpos + j].toInt() and 0xff + j++ + } + c[j] = 1 + mpos += j + n -= j + add1305(h, c) + for (i in 0 until 17) { + var acc = 0 + for (jj in 0 until 17) { + acc += h[jj] * (if (jj <= i) r[i - jj] else 320 * r[i + 17 - jj]) + } + x[i] = acc + } + for (i in 0 until 17) h[i] = x[i] + var u = 0 + for (jj in 0 until 16) { + u += h[jj] + h[jj] = u and 255 + u = u ushr 8 + } + u += h[16] + h[16] = u and 3 + u = 5 * (u ushr 2) + for (jj in 0 until 16) { + u += h[jj] + h[jj] = u and 255 + u = u ushr 8 + } + u += h[16] + h[16] = u + } + for (j in 0 until 17) g[j] = h[j] + add1305(h, MINUS_P) + val s = -(h[16] ushr 7) + for (j in 0 until 17) h[j] = h[j] xor (s and (g[j] xor h[j])) + for (j in 0 until 16) c[j] = k[j + 16].toInt() and 0xff + c[16] = 0 + add1305(h, c) + for (j in 0 until 16) out[outPos + j] = h[j].toByte() + } + + private fun verify16(x: ByteArray, xi: Int, y: ByteArray, yi: Int): Boolean { + var d = 0 + for (i in 0 until 16) d = d or ((x[xi + i].toInt() xor y[yi + i].toInt()) and 0xff) + return d == 0 + } + + // ---- secretbox ------------------------------------------------------------------------- + + /** tweetnacl-js `secretbox(msg, nonce, key)`: returns `mac(16) ‖ ciphertext`. */ + fun secretbox(msg: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { + require(nonce.size == NONCE_BYTES) { "bad nonce size" } + require(key.size == KEY_BYTES) { "bad key size" } + val m = ByteArray(ZERO_BYTES + msg.size) + System.arraycopy(msg, 0, m, ZERO_BYTES, msg.size) + val c = ByteArray(m.size) + streamXor(c, m, m.size.toLong(), nonce, key) + onetimeauth(c, 16, c, 32, (m.size - 32).toLong(), c) + return c.copyOfRange(BOX_ZERO_BYTES, c.size) + } + + /** tweetnacl-js `secretbox.open`: null on a short box or a failed MAC. */ + fun secretboxOpen(box: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray? { + if (nonce.size != NONCE_BYTES || key.size != KEY_BYTES) return null + val c = ByteArray(BOX_ZERO_BYTES + box.size) + System.arraycopy(box, 0, c, BOX_ZERO_BYTES, box.size) + if (c.size < 32) return null + val x = ByteArray(32) + stream(x, 32, nonce, key) + val tag = ByteArray(16) + onetimeauth(tag, 0, c, 32, (c.size - 32).toLong(), x) + if (!verify16(c, 16, tag, 0)) return null + val m = ByteArray(c.size) + streamXor(m, c, c.size.toLong(), nonce, key) + return m.copyOfRange(ZERO_BYTES, m.size) + } + + // ---- Curve25519 ----------------------------------------------------------------------- + + private fun car25519(o: LongArray) { + for (i in 0 until 16) { + o[i] += 1L shl 16 + val c = o[i] shr 16 + if (i < 15) o[i + 1] += c - 1 else o[0] += 38 * (c - 1) + o[i] -= c shl 16 + } + } + + private fun sel25519(p: LongArray, q: LongArray, b: Int) { + val c = (b - 1).toLong().inv() + for (i in 0 until 16) { + val t = c and (p[i] xor q[i]) + p[i] = p[i] xor t + q[i] = q[i] xor t + } + } + + private fun pack25519(o: ByteArray, n: LongArray) { + val m = LongArray(16) + val t = n.copyOf() + car25519(t) + car25519(t) + car25519(t) + for (j in 0 until 2) { + m[0] = t[0] - 0xffed + for (i in 1 until 15) { + m[i] = t[i] - 0xffff - ((m[i - 1] shr 16) and 1) + m[i - 1] = m[i - 1] and 0xffff + } + m[15] = t[15] - 0x7fff - ((m[14] shr 16) and 1) + val b = ((m[15] shr 16) and 1).toInt() + m[14] = m[14] and 0xffff + sel25519(t, m, 1 - b) + } + for (i in 0 until 16) { + o[2 * i] = (t[i] and 0xff).toByte() + o[2 * i + 1] = (t[i] shr 8).toByte() + } + } + + private fun unpack25519(o: LongArray, n: ByteArray) { + for (i in 0 until 16) o[i] = (n[2 * i].toLong() and 0xff) + ((n[2 * i + 1].toLong() and 0xff) shl 8) + o[15] = o[15] and 0x7fff + } + + private fun add(o: LongArray, a: LongArray, b: LongArray) { + for (i in 0 until 16) o[i] = a[i] + b[i] + } + + private fun sub(o: LongArray, a: LongArray, b: LongArray) { + for (i in 0 until 16) o[i] = a[i] - b[i] + } + + private fun mul(o: LongArray, a: LongArray, b: LongArray) { + val t = LongArray(31) + for (i in 0 until 16) for (j in 0 until 16) t[i + j] += a[i] * b[j] + for (i in 0 until 15) t[i] += 38 * t[i + 16] + for (i in 0 until 16) o[i] = t[i] + car25519(o) + car25519(o) + } + + private fun sq(o: LongArray, a: LongArray) = mul(o, a, a) + + private fun inv25519(o: LongArray, i: LongArray) { + val c = i.copyOf() + for (a in 253 downTo 0) { + sq(c, c) + if (a != 2 && a != 4) mul(c, c, i) + } + for (a in 0 until 16) o[a] = c[a] + } + + /** X25519: `q = n · p`. `n` is clamped here, exactly as the reference does. */ + fun scalarmult(q: ByteArray, n: ByteArray, p: ByteArray) { + val z = ByteArray(32) + for (i in 0 until 31) z[i] = n[i] + z[31] = ((n[31].toInt() and 127) or 64).toByte() + z[0] = (z[0].toInt() and 248).toByte() + val x = LongArray(16) + unpack25519(x, p) + val a = LongArray(16) + val b = x.copyOf() + val c = LongArray(16) + val d = LongArray(16) + val e = LongArray(16) + val f = LongArray(16) + a[0] = 1 + d[0] = 1 + for (i in 254 downTo 0) { + val r = ((z[i ushr 3].toInt() and 0xff) ushr (i and 7)) and 1 + sel25519(a, b, r) + sel25519(c, d, r) + add(e, a, c) + sub(a, a, c) + add(c, b, d) + sub(b, b, d) + sq(d, e) + sq(f, a) + mul(a, c, a) + mul(c, b, e) + add(e, a, c) + sub(a, a, c) + sq(b, a) + sub(c, d, f) + mul(a, c, GF_121665) + add(a, a, d) + mul(c, c, a) + mul(a, d, f) + mul(d, b, x) + sq(b, e) + sel25519(a, b, r) + sel25519(c, d, r) + } + val x16 = a.copyOf() + val x32 = c.copyOf() + inv25519(x32, x32) + mul(x16, x16, x32) + pack25519(q, x16) + } + + fun scalarmultBase(q: ByteArray, n: ByteArray) = scalarmult(q, n, NINE) + + /** tweetnacl-js `box.before(theirPublic, ourSecret)`. */ + fun boxBefore(publicKey: ByteArray, secretKey: ByteArray): ByteArray { + require(publicKey.size == 32) { "bad public key size" } + require(secretKey.size == 32) { "bad secret key size" } + val s = ByteArray(32) + scalarmult(s, secretKey, publicKey) + val k = ByteArray(32) + coreHSalsa20(k, ZERO16, 0, s, SIGMA) + return k + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt new file mode 100644 index 0000000000..1235ca424b --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -0,0 +1,108 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.KanbanLabel +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.serialization.json.JsonElement +import java.io.Closeable + +/** Receives one attached terminal's output. Called on a transport thread, in order. */ +interface TerminalSink { + /** The current screen to paint BEFORE any live output (the attach snapshot). */ + fun onPaint(text: String) + /** Live terminal bytes (UTF-8; a chunk may end mid code point — decode statefully). */ + fun onOutput(bytes: ByteArray) + /** The host reports the shared pty's real size (a desktop viewer sized it). */ + fun onResized(cols: Int, rows: Int) {} + /** The pane's process exited, or the attach failed. The stream is gone. */ + fun onExit(code: Int?) +} + +/** One phone view of a node's tmux session. Detaching never ends the session. */ +interface TerminalStream { + /** True when the attach had to CREATE the tmux session (cold start: reboot, or a new node). */ + val fresh: Boolean + fun write(text: String) + fun resize(cols: Int, rows: Int) + /** Scroll tmux's own history (its mouse is on): `lines` wheel notches, clamped host-side. */ + suspend fun scroll(up: Boolean, lines: Int) + /** Stop viewing. The session keeps running. */ + suspend fun detach() + /** Permanently end the session and take the node off its canvas (the desktop ×). */ + suspend fun endSession() +} + +enum class TransportKind { RELAY, SSH } + +/** What a connection can do beyond browse + attach; the UI hides what is false and says why. */ +data class HostCapabilities( + val boardWrites: Boolean, + val git: Boolean, + val nodeActions: Boolean, + val registerNode: Boolean, + /** Can answer a held hook-reply approval (docs/hook-reply-approvals.md) without keystrokes. */ + val answerApprovals: Boolean +) + +data class NewNode(val id: String, val title: String?, val agentId: String?, val accountId: String?) + +/** `projects.editCardLabels` input (`parseCardLabelEdit` host-side). */ +data class CardLabelEdit( + val add: List = emptyList(), + val remove: List = emptyList(), + val create: List> = emptyList() +) + +data class LabelEditResult(val edited: Boolean, val labels: List, val cardLabelIds: List) + +enum class ApprovalOutcome { SENT, ALREADY_HANDLED, UNSUPPORTED } + +/** + * One live connection to a paired computer, over either transport. Every call is honest about + * failure: a verb the host does not serve throws [HostException] with the host's own message. + */ +interface HostConnection : Closeable { + val kind: TransportKind + val capabilities: HostCapabilities + + suspend fun listProjects(): ProjectsSnapshot + suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink): TerminalStream + + suspend fun wake(nodeId: String) + suspend fun refresh(nodeId: String) + suspend fun rename(nodeId: String, title: String) + + suspend fun ensureBoard(projectId: String): List? + suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean + suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? + suspend fun registerNode(projectId: String, node: NewNode): Boolean + + /** + * Answer a held hook-reply approval (one that carries a `pendingId`): the relay's + * `approvals.answer` verb, or the `.answer` file over SSH. [ApprovalOutcome.UNSUPPORTED] means + * this host cannot (an older desktop, no pendingId) — the caller falls back to keystrokes only + * when the prompt is actually on screen. + */ + suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome + + /** Tell the computer this phone READ a finished session (clears its unread, archives the card). */ + suspend fun ackRead(nodeId: String, eventId: String?) + + /** Type raw keys into a node's pane (quick approve / answer fallback). */ + suspend fun sendKeys(nodeId: String, keys: String) + + suspend fun git(verb: GitVerb, cwd: String, args: Map = emptyMap()): JsonElement? + + /** Fires when the host signals a change worth a re-list (a canvas push, a reconnect). */ + fun setOnChanged(listener: (() -> Unit)?) + /** Fires once when the connection drops on its own. */ + fun setOnClosed(listener: ((String?) -> Unit)?) +} + +enum class GitVerb(val wire: String) { + STATUS("git.status"), DIFF("git.diff"), STAGE("git.stage"), UNSTAGE("git.unstage"), + COMMIT("git.commit"), PUSH("git.push"), PULL("git.pull"), HISTORY("git.history") +} + +class HostException(message: String) : Exception(message) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt new file mode 100644 index 0000000000..d4cf795655 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt @@ -0,0 +1,57 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind + +/** + * The Inbox card actions (docs/mobile-usage-inbox.md "Quick approve", docs/hook-reply-approvals.md), + * shared by every transport: + * + * 1. RE-READ the status before acting, and act only if the node is still waiting on exactly this + * (a card can be minutes old; typing `1` into a pane that moved on is typing into whatever is + * there now); + * 2. a held hook-reply approval (it carries a `pendingId`) is answered DETERMINISTICALLY — the + * relay verb or the answer file — never with keys: while the hook holds the request, the prompt + * is not on screen and a keystroke would land in the agent's composer; + * 3. only a claude approval with NO ticket falls back to keys (`1` allow / Esc deny), because only + * claude's prompt layout is known; anything else is "open the session". + */ +object QuickActions { + enum class Result { SENT, ALREADY_HANDLED, OPEN_SESSION } + + suspend fun answerApproval(conn: HostConnection, event: InboxEvent, allow: Boolean): Result { + if (event.kind != InboxKind.APPROVAL) return Result.OPEN_SESSION + if (!stillWaiting(conn, event, AgentState.BLOCKED)) return Result.ALREADY_HANDLED + if (event.pendingId != null && conn.capabilities.answerApprovals) { + when (conn.answerApproval(event, allow)) { + ApprovalOutcome.SENT -> return Result.SENT + ApprovalOutcome.ALREADY_HANDLED -> return Result.ALREADY_HANDLED + ApprovalOutcome.UNSUPPORTED -> Unit + } + } + // A ticketed approval we could not answer deterministically: its prompt is not on screen + // while the hook holds it, so keys are wrong. Send the user to the session instead. + if (event.pendingId != null) return Result.OPEN_SESSION + if (event.agentId != "claude") return Result.OPEN_SESSION + conn.sendKeys(event.nodeId, if (allow) "1" else "\u001b") + return Result.SENT + } + + /** AskUserQuestion: choices are digits on screen (a hook cannot inject an answer value). */ + suspend fun answerQuestion(conn: HostConnection, event: InboxEvent, optionIndex: Int): Result { + if (event.kind != InboxKind.QUESTION || event.multiSelect) return Result.OPEN_SESSION + if (optionIndex !in event.options.indices || optionIndex > 8) return Result.OPEN_SESSION + if (!stillWaiting(conn, event, AgentState.WAITING)) return Result.ALREADY_HANDLED + conn.sendKeys(event.nodeId, (optionIndex + 1).toString()) + return Result.SENT + } + + private suspend fun stillWaiting(conn: HostConnection, event: InboxEvent, expected: AgentState): Boolean { + val snap = conn.listProjects() + val status = snap.statusOf(event.nodeId) ?: return false + if (status.state != expected) return false + val fresh = snap.status?.inbox?.events?.firstOrNull { it.id == event.id } + return fresh == null || !fresh.resolved + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt new file mode 100644 index 0000000000..385196c647 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt @@ -0,0 +1,92 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.relay.OkHttpRelayTransport +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RelayTransportFactory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull + +/** Where a relay connect is, for the UI. */ +sealed interface RelayConnectStatus { + data object MintingToken : RelayConnectStatus + data object Handshaking : RelayConnectStatus + /** The handshake completed; the desktop must approve this phone (first connect only — pin-once). */ + data class AwaitingApproval(val sas: String) : RelayConnectStatus +} + +/** The host message the standing host answers every request with until it approves this device. */ +const val AWAITING_APPROVAL_MESSAGE = "Awaiting host approval." + +/** + * Connect to a paired computer over the relay and wait until it serves us. + * + * The standing phone host is PIN-ONCE (standing-host.ts): the first connect from this phone's box + * key puts the SAS approval dialog on the desktop; once approved the key is pinned and every later + * connect auto-approves silently. Until then every request answers [AWAITING_APPROVAL_MESSAGE], so + * approval is observed by retrying `projects.list` — which is also the first thing the UI needs. + */ +object RelayConnector { + class Connected(val connection: RelayHostConnection, val sas: String, val first: ProjectsSnapshot) + + suspend fun connect( + relayUrl: String, + token: String, + deviceKeys: BoxKeyPair, + hostPublicKeyB64: String, + onStatus: (RelayConnectStatus) -> Unit = {}, + transport: RelayTransportFactory = OkHttpRelayTransport.factory(), + handshakeTimeoutMs: Long = 20_000, + approvalTimeoutMs: Long = 5 * 60_000, + approvalPollMs: Long = 1_500 + ): Connected { + onStatus(RelayConnectStatus.Handshaking) + val ready = CompletableDeferred() + val closed = CompletableDeferred() + val conn = RelayHostConnection.open( + build = { listener -> RelaySocket(relayUrl, token, deviceKeys, hostPublicKeyB64, transport, listener) }, + onReady = { sas -> ready.complete(sas) } + ) + conn.setOnClosed { reason -> closed.complete(reason) } + try { + val sas = withTimeoutOrNull(handshakeTimeoutMs) { + kotlinx.coroutines.selects.select { + ready.onAwait { it } + closed.onAwait { reason -> + throw HostException( + "The relay closed the connection before your computer answered" + + (reason?.let { " ($it)" } ?: "") + + ". Is remote access on in nodeterm → Settings → Phone?" + ) + } + } + } ?: throw HostException("Your computer did not answer through the relay. Is nodeterm running with remote access on?") + + val deadline = System.currentTimeMillis() + approvalTimeoutMs + var announced = false + while (true) { + if (closed.isCompleted) throw HostException("The connection closed while waiting for approval.") + try { + val snapshot = conn.listProjects() + conn.setOnClosed(null) + return Connected(conn, sas, snapshot) + } catch (e: HostException) { + if (e.message != AWAITING_APPROVAL_MESSAGE) throw e + if (!announced) { + announced = true + onStatus(RelayConnectStatus.AwaitingApproval(sas)) + } + if (System.currentTimeMillis() > deadline) { + throw HostException("Nobody approved this phone on your computer in time.") + } + delay(approvalPollMs) + } + } + } catch (e: Throwable) { + conn.close() + throw e + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt new file mode 100644 index 0000000000..f162d46d55 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt @@ -0,0 +1,309 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.objects +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.J.strings +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.KanbanLabel +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.relay.Frame +import dev.nodeterm.protocol.relay.Framing +import dev.nodeterm.protocol.relay.Op +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RelaySocketListener +import dev.nodeterm.protocol.relay.RpcException +import dev.nodeterm.protocol.relay.SnapshotReassembler +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * [HostConnection] over the standing phone host's relay dialect — `createHostHandlers` in + * `src/main/remote/host-service.ts` is the other end, verb for verb: + * + * `projects.list` → `{output}` · `pty.attach {nodeId, cols, rows}` → `{streamId, fresh}` then + * Snapshot Start, Chunk…, End and Output frames · `pty.kill|destroy|scroll {streamId…}` · + * `node.wake|refresh|rename {nodeId, title?}` · `projects.ensureBoard|setCardColumn| + * editCardLabels|registerNode` · `git.*` · Input/Resize frames up, Output/Resized/Error down. + * + * `resizedFrames` is deliberately NOT sent on attach, exactly like the iOS app: a phone is a + * CEILING on the shared pty (a session never grows wider than its screen). An `OP.Resized` still + * arrives when a desktop viewer sized the session, and is surfaced so the UI can offer "fit". + */ +class RelayHostConnection private constructor() : HostConnection, RelaySocketListener { + private lateinit var socket: RelaySocket + private val streams = ConcurrentHashMap() + @Volatile private var onChanged: (() -> Unit)? = null + @Volatile private var onClosed: ((String?) -> Unit)? = null + @Volatile private var readyListener: ((String) -> Unit)? = null + + override val kind = TransportKind.RELAY + override val capabilities = HostCapabilities( + boardWrites = true, git = true, nodeActions = true, registerNode = true, answerApprovals = true + ) + + private inner class Stream(val id: Long, override val fresh: Boolean, val sink: TerminalSink) : TerminalStream { + private val snapshot = SnapshotReassembler() + private var outSeq = 0L + + fun accept(frame: Frame) { + when (frame.op) { + Op.SNAPSHOT_START, Op.SNAPSHOT_CHUNK -> snapshot.accept(frame) + Op.SNAPSHOT_END -> snapshot.accept(frame)?.let { if (it.isNotEmpty()) sink.onPaint(it) } + Op.OUTPUT -> sink.onOutput(frame.payload) + Op.RESIZED -> Framing.readSize(frame.payload)?.let { (c, r) -> sink.onResized(c, r) } + Op.ERROR -> { + streams.remove(id) + val code = J.obj(J.parse(String(frame.payload, Charsets.UTF_8)))?.l("exitCode")?.toInt() + sink.onExit(code) + } + } + } + + override fun write(text: String) { + synchronized(this) { socket.sendFrame(Op.INPUT, id, outSeq++, text.toByteArray(Charsets.UTF_8)) } + } + + override fun resize(cols: Int, rows: Int) { + synchronized(this) { socket.sendFrame(Op.RESIZE, id, outSeq++, Framing.sizePayload(cols, rows)) } + } + + override suspend fun scroll(up: Boolean, lines: Int) { + call("pty.scroll", buildJsonObject { + put("streamId", id) + put("dir", if (up) "up" else "down") + put("lines", lines) + }) + } + + override suspend fun detach() { + streams.remove(id) + runCatching { call("pty.kill", buildJsonObject { put("streamId", id) }) } + } + + override suspend fun endSession() { + try { + call("pty.destroy", buildJsonObject { put("streamId", id) }) + } finally { + streams.remove(id) + } + } + } + + // ---- RelaySocketListener ------------------------------------------------------------------ + + override fun onReady(sas: String) { + readyListener?.invoke(sas) + } + + override fun onNotify(method: String, params: JsonElement?) { + // `canvas:state` is pushed on approval and on every host canvas change: a cheap "re-list". + if (method == "canvas:state") onChanged?.invoke() + } + + override fun onFrame(frame: Frame) { + streams[frame.streamId]?.accept(frame) + } + + override fun onClosed(reason: String?) { + val live = streams.values.toList() + streams.clear() + for (s in live) s.sink.onExit(null) + onClosed?.invoke(reason) + } + + // ---- RPC plumbing --------------------------------------------------------------------- + + private suspend fun call(method: String, params: JsonElement? = null): JsonElement? = try { + socket.call(method, params) + } catch (e: RpcException) { + throw HostException(e.message ?: "Request failed.") + } + + override suspend fun listProjects(): ProjectsSnapshot { + val body = J.obj(call("projects.list")) ?: return ProjectsSnapshot.EMPTY + return ProjectsParser.parseBlob(body.s("output") ?: "") + } + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink): TerminalStream = + suspendCancellableCoroutine { cont -> + val params = buildJsonObject { + put("nodeId", nodeId) + put("cols", cols) + put("rows", rows) + } + socket.request("pty.attach", params) { r -> + r.fold( + onSuccess = { body -> + val o = J.obj(body) + val streamId = o?.l("streamId") + if (streamId == null) { + cont.resumeWithException(HostException("The host did not open a terminal stream.")) + } else { + // Registered HERE, on the reader thread, before the snapshot frames that + // follow the response are processed. + val s = Stream(streamId, o.b("fresh") == true, sink) + streams[streamId] = s + cont.resume(s) + } + }, + onFailure = { cont.resumeWithException(HostException(it.message ?: "Attach failed.")) } + ) + } + } + + override suspend fun wake(nodeId: String) { + call("node.wake", buildJsonObject { put("nodeId", nodeId) }) + } + + override suspend fun refresh(nodeId: String) { + call("node.refresh", buildJsonObject { put("nodeId", nodeId) }) + } + + override suspend fun rename(nodeId: String, title: String) { + call("node.rename", buildJsonObject { + put("nodeId", nodeId) + put("title", title) + }) + } + + override suspend fun ensureBoard(projectId: String): List? { + val o = J.obj(call("projects.ensureBoard", buildJsonObject { put("projectId", projectId) })) ?: return null + val cols = o["columns"] as? JsonArray ?: return null + return cols.mapNotNull { J.obj(it) }.mapNotNull { c -> + val id = c.s("id") ?: return@mapNotNull null + KanbanColumn(id, c.s("title") ?: "", c.s("color")) + } + } + + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean { + val o = J.obj(call("projects.setCardColumn", buildJsonObject { + put("projectId", projectId) + put("nodeId", nodeId) + // null is REAL here: the virtual Ungrouped column. + put("columnId", columnId?.let(::JsonPrimitive) ?: JsonNull) + })) + return o?.b("moved") == true + } + + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? { + val o = J.obj(call("projects.editCardLabels", buildJsonObject { + put("projectId", projectId) + put("nodeId", nodeId) + if (edit.add.isNotEmpty()) put("add", JsonArray(edit.add.map(::JsonPrimitive))) + if (edit.remove.isNotEmpty()) put("remove", JsonArray(edit.remove.map(::JsonPrimitive))) + if (edit.create.isNotEmpty()) put("create", JsonArray(edit.create.map { (name, color) -> + buildJsonObject { + put("name", name) + put("color", color) + } + })) + })) ?: return null + if (o["labels"] !is JsonArray) return null + return LabelEditResult( + edited = o.b("edited") == true, + labels = o.objects("labels").mapNotNull { l -> + val id = l.s("id") ?: return@mapNotNull null + KanbanLabel(id, l.s("name") ?: "", l.s("color") ?: "default") + }, + cardLabelIds = o.strings("cardLabelIds") + ) + } + + override suspend fun registerNode(projectId: String, node: NewNode): Boolean { + val o = J.obj(call("projects.registerNode", buildJsonObject { + put("projectId", projectId) + put("node", buildJsonObject { + put("id", node.id) + node.title?.let { put("title", it) } + node.agentId?.let { put("agentId", it) } + node.accountId?.let { put("accountId", it) } + }) + })) + return o?.b("registered") == true + } + + /** `approvals.answer` (host-service.ts `HostInboxOps`). A desktop that predates the verb answers + * "not served", which is [ApprovalOutcome.UNSUPPORTED] — never a guess. */ + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome { + val pendingId = event.pendingId ?: return ApprovalOutcome.UNSUPPORTED + val body = try { + J.obj(call("approvals.answer", buildJsonObject { + put("nodeId", event.nodeId) + put("pendingId", pendingId) + put("decision", if (allow) "allow" else "deny") + })) + } catch (e: HostException) { + if (e.message?.contains("not served") == true || e.message?.startsWith("Unknown method") == true) { + return ApprovalOutcome.UNSUPPORTED + } + throw e + } + return if (body?.b("answered") == true) ApprovalOutcome.SENT else ApprovalOutcome.ALREADY_HANDLED + } + + override suspend fun ackRead(nodeId: String, eventId: String?) { + try { + call("inbox.ack", buildJsonObject { put("nodeId", nodeId) }) + } catch (_: HostException) { + // An older desktop: the read stays phone-local, exactly as before the verb existed. + } + } + + override suspend fun sendKeys(nodeId: String, keys: String) { + val sink = object : TerminalSink { + override fun onPaint(text: String) {} + override fun onOutput(bytes: ByteArray) {} + override fun onExit(code: Int?) {} + } + val stream = attach(nodeId, 80, 24, sink) + try { + stream.write(keys) + } finally { + stream.detach() + } + } + + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = + call(verb.wire, JsonObject(args + ("cwd" to JsonPrimitive(cwd)))) + + override fun setOnChanged(listener: (() -> Unit)?) { + onChanged = listener + } + + override fun setOnClosed(listener: ((String?) -> Unit)?) { + onClosed = listener + } + + override fun close() { + streams.clear() + socket.close() + } + + companion object { + /** + * Open the socket and wire this connection as its listener. [onReady] fires with the SAS once + * the E2EE handshake completes; APPROVAL is separate (the desktop human, or a pin) — see + * [RelayConnector]. + */ + fun open(build: (RelaySocketListener) -> RelaySocket, onReady: (String) -> Unit): RelayHostConnection { + val conn = RelayHostConnection() + conn.readyListener = onReady + conn.socket = build(conn) + return conn + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt new file mode 100644 index 0000000000..ab81e29b78 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt @@ -0,0 +1,76 @@ +package dev.nodeterm.protocol.model + +import java.security.SecureRandom + +/** + * The builtin agents, as `AGENT_CONFIG` (src/shared/agents/config.ts) defines them — label, brand + * colour, launch command — and the two command lines the phone types: a first launch and a resume. + * The phone never invents a flag the desktop would not emit; where it cannot know a host fact it + * emits the bare command (the desktop's own degrade direction). + */ +enum class Agent(val id: String, val label: String, val color: String, val launchCmd: String, val resumable: Boolean) { + CLAUDE("claude", "Claude Code", "#d97757", "claude", true), + CODEX("codex", "Codex", "#10a37f", "codex", true), + GEMINI("gemini", "Gemini", "#4285f4", "gemini", true), + OPENCODE("opencode", "opencode", "#a78bfa", "opencode", true), + GROK("grok", "Grok", "#64748b", "grok", true), + COPILOT("copilot", "GitHub Copilot", "#8957e5", "copilot", true); + + companion object { + fun of(id: String?): Agent? = entries.firstOrNull { it.id == id } + } +} + +object Launch { + /** `SAFE_SESSION_ID` (config.ts): only this charset ever reaches a command line. */ + private val SAFE_SESSION_ID = Regex("^[A-Za-z0-9][A-Za-z0-9._-]*$") + private val PERMISSION_MODES = setOf("manual", "auto", "acceptEdits", "plan", "bypassPermissions") + private val SAFE_DIR = Regex("^/[^\\u0000-\\u001f'\"`$\\\\]*$") + + /** `resumeCommandWith` (config.ts): each builtin's own resume grammar, or null. */ + fun resumeCommand(agent: Agent, sessionId: String): String? { + val sid = sessionId.trim() + if (!agent.resumable || sid.isEmpty() || !SAFE_SESSION_ID.matches(sid)) return null + return when (agent) { + Agent.CODEX -> "${agent.launchCmd} resume $sid" + Agent.OPENCODE -> "${agent.launchCmd} --session $sid" + Agent.COPILOT -> "${agent.launchCmd} --resume=$sid" + Agent.CLAUDE, Agent.GEMINI, Agent.GROK -> "${agent.launchCmd} --resume $sid" + } + } + + /** + * A first launch of [agent] on a host whose mirror advertised [settings]. Only CLAUDE gets a + * permission-mode flag, and only as the desktop would emit it: `manual` = no flag, `auto` only + * when the HOST's claude supports it (`autoSupported` answers for claude alone), and an unknown + * value = no flag (the value came from a file; re-validated here like `permissionModeFlag`). + * Every other agent launches bare — its own default — because the phone does not have the + * per-agent approval table's host facts (codex's vocabulary moved between releases, #785). + */ + fun launchCommand(agent: Agent, settings: MirrorSettings?, accountId: String?, cwd: String?): String { + val parts = ArrayList() + if (!cwd.isNullOrBlank() && SAFE_DIR.matches(cwd)) parts += "cd '${cwd}' &&" + val account = accountId?.let { id -> settings?.claudeAccounts?.firstOrNull { it.id == id } } + if (agent == Agent.CLAUDE && account != null && SAFE_DIR.matches(account.dir)) { + parts += "CLAUDE_CONFIG_DIR='${account.dir}'" + } + parts += agent.launchCmd + if (agent == Agent.CLAUDE) { + val mode = settings?.claudePermissionMode?.takeIf { it in PERMISSION_MODES } ?: "manual" + val emit = when (mode) { + "manual" -> false + "auto" -> settings?.autoSupported == true + else -> true + } + if (emit) parts += "--permission-mode $mode" + } + return parts.joinToString(" ") + } + + /** A node id in the desktop's shape (`term--`, project-node-append.ts + * `SAFE_NODE_ID`), so the host registrar accepts it and it is a boring tmux name. */ + fun newNodeId(now: Long = System.currentTimeMillis(), random: SecureRandom = SecureRandom()): String { + val token = ByteArray(5).also(random::nextBytes).joinToString("") { "%02x".format(it) } + return "term-${now.toString(36)}-$token" + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt new file mode 100644 index 0000000000..a582d14634 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt @@ -0,0 +1,39 @@ +package dev.nodeterm.protocol.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Tolerant field reads. Everything the phone parses comes from files the desktop documents as + * hand-editable and hostile (project.json is git-shared), so a wrong-typed field degrades to + * "absent" — it never throws and never takes the whole listing down with it. + */ +internal object J { + fun parse(text: String): JsonElement? = try { + if (text.isBlank()) null else Json.parseToJsonElement(text) + } catch (_: Exception) { + null + } + + fun obj(e: JsonElement?): JsonObject? = e as? JsonObject + fun arr(e: JsonElement?): JsonArray? = e as? JsonArray + fun str(e: JsonElement?): String? = (e as? JsonPrimitive)?.takeIf { it.isString }?.content + fun long(e: JsonElement?): Long? = (e as? JsonPrimitive)?.takeIf { !it.isString }?.let { it.longOrNull ?: it.doubleOrNull?.toLong() } + fun double(e: JsonElement?): Double? = (e as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull + fun bool(e: JsonElement?): Boolean? = (e as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull + + fun JsonObject.s(key: String): String? = str(this[key]) + fun JsonObject.l(key: String): Long? = long(this[key]) + fun JsonObject.d(key: String): Double? = double(this[key]) + fun JsonObject.b(key: String): Boolean? = bool(this[key]) + fun JsonObject.o(key: String): JsonObject? = obj(this[key]) + fun JsonObject.a(key: String): List = arr(this[key]) ?: emptyList() + fun JsonObject.objects(key: String): List = a(key).mapNotNull { it as? JsonObject } + fun JsonObject.strings(key: String): List = a(key).mapNotNull { str(it) } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt new file mode 100644 index 0000000000..f9fcc3fba0 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt @@ -0,0 +1,234 @@ +package dev.nodeterm.protocol.model + +/** + * The phone's view of one paired computer: its projects (the ASSEMBLED v2 workspace the desktop's + * `listProjectsOutput` serves), which tmux sessions are live, and the agent-status mirror file + * (`agent-status.json`, see `src/core/agent-status-mirror.ts` and docs/mobile-usage-inbox.md). + */ +data class ProjectsSnapshot( + val projects: List, + /** Live nodeterm tmux session names (`nt-`), from `tmux ls` on the host. */ + val liveSessions: Set, + val status: AgentStatusFile?, + val fetchedAt: Long +) { + fun isLive(nodeId: String): Boolean = liveSessions.contains(TmuxNames.sessionName(nodeId)) + + fun statusOf(nodeId: String): AgentNodeStatus? = status?.nodes?.get(nodeId) + + fun openProjects(): List = projects.filter { !it.closed } + + fun findNode(nodeId: String): Pair? { + for (p in projects) p.nodes.firstOrNull { it.id == nodeId }?.let { return p to it } + return null + } + + companion object { + val EMPTY = ProjectsSnapshot(emptyList(), emptySet(), null, 0) + } +} + +data class ProjectInfo( + val id: String, + val name: String, + val color: String?, + val cwd: String?, + /** `user@host` for an SSH project (its terminals run on ANOTHER machine), else null. */ + val sshTarget: String?, + val closed: Boolean, + val nodes: List, + /** The persisted board, or null when the project has never had one (the desktop's lazy default). */ + val board: KanbanBoard? +) { + /** Session nodes — what the phone lists and can attach to. */ + val sessions: List get() = nodes.filter { it.kind == NodeKind.TERMINAL } +} + +enum class NodeKind(val wire: String) { + TERMINAL("terminal"), STICKY("sticky"), GROUP("group"), BROWSER("browser"), OTHER(""); + + companion object { + /** A missing kind is a terminal — the same backward-compat default `nodeStatesToFlow` applies. */ + fun of(wire: String?): NodeKind = when (wire ?: "terminal") { + "terminal" -> TERMINAL + "sticky" -> STICKY + "group" -> GROUP + "browser" -> BROWSER + else -> OTHER + } + } +} + +data class NodeInfo( + val id: String, + val kind: NodeKind, + val title: String, + val color: String?, + val agentId: String?, + val accountId: String?, + val cwd: String?, + val parentId: String?, + /** A one-grapheme emoji icon, when the node has one (image icons are not drawn on the phone). */ + val iconEmoji: String?, + /** The session id nodeterm minted at launch (`agentSessionId`), a resume fallback. */ + val agentSessionId: String?, + /** Sticky text (sticky nodes only). */ + val text: String? +) + +enum class AgentState(val wire: String) { + WORKING("working"), WAITING("waiting"), BLOCKED("blocked"), DONE("done"); + + companion object { + fun of(wire: String?): AgentState? = entries.firstOrNull { it.wire == wire } + } +} + +/** + * The sessions sidebar's grouping (CLAUDE.md "Status-grouped sessions"): a finished turn, a + * question and an approval ALL wait on the user; `working` is running; no live hook state is + * unknown. There is deliberately no "done" bucket. + */ +enum class SessionBucket { NEEDS_YOU, RUNNING, SLEEPING, UNKNOWN } + +data class AgentNodeStatus( + val state: AgentState?, + val agentId: String?, + val sessionId: String?, + /** The agent's own session name (the `/rename` name), when resolved. */ + val name: String?, + val hibernated: Boolean, + val updatedAt: Long, + /** Observed account label for a non-system Claude account, when the hook reported one. */ + val accountLabel: String? +) { + val bucket: SessionBucket + get() = when { + hibernated -> SessionBucket.SLEEPING + state == AgentState.WORKING -> SessionBucket.RUNNING + state == AgentState.DONE || state == AgentState.WAITING || state == AgentState.BLOCKED -> SessionBucket.NEEDS_YOU + else -> SessionBucket.UNKNOWN + } +} + +data class AgentStatusFile( + val updatedAt: Long, + val nodes: Map, + val settings: MirrorSettings?, + val usage: MirrorUsage?, + val inbox: MirrorInbox?, + val serverVersion: String? +) + +data class MirrorSettings( + val claudePermissionMode: String?, + /** Answers for CLAUDE only: may `--permission-mode auto` be emitted on this host. */ + val autoSupported: Boolean?, + val claudeAccounts: List, + /** Values this host's codex accepts for `--ask-for-approval`; empty = not probed. */ + val codexApprovalValues: List +) + +data class ManagedAccount(val id: String, val dir: String) + +data class MirrorUsage(val updatedAt: Long, val accounts: List) + +data class UsageAccount( + val accountId: String?, + val label: String?, + val email: String?, + val agentId: String, + val status: String, + val updatedAt: Long, + val limits: List +) + +data class UsageLimit( + val kind: String, + val group: String?, + val usedPercent: Double, + val severity: String?, + val resetsAt: Long?, + val windowMinutes: Long?, + val scopeLabel: String?, + val isActive: Boolean +) + +data class MirrorInbox(val events: List, val nodes: Map) + +enum class InboxKind(val wire: String) { + APPROVAL("approval"), QUESTION("question"), DONE("done"); + + companion object { + fun of(wire: String?): InboxKind? = entries.firstOrNull { it.wire == wire } + } +} + +data class InboxEvent( + val id: String, + val ts: Long, + val nodeId: String, + val agentId: String?, + val sessionId: String?, + val kind: InboxKind, + val title: String, + val detail: String?, + val interrupted: Boolean, + val resolved: Boolean, + val options: List, + val multiSelect: Boolean, + /** The hook-reply ticket (docs/hook-reply-approvals.md): answer by writing the `.answer` file. */ + val pendingId: String? +) { + val actionable: Boolean get() = !resolved && (kind == InboxKind.APPROVAL || kind == InboxKind.QUESTION) +} + +data class InboxNodeNow( + val activity: String?, + val tool: String?, + val contextPercent: Double?, + val prompt: String?, + val updatedAt: Long +) + +data class KanbanColumn(val id: String, val title: String, val color: String?) + +data class KanbanLabel(val id: String, val name: String, val color: String) + +data class KanbanCardMeta( + val nodeId: String, + val assignees: List, + val dueAt: Long?, + val priority: String?, + val labels: List +) + +data class KanbanBoard( + val columns: List, + /** nodeId → columnId, in persisted order (order within a column = relative order here). */ + val assignments: List>, + val labels: List, + val meta: List +) { + fun columnOf(nodeId: String): String? = assignments.firstOrNull { it.first == nodeId }?.second + + fun metaOf(nodeId: String): KanbanCardMeta? = meta.firstOrNull { it.nodeId == nodeId } + + companion object { + /** `@shared/kanban-default-board`: the columns a project with no board is shown. Display only — + * a write first asks the host to seed the real board (`projects.ensureBoard`). */ + val DEFAULT_COLUMN_TITLES = listOf("To Do", "In Progress", "Done") + } +} + +/** `src/core/tmux-naming.ts`. */ +object TmuxNames { + const val SOCKET = "node-terminal" + /** The socket a DESKTOP uses for sessions it runs on an SSH host (never the phone's target). */ + const val REMOTE_SOCKET = "nodeterm-rmt" + + fun sessionName(persistKey: String): String = "nt-" + persistKey.replace(Regex("[^a-zA-Z0-9_-]"), "_") + + /** `^nt-[A-Za-z0-9_-]+$` — the only target shape we ever interpolate into a tmux command. */ + fun isSessionName(target: String): Boolean = Regex("^nt-[A-Za-z0-9_-]+$").matches(target) +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHost.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHost.kt new file mode 100644 index 0000000000..09ce0aa8c5 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHost.kt @@ -0,0 +1,87 @@ +package dev.nodeterm.protocol.model + +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.PairingResult +import dev.nodeterm.protocol.pairing.RelayBlock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * One paired computer, as the phone keeps it. Public facts only — the phone's own secrets (its box + * key, its SSH seed) live in the app's Keystore-backed store, never in this record — except the + * relay device token, which is a bearer credential and is therefore persisted by the app through + * the same encrypted store (see the app's `SecureStore`). + */ +data class PairedHost( + /** Local id for this pairing (the host-assigned deviceId — unique per pairing). */ + val id: String, + val name: String, + val host: String, + val port: Int, + val user: String, + val sshAvailable: Boolean, + /** The host's box key from the QR (`hostKey`): the relay identity pinned at pairing. */ + val hostKeyB64: String?, + val relay: RelayBlock?, + /** `SHA256:…` of the SSH host key, pinned on first connect (TOFU). */ + val sshHostKeyFingerprint: String?, + val pairedAt: Long +) { + /** The relay host key: the relay block's when present, else the QR's `hostKey` (same key). */ + val relayHostKeyB64: String? get() = relay?.hostPublicKeyB64 ?: hostKeyB64 + + fun toJson(): JsonObject = buildJsonObject { + put("id", id) + put("name", name) + put("host", host) + put("port", port) + put("user", user) + put("sshAvailable", sshAvailable) + hostKeyB64?.let { put("hostKeyB64", it) } + relay?.let { r -> + put("relay", buildJsonObject { + put("hostId", r.hostId) + put("hostPublicKeyB64", r.hostPublicKeyB64) + put("relayEndpoint", r.relayEndpoint) + }) + } + sshHostKeyFingerprint?.let { put("sshHostKeyFingerprint", it) } + put("pairedAt", pairedAt) + } + + companion object { + fun fromJson(o: JsonObject): PairedHost? { + val id = o.s("id") ?: return null + return PairedHost( + id = id, + name = o.s("name") ?: "Computer", + host = o.s("host") ?: return null, + port = o.l("port")?.toInt() ?: 22, + user = o.s("user") ?: return null, + sshAvailable = o.b("sshAvailable") != false, + hostKeyB64 = o.s("hostKeyB64"), + relay = o.o("relay")?.let(PairingPayload::parseRelayBlock), + sshHostKeyFingerprint = o.s("sshHostKeyFingerprint"), + pairedAt = o.l("pairedAt") ?: 0 + ) + } + + fun from(payload: PairingPayload, result: PairingResult, now: Long = System.currentTimeMillis()) = PairedHost( + id = result.deviceId, + name = payload.name, + host = payload.host, + port = payload.port, + user = payload.user, + sshAvailable = payload.sshAvailable, + hostKeyB64 = payload.hostKey, + relay = result.relay ?: payload.relay, + sshHostKeyFingerprint = null, + pairedAt = now + ) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt new file mode 100644 index 0000000000..46e2b994fe --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt @@ -0,0 +1,218 @@ +package dev.nodeterm.protocol.model + +import dev.nodeterm.protocol.model.J.a +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.d +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.objects +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.J.strings +import kotlinx.serialization.json.JsonObject + +/** + * Parses the `projects.list` blob: `\n--NT-PROJECTS-SPLIT--\n\n--NT-STATUS-SPLIT--\n` (src/main/index.ts `listProjectsOutput`). The + * direct-SSH browse produces the same shape, so both transports share this parser. Every section + * is best-effort: a missing or corrupt one degrades to empty, never to an exception. + */ +object ProjectsParser { + const val PROJECTS_MARK = "--NT-PROJECTS-SPLIT--" + const val STATUS_MARK = "--NT-STATUS-SPLIT--" + + fun parseBlob(blob: String, now: Long = System.currentTimeMillis()): ProjectsSnapshot { + val p = blob.indexOf(PROJECTS_MARK) + val s = blob.indexOf(STATUS_MARK) + val workspaceText = if (p >= 0) blob.substring(0, p) else blob + val sessionsText = if (p >= 0) blob.substring(p + PROJECTS_MARK.length, if (s > p) s else blob.length) else "" + val statusText = if (s >= 0) blob.substring(s + STATUS_MARK.length) else "" + return ProjectsSnapshot( + projects = parseWorkspace(workspaceText), + liveSessions = sessionsText.lineSequence().map { it.trim() }.filter { it.startsWith("nt-") }.toSet(), + status = parseStatus(statusText), + fetchedAt = now + ) + } + + /** The assembled v2 workspace `{version:2, projects:[…]}`. A v3 index carries no nodes and is + * resolved by the SSH browse before it gets here. */ + fun parseWorkspace(text: String): List { + val root = J.obj(J.parse(text)) ?: return emptyList() + return root.objects("projects").mapNotNull(::parseProject) + } + + fun parseProject(p: JsonObject): ProjectInfo? { + val id = p.s("id") ?: return null + val ssh = p.o("ssh")?.o("server") + val sshTarget = ssh?.let { srv -> + val host = srv.s("host") ?: return@let null + val user = srv.s("user") + if (user.isNullOrEmpty()) host else "$user@$host" + } + return ProjectInfo( + id = id, + name = p.s("name") ?: id, + color = p.s("color"), + cwd = p.s("cwd"), + sshTarget = sshTarget, + closed = p.b("closed") == true, + nodes = p.objects("nodes").mapNotNull(::parseNode), + board = p.o("kanban")?.let(::parseBoard) + ) + } + + fun parseNode(n: JsonObject): NodeInfo? { + val id = n.s("id") ?: return null + val icon = n.o("icon") + val emoji = if (icon?.s("type") == "emoji") icon.s("value")?.takeIf { it.isNotBlank() && it.length <= 16 } else null + return NodeInfo( + id = id, + kind = NodeKind.of(n.s("kind")), + title = n.s("title") ?: "", + color = n.s("color"), + agentId = n.s("agentId") ?: legacyAgent(n), + accountId = n.s("accountId"), + cwd = n.s("cwd"), + parentId = n.s("parentId") ?: n.s("group"), + iconEmoji = emoji, + agentSessionId = n.s("agentSessionId"), + text = n.s("text") + ) + } + + /** `nodeStatesToFlow` migrates the legacy `tags:['claude']` marker to `agentId = 'claude'`. */ + private fun legacyAgent(n: JsonObject): String? = if ("claude" in n.strings("tags")) "claude" else null + + fun parseBoard(k: JsonObject): KanbanBoard? { + val columns = k.objects("columns").mapNotNull { c -> + val id = c.s("id") ?: return@mapNotNull null + KanbanColumn(id, c.s("title") ?: "", c.s("color")) + } + // `validKanban` drops a malformed board to the fresh default; the phone does the same by + // reporting none, so the UI shows the default and a write seeds a real one. + if (columns.isEmpty() && k["columns"] == null) return null + val assignments = k.objects("assignments").mapNotNull { a -> + val nodeId = a.s("nodeId") ?: return@mapNotNull null + val columnId = a.s("columnId") ?: return@mapNotNull null + nodeId to columnId + } + val labels = k.objects("labels").mapNotNull { l -> + val id = l.s("id") ?: return@mapNotNull null + KanbanLabel(id, l.s("name") ?: "", l.s("color") ?: "default") + } + val meta = k.objects("meta").mapNotNull { m -> + val nodeId = m.s("nodeId") ?: return@mapNotNull null + KanbanCardMeta( + nodeId = nodeId, + assignees = m.objects("assignees").mapNotNull { it.s("name") }, + dueAt = m.l("dueAt"), + priority = m.s("priority"), + labels = m.strings("labels") + ) + } + return KanbanBoard(columns, assignments, labels, meta) + } + + fun parseStatus(text: String): AgentStatusFile? { + val root = J.obj(J.parse(text)) ?: return null + val nodes = LinkedHashMap() + root.o("nodes")?.forEach { (nodeId, raw) -> + val e = J.obj(raw) ?: return@forEach + val account = e.o("account") + nodes[nodeId] = AgentNodeStatus( + state = AgentState.of(e.s("state")), + agentId = e.s("agentId"), + sessionId = e.s("sessionId"), + name = e.s("name"), + hibernated = e.b("hibernated") == true, + updatedAt = e.l("updatedAt") ?: 0, + accountLabel = account?.let { it.s("label") ?: it.s("accountId") } + ) + } + return AgentStatusFile( + updatedAt = root.l("updatedAt") ?: 0, + nodes = nodes, + settings = root.o("settings")?.let(::parseSettings), + usage = root.o("usage")?.let(::parseUsage), + inbox = root.o("inbox")?.let(::parseInbox), + serverVersion = root.o("server")?.s("version") + ) + } + + private fun parseSettings(s: JsonObject) = MirrorSettings( + claudePermissionMode = s.s("claudePermissionMode"), + autoSupported = s.b("autoSupported"), + claudeAccounts = s.objects("claudeAccounts").mapNotNull { a -> + val id = a.s("id") ?: return@mapNotNull null + val dir = a.s("dir") ?: return@mapNotNull null + ManagedAccount(id, dir) + }, + codexApprovalValues = s.strings("codexApprovalValues") + ) + + private fun parseUsage(u: JsonObject) = MirrorUsage( + updatedAt = u.l("updatedAt") ?: 0, + accounts = u.objects("accounts").map { a -> + UsageAccount( + accountId = a.s("accountId"), + label = a.s("label"), + email = a.s("email"), + agentId = a.s("agentId") ?: "claude", + status = a.s("status") ?: "unavailable", + updatedAt = a.l("updatedAt") ?: 0, + limits = a.objects("limits").mapNotNull { l -> + val kind = l.s("kind") ?: return@mapNotNull null + val used = l.d("usedPercent") ?: return@mapNotNull null + UsageLimit( + kind = kind, + group = l.s("group"), + usedPercent = used, + severity = l.s("severity"), + resetsAt = l.l("resetsAt"), + windowMinutes = l.l("windowMinutes"), + scopeLabel = l.s("scopeLabel"), + isActive = l.b("isActive") == true + ) + } + ) + } + ) + + private fun parseInbox(i: JsonObject): MirrorInbox { + val events = i.objects("events").mapNotNull { e -> + val id = e.s("id") ?: return@mapNotNull null + val nodeId = e.s("nodeId") ?: return@mapNotNull null + val kind = InboxKind.of(e.s("kind")) ?: return@mapNotNull null + InboxEvent( + id = id, + ts = e.l("ts") ?: 0, + nodeId = nodeId, + agentId = e.s("agentId"), + sessionId = e.s("sessionId"), + kind = kind, + title = e.s("title") ?: "", + detail = e.s("detail"), + interrupted = e.b("interrupted") == true, + resolved = e.b("resolved") == true, + options = e.strings("options"), + multiSelect = e.b("multiSelect") == true, + pendingId = e.s("pendingId") + ) + } + val nodes = LinkedHashMap() + i.o("nodes")?.forEach { (nodeId, raw) -> + val n = J.obj(raw) ?: return@forEach + nodes[nodeId] = InboxNodeNow( + activity = n.s("activity"), + tool = n.s("tool"), + contextPercent = n.d("contextPercent"), + prompt = n.s("prompt"), + updatedAt = n.l("updatedAt") ?: 0 + ) + } + return MirrorInbox(events, nodes) + } + + @Suppress("unused") + private fun JsonObject.hasArray(key: String): Boolean = a(key).isNotEmpty() +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt new file mode 100644 index 0000000000..f44db0a4d5 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt @@ -0,0 +1,152 @@ +package dev.nodeterm.protocol.pairing + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.E2ee +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.s +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.InetSocketAddress +import java.net.Socket + +class PairingException(message: String) : Exception(message) + +/** + * The phone side of `src/main/pairing-service.ts`'s `/pair` listener. + * + * Request body `{token, publicKey, deviceName, deviceId, priorDeviceToken?}`. When the QR carried a + * `hostKey`, the whole body is sealed to it — `{epk: , box: base64(nonce ‖ + * secretbox)}` under `box.before(hostKey, ephemeralSecret)` — and the answer comes back sealed the + * same way, so the relay device token never crosses the LAN in the clear. + * + * Spoken over a RAW TCP socket rather than an HTTP client, like the iOS app: a bare-IP `http://` + * URL is cleartext traffic Android's network security policy blocks by default for HTTP stacks, + * and the desktop frames every response with an explicit Content-Length precisely so a minimal + * reader works (pairing-service.ts `send`). A non-2xx body is plain text meant for the user. + */ +class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readTimeoutMs: Int = 20_000) { + + suspend fun pair( + payload: PairingPayload, + sshPublicKeyLine: String, + deviceName: String, + deviceId: String, + priorDeviceToken: String? = null + ): PairingResult = withContext(Dispatchers.IO) { + val inner = buildJsonObject { + put("token", payload.token) + put("publicKey", sshPublicKeyLine) + put("deviceName", deviceName) + put("deviceId", deviceId) + priorDeviceToken?.let { put("priorDeviceToken", it) } + }.toString() + + var shared: ByteArray? = null + val body = if (payload.hostKey != null) { + val eph = BoxKeyPair.generate() + val key = E2ee.deriveSharedKey(payload.hostKey, eph.secretKey) + shared = key + buildJsonObject { + put("epk", eph.publicKeyB64) + put("box", B64.encode(E2ee.encrypt(inner.toByteArray(Charsets.UTF_8), key))) + }.toString() + } else { + inner + } + + val (status, text) = postRaw(payload.host, payload.pairPort, "/pair", body) + if (status !in 200..299) { + throw PairingException( + if (text.isNotBlank()) "The computer rejected pairing: ${text.trim()}" + else "The computer rejected pairing (HTTP $status)." + ) + } + var obj = J.obj(J.parse(text)) ?: throw PairingException("The computer answered with something that is not JSON.") + if (shared != null) { + val sealed = obj.s("box")?.let(B64::decode) + ?: throw PairingException("The computer's answer was not encrypted as expected.") + val plain = E2ee.decrypt(sealed, shared) + ?: throw PairingException("Couldn't decrypt the computer's answer — was the QR code from this computer?") + obj = J.obj(J.parse(String(plain, Charsets.UTF_8))) + ?: throw PairingException("The computer's answer was not valid JSON.") + } + if (obj.b("ok") != true) throw PairingException("The computer did not confirm the pairing.") + PairingResult( + deviceId = obj.s("deviceId") ?: throw PairingException("The computer did not assign a device id."), + agentToken = obj.s("agentToken") ?: "", + relay = obj.o("relay")?.let(PairingPayload::parseRelayBlock), + relayDeviceToken = obj.s("relayDeviceToken") + ) + } + + private fun postRaw(host: String, port: Int, path: String, body: String): Pair { + val bytes = body.toByteArray(Charsets.UTF_8) + try { + Socket().use { sock -> + sock.connect(InetSocketAddress(host, port), connectTimeoutMs) + sock.soTimeout = readTimeoutMs + val hostHeader = if (host.contains(':')) "[$host]:$port" else "$host:$port" + val head = "POST $path HTTP/1.1\r\n" + + "Host: $hostHeader\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: ${bytes.size}\r\n" + + "Connection: close\r\n\r\n" + val out = sock.getOutputStream() + out.write(head.toByteArray(Charsets.US_ASCII)) + out.write(bytes) + out.flush() + return readResponse(sock.getInputStream()) + } + } catch (e: IOException) { + throw PairingException( + "Couldn't reach the computer at $host:$port (${e.message ?: e.javaClass.simpleName}). " + + "Is the phone on the same network, and is the pairing code still on screen?" + ) + } + } + + /** Minimal HTTP/1.1 response reader: status line, headers, then Content-Length (or EOF) body. */ + internal fun readResponse(input: InputStream): Pair { + val headBytes = ByteArrayOutputStream() + var last4 = 0 + while (true) { + val c = input.read() + if (c < 0) break + headBytes.write(c) + last4 = (last4 shl 8) or c + if (last4 == 0x0d0a0d0a) break + if (headBytes.size() > 64 * 1024) throw IOException("response header too large") + } + val head = headBytes.toString(Charsets.ISO_8859_1.name()) + val lines = head.split("\r\n") + val status = lines.firstOrNull()?.split(' ')?.getOrNull(1)?.toIntOrNull() + ?: throw IOException("malformed HTTP response") + val length = lines.drop(1).firstNotNullOfOrNull { line -> + val idx = line.indexOf(':') + if (idx > 0 && line.substring(0, idx).trim().equals("content-length", ignoreCase = true)) { + line.substring(idx + 1).trim().toIntOrNull() + } else null + } + val body = if (length != null) { + val buf = ByteArray(length) + var off = 0 + while (off < length) { + val n = input.read(buf, off, length - off) + if (n < 0) break + off += n + } + buf.copyOf(off) + } else { + input.readBytes() + } + return status to String(body, Charsets.UTF_8) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt new file mode 100644 index 0000000000..8b24d1975e --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt @@ -0,0 +1,89 @@ +package dev.nodeterm.protocol.pairing + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.s +import kotlinx.serialization.json.JsonObject +import java.net.URI + +/** `RelayPairingBlock` (pairing-core.ts): how to reach this host over the relay. */ +data class RelayBlock(val hostId: String, val hostPublicKeyB64: String, val relayEndpoint: String) + +/** + * The QR the desktop shows under Settings → Phone (`buildPairingPayload`, pairing-core.ts): + * `{"v":1,"host":…,"port":22,"user":…,"token":…,"pairPort":N,"nodeterm":true,"name":…, + * "hostKey"?:…, "relay"?:{…}, "ssh"?:false}`. + */ +data class PairingPayload( + val host: String, + val port: Int, + val user: String, + val token: String, + val pairPort: Int, + val name: String, + /** The host's NaCl box key: seal `/pair` to it. Also the relay host key. */ + val hostKey: String?, + val relay: RelayBlock?, + /** False on a Windows host: no SSH key is installed; the phone must use the relay. */ + val sshAvailable: Boolean +) { + companion object { + /** Null for anything that is not a nodeterm pairing payload — never throws. */ + fun parse(text: String): PairingPayload? { + val o = J.obj(J.parse(text.trim())) ?: return null + if (o.l("v") != 1L || o.b("nodeterm") != true) return null + val host = o.s("host")?.takeIf { it.isNotBlank() } ?: return null + val user = o.s("user")?.takeIf { it.isNotBlank() } ?: return null + val token = o.s("token")?.takeIf { it.isNotBlank() } ?: return null + val pairPort = o.l("pairPort")?.toInt()?.takeIf { it in 1..65535 } ?: return null + val port = o.l("port")?.toInt()?.takeIf { it in 1..65535 } ?: 22 + val hostKey = o.s("hostKey")?.takeIf { B64.decode(it)?.size == 32 } + return PairingPayload( + host = host, + port = port, + user = user, + token = token, + pairPort = pairPort, + name = o.s("name")?.takeIf { it.isNotBlank() } ?: host, + hostKey = hostKey, + relay = o.o("relay")?.let(::parseRelayBlock), + sshAvailable = o.b("ssh") != false + ) + } + + fun parseRelayBlock(r: JsonObject): RelayBlock? { + val hostId = r.s("hostId")?.takeIf { it.isNotBlank() } ?: return null + val pub = r.s("hostPublicKeyB64")?.takeIf { B64.decode(it)?.size == 32 } ?: return null + val endpoint = r.s("relayEndpoint")?.takeIf(::isAllowedRelayEndpoint) ?: return null + return RelayBlock(hostId, pub, endpoint) + } + + /** + * R5 (pairing.ts): the phone dials the relay endpoint verbatim, so a payload must not point + * it at plaintext — `wss:` only, `ws:` solely for loopback (a local relay in dev/tests). + */ + fun isAllowedRelayEndpoint(endpoint: String): Boolean { + val uri = try { + URI(endpoint) + } catch (_: Exception) { + return false + } + return when (uri.scheme) { + "wss" -> !uri.host.isNullOrEmpty() + "ws" -> uri.host in setOf("127.0.0.1", "localhost", "::1", "[::1]") + else -> false + } + } + } +} + +/** What `/pair` answered: the host-minted identity plus, when remote access is on, a relay leg. */ +data class PairingResult( + val deviceId: String, + val agentToken: String, + val relay: RelayBlock?, + val relayDeviceToken: String? +) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/SshIdentity.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/SshIdentity.kt new file mode 100644 index 0000000000..99eb2fd5a2 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/SshIdentity.kt @@ -0,0 +1,56 @@ +package dev.nodeterm.protocol.pairing + +import dev.nodeterm.protocol.crypto.B64 +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.EdDSAPublicKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.security.KeyPair +import java.security.SecureRandom + +/** + * The phone's SSH identity: an Ed25519 key generated ON the device. Pairing sends only the public + * half (`ssh-ed25519 AAAA… comment`), which the desktop appends to `~/.ssh/authorized_keys` under + * its own `nodeterm-ios-` comment (pairing-core.ts `rewriteKeyComment` — the comment the + * phone sends is replaced, so the Android comment here is cosmetic). The private seed never leaves + * the phone; the app keeps it encrypted under an Android Keystore key. + */ +class SshIdentity private constructor(val seed: ByteArray) { + private val spec = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519) + private val privateSpec = EdDSAPrivateKeySpec(seed, spec) + + /** The raw 32-byte public point A. */ + val publicKeyBytes: ByteArray = privateSpec.a.toByteArray() + + val keyPair: KeyPair + get() = KeyPair(EdDSAPublicKey(EdDSAPublicKeySpec(privateSpec.a, spec)), EdDSAPrivateKey(privateSpec)) + + /** OpenSSH wire blob: string("ssh-ed25519") ‖ string(A). */ + val publicKeyBlob: ByteArray + get() { + val out = ByteArrayOutputStream() + DataOutputStream(out).use { d -> + val name = "ssh-ed25519".toByteArray(Charsets.US_ASCII) + d.writeInt(name.size) + d.write(name) + d.writeInt(publicKeyBytes.size) + d.write(publicKeyBytes) + } + return out.toByteArray() + } + + fun authorizedKeysLine(comment: String = "nodeterm-android"): String = + "ssh-ed25519 ${B64.encode(publicKeyBlob)} $comment" + + companion object { + fun generate(random: SecureRandom = SecureRandom()): SshIdentity = SshIdentity(ByteArray(32).also(random::nextBytes)) + + fun fromSeed(seed: ByteArray): SshIdentity { + require(seed.size == 32) { "Ed25519 seed must be 32 bytes" } + return SshIdentity(seed.copyOf()) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/Framing.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/Framing.kt new file mode 100644 index 0000000000..0cac4befbf --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/Framing.kt @@ -0,0 +1,113 @@ +package dev.nodeterm.protocol.relay + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * `src/main/remote/framing.ts`: the terminal-stream binary frame the standing phone host speaks. + * + * Header (16 bytes, little-endian): kind 0x74 · version 1 · opcode · reserved · streamId u32 · + * seq high u32 · seq low u32, then the raw payload. Opcode numbers are a stable wire contract. + */ +object Op { + const val OUTPUT = 1 + const val SNAPSHOT_START = 2 + const val SNAPSHOT_CHUNK = 3 + const val SNAPSHOT_END = 4 + const val RESIZED = 5 + const val ERROR = 6 + const val INPUT = 7 + const val RESIZE = 8 + const val SUBSCRIBE = 9 + const val UNSUBSCRIBE = 10 + const val SNAPSHOT_REQUEST = 11 + + private val KNOWN = setOf( + OUTPUT, SNAPSHOT_START, SNAPSHOT_CHUNK, SNAPSHOT_END, RESIZED, ERROR, INPUT, RESIZE, + SUBSCRIBE, UNSUBSCRIBE, SNAPSHOT_REQUEST + ) + + fun isKnown(op: Int): Boolean = op in KNOWN +} + +class Frame(val op: Int, val streamId: Long, val seq: Long, val payload: ByteArray) + +object Framing { + private const val STREAM_KIND = 0x74 + private const val STREAM_VERSION = 1 + const val HEADER_BYTES = 16 + + fun encode(op: Int, streamId: Long, seq: Long, payload: ByteArray): ByteArray { + val out = ByteBuffer.allocate(HEADER_BYTES + payload.size).order(ByteOrder.LITTLE_ENDIAN) + out.put(STREAM_KIND.toByte()) + out.put(STREAM_VERSION.toByte()) + out.put(op.toByte()) + out.put(0) + out.putInt(streamId.toInt()) + val s = maxOf(0L, seq) + out.putInt((s ushr 32).toInt()) + out.putInt(s.toInt()) + out.put(payload) + return out.array() + } + + /** Null for a short buffer, a bad kind/version, or an unknown opcode — never throws. */ + fun decode(buf: ByteArray): Frame? { + if (buf.size < HEADER_BYTES) return null + val b = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN) + if ((b.get(0).toInt() and 0xff) != STREAM_KIND || (b.get(1).toInt() and 0xff) != STREAM_VERSION) return null + val op = b.get(2).toInt() and 0xff + if (!Op.isKnown(op)) return null + val streamId = b.getInt(4).toLong() and 0xffffffffL + val high = b.getInt(8).toLong() and 0xffffffffL + val low = b.getInt(12).toLong() and 0xffffffffL + return Frame(op, streamId, (high shl 32) or low, buf.copyOfRange(HEADER_BYTES, buf.size)) + } + + /** `OP.Resize` / `OP.Resized` payload: two uint16 LE, cols then rows. */ + fun sizePayload(cols: Int, rows: Int): ByteArray = + ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) + .putShort(cols.coerceIn(1, 0xffff).toShort()) + .putShort(rows.coerceIn(1, 0xffff).toShort()) + .array() + + fun readSize(payload: ByteArray): Pair? { + if (payload.size < 4) return null + val b = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN) + return (b.getShort(0).toInt() and 0xffff) to (b.getShort(2).toInt() and 0xffff) + } +} + +/** + * `snapshot.ts`: the current-screen paint arrives as Start · Chunk* · End on the stream. Chunk + * BYTES are accumulated and decoded only on End, so a multi-byte code point split across a chunk + * boundary survives. + */ +class SnapshotReassembler { + private val chunks = ArrayList() + private var open = false + + /** Returns the reassembled text on End, else null. */ + fun accept(frame: Frame): String? { + when (frame.op) { + Op.SNAPSHOT_START -> { + chunks.clear() + open = true + } + Op.SNAPSHOT_CHUNK -> if (open && frame.payload.isNotEmpty()) chunks.add(frame.payload) + Op.SNAPSHOT_END -> { + val total = chunks.sumOf { it.size } + val merged = ByteArray(total) + var off = 0 + for (c in chunks) { + System.arraycopy(c, 0, merged, off, c.size) + off += c.size + } + chunks.clear() + open = false + return String(merged, Charsets.UTF_8) + } + } + return null + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/OkHttpRelayTransport.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/OkHttpRelayTransport.kt new file mode 100644 index 0000000000..3b758b667d --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/OkHttpRelayTransport.kt @@ -0,0 +1,56 @@ +package dev.nodeterm.protocol.relay + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import okio.ByteString.Companion.toByteString +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The production [RelayTransport]: an OkHttp WebSocket. Text frames stay text and binary frames + * stay binary end to end — the relay preserves the distinction and the handshake depends on it. + * OkHttp queues sends issued before the upgrade completes and delivers messages on one reader + * thread in wire order, which is the ordering [RelaySocket] relies on. + */ +class OkHttpRelayTransport private constructor(private val ws: WebSocket) : RelayTransport { + override fun sendText(text: String): Boolean = ws.send(text) + override fun sendBinary(bytes: ByteArray): Boolean = ws.send(bytes.toByteString()) + override fun queuedBytes(): Long = ws.queueSize() + override fun close() { + if (!ws.close(1000, null)) ws.cancel() + } + + companion object { + val defaultClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + // Transport-level liveness on top of the E2EE keepalive: a phone that roams between + // networks otherwise holds a dead socket until the OS gives up on it. + .pingInterval(20, TimeUnit.SECONDS) + .build() + } + + fun factory(client: OkHttpClient = defaultClient) = RelayTransportFactory { url, events -> + val closed = AtomicBoolean(false) + val fireClosed = { reason: String? -> if (closed.compareAndSet(false, true)) events.onClosed(reason) } + val ws = client.newWebSocket(Request.Builder().url(url).build(), object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) = events.onText(text) + override fun onMessage(webSocket: WebSocket, bytes: ByteString) = events.onBinary(bytes.toByteArray()) + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + fireClosed(reason.ifEmpty { "closed ($code)" }) + } + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = + fireClosed(reason.ifEmpty { "closed ($code)" }) + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = + fireClosed(response?.let { "HTTP ${it.code}" } ?: t.message ?: t.javaClass.simpleName) + }) + OkHttpRelayTransport(ws) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelayApi.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelayApi.kt new file mode 100644 index 0000000000..7c3458d972 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelayApi.kt @@ -0,0 +1,95 @@ +package dev.nodeterm.protocol.relay + +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.s +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException + +class RelayApiException(message: String, val status: Int? = null) : Exception(message) + +data class RelayJoin(val pairingToken: String, val exp: Long) + +data class RelayDevice(val deviceToken: String, val hostId: String, val exp: Long) + +/** + * The two backend calls a phone makes for the relay leg (`api.nodeterm.dev`, a separate repo): + * + * - `POST /v1/relay/join` — trade the device token minted at pairing for a SINGLE-USE, short-lived + * (~120 s) relay token for this host's room. A new one per connect: relay-socket never re-dials + * on an old token (standing-host.ts manual smoke test, step 3; docs/ios-protocol-migration.md §8.5). + * - `POST /v1/relay/device` — the free-tier TOFU mint, for LATE ADOPTION: a phone paired while + * remote access was off reads `~/.nodeterm/relay.json` over its SSH bootstrap and mints its own + * device token (src/main/remote/relay-advertise.ts). Same body the desktop sends for a free mint. + * + * The join body/response shape is not documented in this repo (the backend lives elsewhere); it is + * read tolerantly — `pairingToken`, `token` or `joinToken` — and every failure says what happened. + */ +class RelayApi( + val apiBase: String = DEFAULT_API_BASE, + private val client: OkHttpClient = OkHttpRelayTransport.defaultClient +) { + suspend fun join(deviceToken: String, hostId: String): RelayJoin { + val body = post("/v1/relay/join", buildJsonObject { + put("deviceToken", deviceToken) + put("hostId", hostId) + }) + val token = body.s("pairingToken") ?: body.s("token") ?: body.s("joinToken") + ?: throw RelayApiException("The relay did not hand out a connection token.") + return RelayJoin(token, body.l("exp") ?: 0) + } + + suspend fun mintDevice( + deviceId: String, + hostDeviceId: String, + hostPublicKeyB64: String, + label: String, + priorDeviceToken: String? + ): RelayDevice { + val body = post("/v1/relay/device", buildJsonObject { + put("deviceId", deviceId) + put("hostDeviceId", hostDeviceId) + put("hostPublicKeyB64", hostPublicKeyB64) + put("label", label) + priorDeviceToken?.let { put("priorDeviceToken", it) } + }) + val token = body.s("deviceToken") ?: throw RelayApiException("The relay did not mint a device token.") + return RelayDevice(token, body.s("hostId") ?: "", body.l("exp") ?: 0) + } + + private suspend fun post(path: String, json: JsonObject): JsonObject = withContext(Dispatchers.IO) { + val req = Request.Builder() + .url(apiBase.trimEnd('/') + path) + .post(json.toString().toRequestBody(JSON)) + .build() + try { + client.newCall(req).execute().use { res -> + val text = res.body?.string().orEmpty() + if (!res.isSuccessful) { + val msg = J.obj(J.parse(text))?.let { it.s("error") ?: it.s("message") } + throw RelayApiException( + "The relay refused the request (HTTP ${res.code})" + (msg?.let { ": $it" } ?: "."), + res.code + ) + } + J.obj(J.parse(text)) ?: throw RelayApiException("The relay answered with something that is not JSON.") + } + } catch (e: IOException) { + throw RelayApiException("Couldn't reach the relay: ${e.message ?: e.javaClass.simpleName}") + } + } + + companion object { + const val DEFAULT_API_BASE = "https://api.nodeterm.dev" + const val DEFAULT_RELAY_URL = "wss://relay.nodeterm.dev" + private val JSON = "application/json".toMediaType() + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelaySocket.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelaySocket.kt new file mode 100644 index 0000000000..8c8718a4d5 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelaySocket.kt @@ -0,0 +1,355 @@ +package dev.nodeterm.protocol.relay + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.E2ee +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** A duplex relay connection: text frames carry the plaintext handshake, binary frames E2EE boxes. */ +interface RelayTransport { + fun sendText(text: String): Boolean + fun sendBinary(bytes: ByteArray): Boolean + /** Bytes handed to the socket and not yet flushed — the backpressure signal. */ + fun queuedBytes(): Long + fun close() +} + +/** Inbound side of a [RelayTransport]. The transport must call these in wire order, one at a time. */ +interface RelayTransportEvents { + fun onText(text: String) + fun onBinary(bytes: ByteArray) + /** Fired at most once, on a close OR an error. */ + fun onClosed(reason: String?) +} + +fun interface RelayTransportFactory { + fun open(url: String, events: RelayTransportEvents): RelayTransport +} + +/** What an application layer sees of a live relay session. */ +interface RelaySocketListener { + /** The E2EE handshake completed. `sas` is the 6-digit code the desktop shows beside the approval. */ + fun onReady(sas: String) {} + /** A one-way host → phone notification (`canvas:state` today). */ + fun onNotify(method: String, params: JsonElement?) {} + /** A decoded terminal frame. */ + fun onFrame(frame: Frame) {} + /** The connection dropped on its own (never fired for [RelaySocket.close]). */ + fun onClosed(reason: String?) {} +} + +class RpcException(message: String) : Exception(message) + +/** + * The CLIENT (phone) role of `src/main/remote/relay-socket.ts`, rule for rule: + * + * - the token rides the URL as `?token=` (never a data frame); + * - handshake `e2ee_hello → e2ee_ready → e2ee_auth → e2ee_authenticated`; base key = + * `box.before(hostPub, ourSecret)`, session key = HKDF(base, hostNonce ‖ clientNonce); + * - every box's sealed plaintext is `[role][seq u64 as two LE u32, high then low][tag][body]`; + * only boxes carrying the HOST role (1) are accepted (a box with our own role is a relay + * reflection) and only with a strictly increasing seq (replay/reorder); + * - once ready, a plaintext handshake frame is IGNORED — never re-keyed, never a reason to close + * (docs/ios-protocol-migration.md §2.4); + * - the host key is pinned: the one passed in is the only identity this session may use. + * + * Reconnect is the caller's job: a relay token is single-use, so re-dialing here with the same one + * would only be refused. Callers mint a fresh token and build a new socket. + */ +class RelaySocket( + url: String, + token: String, + private val ourKeys: BoxKeyPair, + private val hostPublicKeyB64: String, + transportFactory: RelayTransportFactory, + private val listener: RelaySocketListener, + private val scheduler: ScheduledExecutorService = sharedScheduler +) { + private enum class State { HANDSHAKING, READY, CLOSED } + + private class Pending(val onResult: (Result) -> Unit, val timeout: ScheduledFuture<*>?) + + private val lock = Any() + private var state = State.HANDSHAKING + private var intentionallyClosed = false + private val baseKey: ByteArray = E2ee.deriveSharedKey(hostPublicKeyB64, ourKeys.secretKey) + private val ourNonce: ByteArray = E2ee.randomSessionNonce() + private var sessionKey: ByteArray? = null + private var sendSeq = 0L + private var recvSeq = -1L + private var readyFired = false + private var requestCounter = 0 + private val pending = HashMap() + private var keepalive: ScheduledFuture<*>? = null + private val transport: RelayTransport + + /** The SAS for this channel. Derived from the pinned host key, so it is known before `ready`. */ + val sas: String = E2ee.sasFromSharedKey(baseKey) + + init { + val events = object : RelayTransportEvents { + override fun onText(text: String) = handleControl(text) + override fun onBinary(bytes: ByteArray) = handleBinary(bytes) + override fun onClosed(reason: String?) = handleClose(reason) + } + transport = transportFactory.open(withToken(url, token), events) + val hello = buildJsonObject { + put("type", "e2ee_hello") + put("publicKeyB64", ourKeys.publicKeyB64) + put("nonceB64", B64.encode(ourNonce)) + } + transport.sendText(hello.toString()) + } + + val isReady: Boolean get() = synchronized(lock) { state == State.READY } + + // ---- inbound -------------------------------------------------------------------------- + + private fun handleControl(raw: String) { + synchronized(lock) { + // SECURITY: after ready (or once a session key exists) a plaintext control frame is a + // re-key attempt. Drop it without re-keying and without closing. + if (readyFired || sessionKey != null || state == State.CLOSED) return + val msg = parseObject(raw) ?: return + if (msg.str("type") != "e2ee_ready") return + val hostNonce = msg.str("nonceB64")?.let(B64::decode) ?: return + if (hostNonce.size != 16) return + val key = E2ee.deriveSessionKey(baseKey, hostNonce, ourNonce) + sessionKey = key + sendSealedLocked(TAG_RPC, """{"type":"e2ee_auth"}""".toByteArray()) + } + } + + private fun handleBinary(bytes: ByteArray) { + val effects = ArrayList<() -> Unit>() + synchronized(lock) { + if (state == State.CLOSED) return + val key = sessionKey ?: return + val sealed = E2ee.decrypt(bytes, key) ?: return + if (sealed.size < HEADER_BYTES) return + if (sealed[0].toInt() != HOST_ROLE) return + val b = ByteBuffer.wrap(sealed).order(ByteOrder.LITTLE_ENDIAN) + val seq = ((b.getInt(1).toLong() and 0xffffffffL) shl 32) or (b.getInt(5).toLong() and 0xffffffffL) + if (seq <= recvSeq) return + recvSeq = seq + if (sealed.size < HEADER_BYTES + 1) return + val tag = sealed[HEADER_BYTES].toInt() and 0xff + val body = sealed.copyOfRange(HEADER_BYTES + 1, sealed.size) + if (state == State.HANDSHAKING) { + if (tag != TAG_RPC) return + val msg = parseObject(String(body, Charsets.UTF_8)) ?: return + if (msg.str("type") == "e2ee_authenticated") { + state = State.READY + readyFired = true + startKeepaliveLocked() + effects += { listener.onReady(sas) } + } + return@synchronized + } + when (tag) { + TAG_FRAME -> Framing.decode(body)?.let { f -> effects += { listener.onFrame(f) } } + TAG_RPC -> handleEnvelopeLocked(String(body, Charsets.UTF_8), effects) + // TAG_TUNNEL_* belong to the rpc.ts dialect the standing phone host does not speak. + else -> Unit + } + } + for (e in effects) e() + } + + private fun handleEnvelopeLocked(raw: String, effects: MutableList<() -> Unit>) { + val env = parseObject(raw) ?: return + when (env.str("kind")) { + "res" -> { + val id = env.str("id") ?: return + val waiter = pending.remove(id) ?: return + waiter.timeout?.cancel(false) + val ok = (env["ok"] as? JsonPrimitive)?.contentOrNull == "true" + val body = env["body"] + val result = if (ok) Result.success(body) else Result.failure(RpcException(errorMessage(body))) + effects += { waiter.onResult(result) } + } + "notify" -> { + val method = env.str("method") ?: return + val params = env["params"] + effects += { listener.onNotify(method, params) } + } + // "keepalive" and host→client "req" (the legacy dialect never sends one) are ignored. + else -> Unit + } + } + + private fun handleClose(reason: String?) { + val waiters: List + val notify: Boolean + synchronized(lock) { + if (state == State.CLOSED) return + state = State.CLOSED + keepalive?.cancel(false) + keepalive = null + sessionKey = null + waiters = pending.values.toList() + pending.clear() + notify = !intentionallyClosed + } + for (w in waiters) { + w.timeout?.cancel(false) + w.onResult(Result.failure(RpcException("Relay connection closed."))) + } + if (notify) listener.onClosed(reason) + } + + // ---- outbound ------------------------------------------------------------------------- + + private fun sendSealedLocked(tag: Int, body: ByteArray): Boolean { + val key = sessionKey ?: return false + val seq = sendSeq++ + val plain = ByteBuffer.allocate(HEADER_BYTES + 1 + body.size).order(ByteOrder.LITTLE_ENDIAN) + plain.put(CLIENT_ROLE.toByte()) + plain.putInt((seq ushr 32).toInt()) + plain.putInt(seq.toInt()) + plain.put(tag.toByte()) + plain.put(body) + return transport.sendBinary(E2ee.encrypt(plain.array(), key)) + } + + private fun startKeepaliveLocked() { + keepalive?.cancel(false) + keepalive = scheduler.scheduleWithFixedDelay({ + synchronized(lock) { + if (state == State.READY) sendSealedLocked(TAG_RPC, """{"kind":"keepalive"}""".toByteArray()) + } + }, KEEPALIVE_MS, KEEPALIVE_MS, TimeUnit.MILLISECONDS) + } + + /** + * Send an RPC request. `onResult` runs on the transport's reader thread for a response — + * BEFORE any later message is processed, which is what lets `pty.attach` register its stream + * before the snapshot frames that follow the response arrive — or on a timer / closing thread + * for a timeout or a closed socket. + */ + fun request( + method: String, + params: JsonElement? = null, + timeoutMs: Long = RPC_TIMEOUT_MS, + onResult: (Result) -> Unit + ) { + val failure: RpcException? + synchronized(lock) { + if (state != State.READY) { + failure = RpcException("Relay socket is not connected.") + } else { + requestCounter += 1 + val id = "client-rpc-$requestCounter-${System.currentTimeMillis()}" + val timer = scheduler.schedule({ + val w = synchronized(lock) { pending.remove(id) } + w?.onResult?.invoke(Result.failure(RpcException("RPC timed out: $method"))) + }, timeoutMs, TimeUnit.MILLISECONDS) + pending[id] = Pending(onResult, timer) + val env = buildJsonObject { + put("kind", "req") + put("id", id) + put("method", method) + put("params", params ?: JsonObject(emptyMap())) + } + if (sendSealedLocked(TAG_RPC, env.toString().toByteArray(Charsets.UTF_8))) { + failure = null + } else { + pending.remove(id) + timer.cancel(false) + failure = RpcException("Relay socket is not connected.") + } + } + } + if (failure != null) onResult(Result.failure(failure)) + } + + suspend fun call(method: String, params: JsonElement? = null, timeoutMs: Long = RPC_TIMEOUT_MS): JsonElement? = + suspendCancellableCoroutine { cont -> + request(method, params, timeoutMs) { r -> + r.fold({ cont.resume(it) }, { cont.resumeWithException(it) }) + } + } + + /** Send a terminal frame. False when not ready or over the buffered-bytes ceiling. */ + fun sendFrame(op: Int, streamId: Long, seq: Long, payload: ByteArray): Boolean = synchronized(lock) { + if (state != State.READY) return false + if (transport.queuedBytes() > MAX_BINARY_BUFFERED_BYTES) return false + sendSealedLocked(TAG_FRAME, Framing.encode(op, streamId, seq, payload)) + } + + /** Intentional, final close: pending requests fail, [RelaySocketListener.onClosed] is NOT fired. */ + fun close() { + synchronized(lock) { + if (state == State.CLOSED) return + intentionallyClosed = true + } + handleClose("closed") + transport.close() + } + + private fun parseObject(raw: String): JsonObject? = try { + Json.parseToJsonElement(raw) as? JsonObject + } catch (_: Exception) { + null + } + + private fun JsonObject.str(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun errorMessage(body: JsonElement?): String { + val o = body as? JsonObject ?: return "RPC failed." + return o.str("message") ?: o.str("error") ?: "RPC failed." + } + + companion object { + const val HOST_ROLE = 1 + const val CLIENT_ROLE = 2 + const val HEADER_BYTES = 9 + const val TAG_RPC = 0x01 + const val TAG_FRAME = 0x02 + const val RPC_TIMEOUT_MS = 30_000L + const val KEEPALIVE_MS = 25_000L + const val MAX_BINARY_BUFFERED_BYTES = 8L * 1024 * 1024 + + val sharedScheduler: ScheduledExecutorService by lazy { + Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "nodeterm-relay-timer").apply { isDaemon = true } } + } + + /** `?token=` exactly as `encodeURIComponent` would write it (`&` when a query already exists). */ + fun withToken(url: String, token: String): String { + val sep = if (url.contains('?')) '&' else '?' + return "$url${sep}token=${encodeUriComponent(token)}" + } + + fun encodeUriComponent(s: String): String { + val unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()" + val sb = StringBuilder() + for (b in s.toByteArray(Charsets.UTF_8)) { + val c = (b.toInt() and 0xff).toChar() + if (b >= 0 && unreserved.indexOf(c) >= 0) sb.append(c) + else sb.append('%').append("0123456789ABCDEF"[(b.toInt() shr 4) and 0xf]).append("0123456789ABCDEF"[b.toInt() and 0xf]) + } + return sb.toString() + } + + internal val JsonElement?.isJsonNull: Boolean get() = this == null || this is JsonNull + internal fun JsonElement?.stringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content + internal fun JsonElement?.primitiveContent(): String? = (this as? JsonPrimitive)?.jsonPrimitive?.contentOrNull + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt new file mode 100644 index 0000000000..6cc97cb58c --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -0,0 +1,339 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.objects +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.pairing.SshIdentity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import net.schmizz.sshj.DefaultConfig +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import java.io.OutputStream +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.PublicKey +import java.util.Base64 +import java.util.concurrent.TimeUnit + +/** Trust-on-first-use pin for the computer's SSH host key (`SHA256:`, OpenSSH's format). */ +interface HostKeyPin { + /** The pinned fingerprint, or null before the first successful connect. */ + fun pinned(): String? + fun pin(fingerprint: String) +} + +class HostKeyChangedException(val expected: String, val actual: String) : + Exception("This computer's SSH host key changed (expected $expected, got $actual). If you reinstalled it, remove and re-pair it; otherwise someone may be intercepting the connection.") + +/** + * [HostConnection] over direct SSH (the LAN leg a pairing installs a key for). Everything is POSIX + * sh + tmux on the computer ([SshScripts]); what needs the DESKTOP APP rather than the machine — + * renderer nudges, board writes, node registration, git through the app — is the relay's, and this + * transport reports it as unavailable instead of guessing. + */ +class SshHostConnection private constructor(private val client: SSHClient) : HostConnection { + override val kind = TransportKind.SSH + override val capabilities = HostCapabilities( + boardWrites = false, git = false, nodeActions = false, registerNode = false, answerApprovals = true + ) + + @Volatile private var onClosed: ((String?) -> Unit)? = null + @Volatile private var userData: String? = null + + /** Run a script through `/bin/sh -c` and return stdout (bounded wait). */ + internal fun run(script: String, timeoutSec: Long = 20): Pair { + client.startSession().use { session -> + val cmd = session.exec("/bin/sh -c " + SshScripts.q(script)) + val out = cmd.inputStream.readBytes() + cmd.join(timeoutSec, TimeUnit.SECONDS) + return cmd.exitStatus to String(out, Charsets.UTF_8) + } + } + + override suspend fun listProjects(): ProjectsSnapshot = withContext(Dispatchers.IO) { + val (_, raw) = run(SshScripts.browse()) + val metaEnd = raw.indexOf(SshScripts.META_END) + val meta = if (metaEnd >= 0) raw.substring(0, metaEnd) else "" + val blob = if (metaEnd >= 0) raw.substring(metaEnd + SshScripts.META_END.length).removePrefix("\n") else raw + val ud = meta.lineSequence().firstOrNull { it.startsWith("ud=") }?.removePrefix("ud=")?.takeIf { it.isNotBlank() } + userData = ud + val base = ProjectsParser.parseBlob(blob) + val wsText = blob.substringBefore(ProjectsParser.PROJECTS_MARK) + val root = J.obj(J.parse(wsText)) + val snapshot = if (root != null && J.long(root["version"]) == 3L) { + base.copy(projects = resolveIndexV3(root, ud)) + } else { + base + } + rememberCwds(snapshot) + snapshot + } + + /** + * The on-disk workspace.json is a v3 INDEX: folder refs carry no nodes (their canvas is in + * `/.nodeterm/project.json`), SSH refs carry an offline `cache`, local-data refs a + * `userData/inline-projects/.json` (with `project` as its cache). One extra exec reads every + * referenced file; an unreadable one keeps the entry with whatever the index itself had. + */ + private fun resolveIndexV3(root: JsonObject, ud: String?): List { + val entries = root.objects("entries") + val paths = ArrayList() + val fileIndex = HashMap() + for ((i, e) in entries.withIndex()) { + val cwd = e.s("cwd") + val path = when { + cwd != null && e.o("ssh") == null -> "$cwd/.nodeterm/project.json" + e.b("dataFile") == true && ud != null && e.s("id") != null -> "$ud/inline-projects/${e.s("id")}.json" + else -> null + } + if (path != null) { + fileIndex[i] = paths.size + paths.add(path) + } + } + val files = HashMap() + if (paths.isNotEmpty()) { + val (_, out) = run(SshScripts.catFiles(paths), timeoutSec = 30) + val parts = out.split("\n" + SshScripts.FILE_MARK) + for (part in parts.drop(1)) { + val nl = part.indexOf('\n') + val idx = (if (nl >= 0) part.substring(0, nl) else part).trim().toIntOrNull() ?: continue + files[idx] = if (nl >= 0) part.substring(nl + 1) else "" + } + } + return entries.withIndex().mapNotNull { (i, e) -> + val id = e.s("id") ?: return@mapNotNull null + val fileText = fileIndex[i]?.let { files[it] } + val content: JsonObject? = J.obj(fileText?.let(J::parse)) ?: e.o("cache") ?: e.o("project") + val merged = buildMap { + content?.let { putAll(it) } + // The ENTRY is the identity and the machine-local half (#510): id, name, color, closed. + for (k in listOf("id", "name", "color", "closed", "cwd", "ssh")) e[k]?.let { put(k, it) } + } + val project = ProjectsParser.parseProject(JsonObject(merged)) ?: return@mapNotNull null + val cwd = e.s("cwd") + // Folder-ref node cwds are stored portable ("./…") — resolve them against the root. + if (cwd != null) project.copy(nodes = project.nodes.map { n -> + val c = n.cwd + if (c != null && (c == "." || c.startsWith("./"))) n.copy(cwd = cwd.trimEnd('/') + c.removePrefix(".")) else n + }) else project + } + } + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink): TerminalStream = + withContext(Dispatchers.IO) { + val exists = run(SshScripts.hasSession(nodeId)).second.trim() == "yes" + val session = client.startSession() + try { + session.allocatePTY("xterm-256color", cols, rows, 0, 0, emptyMap()) + val cwd = if (!exists) projectCwdFor(nodeId) else null + val cmd = session.exec("/bin/sh -c " + SshScripts.q(SshScripts.attach(nodeId, cwd))) + SshStream(session, cmd, !exists, sink).also { it.start() } + } catch (e: Exception) { + runCatching { session.close() } + throw HostException("Couldn't open the terminal over SSH: ${e.message}") + } + } + + @Volatile private var lastCwds: Map = emptyMap() + + private fun projectCwdFor(nodeId: String): String? = lastCwds[nodeId] + + /** Remember node cwds from the latest listing so a cold attach opens where the node lives. */ + fun rememberCwds(snapshot: ProjectsSnapshot) { + lastCwds = snapshot.projects.flatMap { p -> p.nodes.mapNotNull { n -> (n.cwd ?: p.cwd)?.let { n.id to it } } }.toMap() + } + + private inner class SshStream( + private val session: Session, + private val cmd: Session.Command, + override val fresh: Boolean, + private val sink: TerminalSink + ) : TerminalStream { + private val stdin: OutputStream = cmd.outputStream + + fun start() { + Thread({ + val buf = ByteArray(16 * 1024) + val input = cmd.inputStream + try { + while (true) { + val n = input.read(buf) + if (n < 0) break + if (n > 0) sink.onOutput(buf.copyOf(n)) + } + } catch (_: Exception) { + // channel closed + } + runCatching { cmd.join(2, TimeUnit.SECONDS) } + sink.onExit(cmd.exitStatus) + runCatching { session.close() } + }, "nodeterm-ssh-stream").apply { isDaemon = true }.start() + } + + override fun write(text: String) { + synchronized(this) { + runCatching { + stdin.write(text.toByteArray(Charsets.UTF_8)) + stdin.flush() + } + } + } + + override fun resize(cols: Int, rows: Int) { + // The exec'd channel is a SessionChannel, which is also a Session.Shell (the window-change owner). + runCatching { (session as Session.Shell).changeWindowDimensions(cols.coerceAtLeast(1), rows.coerceAtLeast(1), 0, 0) } + } + + /** The same SGR wheel event the relay host writes (host-service.ts `handleScroll`): tmux's + * mouse is on, so the wheel enters copy-mode and scrolls its own history. */ + override suspend fun scroll(up: Boolean, lines: Int) { + val seq = "\u001b[<${if (up) 64 else 65};1;1M" + write(seq.repeat(lines.coerceIn(1, 20))) + } + + override suspend fun detach() { + withContext(Dispatchers.IO) { runCatching { session.close() } } + } + + override suspend fun endSession() { + throw HostException("Ending a session needs the relay connection (it also removes the node from the canvas).") + } + } + + override suspend fun wake(nodeId: String) { relayOnly("Waking a sleeping session") } + override suspend fun refresh(nodeId: String) { relayOnly("Refreshing a terminal view") } + override suspend fun rename(nodeId: String, title: String) { relayOnly("Renaming a session") } + override suspend fun ensureBoard(projectId: String): List? = relayOnly("Editing the board") + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = relayOnly("Editing the board") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? = relayOnly("Editing labels") + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = relayOnly("Starting a new session") + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = relayOnly("Source control") + + private fun relayOnly(what: String): Nothing = + throw HostException("$what needs the relay connection (turn on remote access in nodeterm → Settings → Phone).") + + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome = withContext(Dispatchers.IO) { + val pendingId = event.pendingId ?: return@withContext ApprovalOutcome.UNSUPPORTED + if (!SshScripts.PENDING_ID.matches(pendingId)) return@withContext ApprovalOutcome.UNSUPPORTED + when (run(SshScripts.answerApproval(pendingId, allow)).second.trim()) { + "sent" -> ApprovalOutcome.SENT + "gone" -> ApprovalOutcome.ALREADY_HANDLED + else -> throw HostException("Couldn't write the answer on the computer.") + } + } + + /** The phone→host read-ack (src/core/ack-sweep.ts): `~/.nodeterm/acks/.seen`. */ + override suspend fun ackRead(nodeId: String, eventId: String?) { + withContext(Dispatchers.IO) { runCatching { run(SshScripts.ackRead(nodeId, eventId ?: "")) } } + } + + override suspend fun sendKeys(nodeId: String, keys: String) { + withContext(Dispatchers.IO) { + val (code, _) = run(SshScripts.sendKeys(nodeId, keys)) + if (code != null && code != 0) throw HostException("Couldn't type into the session (tmux exited $code).") + } + } + + /** Kill the node's tmux session (the node stays on the canvas; the desktop shows it as ended). */ + suspend fun killSession(nodeId: String) = withContext(Dispatchers.IO) { run(SshScripts.killSession(nodeId)) } + + /** `~/.nodeterm/relay.json`, when the computer advertises its relay identity (late adoption). */ + suspend fun readRelayAdvertisement(): JsonObject? = withContext(Dispatchers.IO) { + J.obj(J.parse(run(SshScripts.readRelayAdvertisement()).second)) + } + + override fun setOnChanged(listener: (() -> Unit)?) { + // SSH has no push channel; the UI polls (every 8 s while foregrounded, like iOS). + } + + override fun setOnClosed(listener: ((String?) -> Unit)?) { + onClosed = listener + } + + override fun close() { + runCatching { client.disconnect() } + } + + companion object { + /** OpenSSH-style `SHA256:` of the host key blob. */ + fun fingerprint(key: PublicKey): String { + val blob = Buffer.PlainBuffer().putPublicKey(key).compactData + val digest = MessageDigest.getInstance("SHA-256").digest(blob) + return "SHA256:" + Base64.getEncoder().withoutPadding().encodeToString(digest) + } + + fun connect( + host: String, + port: Int, + user: String, + identity: SshIdentity, + pin: HostKeyPin, + connectTimeoutMs: Int = 8_000 + ): SshHostConnection { + val client = SSHClient(DefaultConfig()) + var mismatch: HostKeyChangedException? = null + client.addHostKeyVerifier(object : HostKeyVerifier { + override fun verify(hostname: String, port: Int, key: PublicKey): Boolean { + val fp = fingerprint(key) + val pinned = pin.pinned() + return when { + pinned == null -> { + pin.pin(fp) + true + } + pinned == fp -> true + else -> { + mismatch = HostKeyChangedException(pinned, fp) + false + } + } + } + + override fun findExistingAlgorithms(hostname: String, port: Int): List = emptyList() + }) + client.connectTimeout = connectTimeoutMs + client.timeout = 30_000 + try { + client.connect(host, port) + val kp = identity.keyPair + client.authPublickey(user, object : KeyProvider { + override fun getPrivate(): PrivateKey = kp.private + override fun getPublic(): PublicKey = kp.public + override fun getType(): KeyType = KeyType.ED25519 + }) + client.connection.keepAlive.keepAliveInterval = 20 + } catch (e: Exception) { + runCatching { client.disconnect() } + mismatch?.let { throw it } + throw HostException("Couldn't connect over SSH to $user@$host:$port (${e.message ?: e.javaClass.simpleName}).") + } + return SshHostConnection(client) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt new file mode 100644 index 0000000000..5adebca3c7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt @@ -0,0 +1,163 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.TmuxNames + +/** + * The POSIX sh the direct-SSH transport runs on the paired computer. Generated shell, so every + * interpolation is single-quoted with [q] and every target is checked against the shape this app + * generates (`nt-[A-Za-z0-9_-]+`) before it is spliced. + * + * Three facts about the desktop these encode: + * - an ssh exec channel gets a NON-login shell, so Homebrew's tmux is not on PATH on a Mac: the + * PATH is APPENDED (never prepended — a PATH that already resolves tmux keeps that binary), the + * same rule as `remoteTmuxPathPrologue` in src/shared/ssh.ts; the tmux the macOS app ships + * (`Contents/Resources/bin/tmux`) is the last resort, as it is for the desktop's own `findTmux`; + * - the desktop's sessions live on the `-L node-terminal` socket (src/core/tmux-naming.ts), and its + * generated config is `/tmux.conf`; + * - userData is `~/Library/Application Support/nodeterm` on macOS and `$XDG_CONFIG_HOME/nodeterm` + * (default `~/.config/nodeterm`) on Linux. + */ +object SshScripts { + const val META_START = "##NT-META" + const val META_END = "##NT-META-END" + const val FILE_MARK = "##NT-FILE " + + /** Single-quote for sh: `'` → `'\''`. */ + fun q(s: String): String = "'" + s.replace("'", "'\\''") + "'" + + private val PRELUDE = """ + PATH="${'$'}PATH:/opt/homebrew/bin:/usr/local/bin:/opt/local/bin:${'$'}HOME/.local/bin"; export PATH + NT_TMUX=${'$'}(command -v tmux 2>/dev/null) + if [ -z "${'$'}NT_TMUX" ]; then + for c in /Applications/nodeterm.app/Contents/Resources/bin/tmux "${'$'}HOME/Applications/nodeterm.app/Contents/Resources/bin/tmux"; do + if [ -x "${'$'}c" ]; then NT_TMUX="${'$'}c"; break; fi + done + fi + NT_UD="" + for d in "${'$'}HOME/Library/Application Support/nodeterm" "${'$'}{XDG_CONFIG_HOME:-${'$'}HOME/.config}/nodeterm"; do + if [ -f "${'$'}d/workspace.json" ]; then NT_UD="${'$'}d"; break; fi + done + """.trimIndent() + + /** + * Browse: emits a meta block, then EXACTLY the `projects.list` blob shape (workspace.json · + * live `nt-*` sessions · agent-status.json), so the relay and SSH paths share one parser. + */ + fun browse(): String = """ + $PRELUDE + printf '%s\n' '$META_START' + printf 'ud=%s\n' "${'$'}NT_UD" + printf 'tmux=%s\n' "${'$'}NT_TMUX" + printf 'home=%s\n' "${'$'}HOME" + printf '%s\n' '$META_END' + if [ -n "${'$'}NT_UD" ]; then cat "${'$'}NT_UD/workspace.json" 2>/dev/null; fi + printf '\n%s\n' '${ProjectsParser.PROJECTS_MARK}' + if [ -n "${'$'}NT_TMUX" ]; then "${'$'}NT_TMUX" -L ${TmuxNames.SOCKET} list-sessions -F '#{session_name}' 2>/dev/null; fi + printf '\n%s\n' '${ProjectsParser.STATUS_MARK}' + if [ -n "${'$'}NT_UD" ]; then cat "${'$'}NT_UD/agent-status.json" 2>/dev/null; fi + exit 0 + """.trimIndent() + + /** Cat each file behind a `##NT-FILE ` marker (a missing file yields an empty section). */ + fun catFiles(paths: List): String = buildString { + for ((i, p) in paths.withIndex()) { + append("printf '\\n%s\\n' '").append(FILE_MARK).append(i).append("'\n") + append("cat ").append(q(p)).append(" 2>/dev/null\n") + } + append("exit 0\n") + } + + fun hasSession(nodeId: String): String { + val target = target(nodeId) + return """ + $PRELUDE + if [ -n "${'$'}NT_TMUX" ] && "${'$'}NT_TMUX" -L ${TmuxNames.SOCKET} has-session -t ${q("=$target")} 2>/dev/null; then echo yes; else echo no; fi + """.trimIndent() + } + + /** + * Attach a pty to the node's session, creating it when absent — `new-session -A` WITHOUT `-D`: + * the desktop's own client must stay attached (`-D` is what "[detached]" dead terminals are made + * of). `-f` only when the config exists (a missing file is a tmux error, not a default), and it + * only matters when this attach STARTS the server. `-c` only applies to a created session. + */ + fun attach(nodeId: String, cwd: String?): String { + val target = target(nodeId) + val cd = cwd?.takeIf { it.startsWith("/") }?.let { " -c ${q(it)}" } ?: "" + return """ + $PRELUDE + if [ -z "${'$'}NT_TMUX" ]; then echo 'nodeterm: tmux was not found on this computer.' >&2; exit 127; fi + if [ -n "${'$'}NT_UD" ] && [ -f "${'$'}NT_UD/tmux.conf" ]; then set -- -f "${'$'}NT_UD/tmux.conf"; else set --; fi + TERM=xterm-256color; export TERM + exec "${'$'}NT_TMUX" -L ${TmuxNames.SOCKET} "${'$'}@" new-session -A -s ${q(target)}$cd + """.trimIndent() + } + + fun killSession(nodeId: String): String { + val target = target(nodeId) + return """ + $PRELUDE + [ -n "${'$'}NT_TMUX" ] || exit 127 + "${'$'}NT_TMUX" -L ${TmuxNames.SOCKET} kill-session -t ${q("=$target")} + """.trimIndent() + } + + /** + * Type into a pane. `-l --` so text is literal and a leading `-` is never an option (the + * leading-dash hazard documented in tmux-naming.ts). A lone ESC is sent as the `Escape` key. + */ + fun sendKeys(nodeId: String, keys: String): String { + // A PANE target: `=name` alone is refused ("can't find pane", measured on tmux 3.4); the + // exact-session form for a pane command is `=name:` — the session's current window/pane. + val pane = q("=" + target(nodeId) + ":") + val send = when (keys) { + "\u001b" -> "\"${'$'}NT_TMUX\" -L ${TmuxNames.SOCKET} send-keys -t $pane Escape" + "\r" -> "\"${'$'}NT_TMUX\" -L ${TmuxNames.SOCKET} send-keys -t $pane Enter" + else -> "\"${'$'}NT_TMUX\" -L ${TmuxNames.SOCKET} send-keys -t $pane -l -- ${q(keys)}" + } + return """ + $PRELUDE + [ -n "${'$'}NT_TMUX" ] || exit 127 + $send + """.trimIndent() + } + + /** + * Answer a held PermissionRequest hook (docs/hook-reply-approvals.md): write `allow`/`deny` to + * `~/.nodeterm/pending/.answer` atomically (temp + mv). Prints `gone` when the request file + * is no longer there — the hook already timed out or someone else answered. + */ + fun answerApproval(pendingId: String, allow: Boolean): String { + require(PENDING_ID.matches(pendingId)) { "unsafe pending id" } + val decision = if (allow) "allow" else "deny" + return """ + d="${'$'}HOME/.nodeterm/pending" + if [ ! -f "${'$'}d/$pendingId.json" ]; then echo gone; exit 0; fi + umask 077 + printf '%s' '$decision' > "${'$'}d/$pendingId.answer.tmp.${'$'}${'$'}" && mv -f "${'$'}d/$pendingId.answer.tmp.${'$'}${'$'}" "${'$'}d/$pendingId.answer" && echo sent + """.trimIndent() + } + + /** The read-ack the host's ack sweep consumes (src/core/ack-sweep.ts): content = event id, + * atomic, umask 077. The node id becomes a file name, so it is held to the tmux-name alphabet. */ + fun ackRead(nodeId: String, eventId: String): String { + require(Regex("^[A-Za-z0-9_-]{1,200}$").matches(nodeId)) { "unsafe node id" } + return """ + d="${'$'}HOME/.nodeterm/acks" + umask 077 + mkdir -p "${'$'}d" && printf '%s' ${q(eventId)} > "${'$'}d/$nodeId.seen.tmp.${'$'}${'$'}" && mv -f "${'$'}d/$nodeId.seen.tmp.${'$'}${'$'}" "${'$'}d/$nodeId.seen" + """.trimIndent() + } + + /** `~/.nodeterm/relay.json` (relay-advertise.ts) for late relay adoption. */ + fun readRelayAdvertisement(): String = "cat \"\$HOME/.nodeterm/relay.json\" 2>/dev/null; exit 0" + + val PENDING_ID = Regex("^[A-Za-z0-9_-]{1,160}$") + + private fun target(nodeId: String): String { + val t = TmuxNames.sessionName(nodeId) + require(TmuxNames.isSessionName(t)) { "unsafe session target" } + return t + } +} diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts new file mode 100644 index 0000000000..3df74ecfd5 --- /dev/null +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -0,0 +1,256 @@ +// Interop fixture for the Android protocol tests: runs the DESKTOP's real code on the other end of +// the wire, so the Kotlin client is checked against the implementation, not against a reading of it. +// +// mode "relay": a tiny in-process relay broker (pairs a host and a client by room, forwards +// frames preserving text/binary — all the real relay does) plus the desktop's +// `connectHostSession` (src/main/remote/host-service.ts → relay-socket.ts host role) +// serving a FAKE pty/kanban/inbox bridge that records what the phone asked for. +// mode "pair": the desktop's real `createPairingService` (src/main/pairing-service.ts) with HOME +// pointed at a temp dir by the caller, and a fake `/v1/relay/device` API. +// +// Protocol with the Kotlin test: line 1 on stdout is a JSON "ready" object; every later line is a +// JSON event. Bundled by esbuild at test time (see InteropHarness.kt); `electron` and `ws` stay +// external and resolve from the repo's node_modules. +import http from 'http' +import { WebSocketServer, type WebSocket } from 'ws' +import { initPlatform } from '../../../../../src/core/platform' +import { genKeyPair, publicKeyToB64 } from '../../../../../src/main/remote/e2ee' +import { + connectHostSession, + type HostPtyManager, + type HostSession +} from '../../../../../src/main/remote/host-service' +import { createPairingService } from '../../../../../src/main/pairing-service' +import type { DetachedSinks } from '../../../../../src/core/pty-manager' +import type { PtyManager } from '../../../../../src/core/pty-manager' + +const emit = (obj: unknown): void => { + process.stdout.write(JSON.stringify(obj) + '\n') +} + +initPlatform({ + userDataDir: process.env.FIXTURE_USERDATA ?? process.cwd(), + appVersion: '0.0.0-interop', + isPackaged: false, + handle: () => {}, + on: () => {}, + handleWithSender: () => {}, + onWithSender: () => {}, + sendTo: () => {}, + broadcast: () => {}, + clientIds: () => [], + openExternal: async () => {} +}) + +// ---- a relay broker: pair `?token=host-` with `?token=client-` ------------------------ + +function startBroker(): Promise { + const rooms = new Map() + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + wss.on('connection', (ws, req) => { + const token = new URL(req.url ?? '/', 'http://x').searchParams.get('token') ?? '' + const m = /^(host|client)-(.+)$/.exec(token) + if (!m) { + ws.close(4001, 'bad token') + return + } + const role = m[1] as 'host' | 'client' + const room = rooms.get(m[2]) ?? { queue: [] } + rooms.set(m[2], room) + room[role] = ws + emit({ event: 'broker-join', role }) + const peerOf = (r: 'host' | 'client'): 'host' | 'client' => (r === 'host' ? 'client' : 'host') + const flush = (): void => { + room.queue = room.queue.filter((q) => { + const target = room[q.to] + if (!target) return true + target.send(q.data as Buffer, { binary: q.binary }) + return false + }) + } + flush() + ws.on('message', (data, isBinary) => { + room.queue.push({ to: peerOf(role), data: isBinary ? data : data.toString('utf-8'), binary: isBinary }) + flush() + }) + ws.on('close', () => { + emit({ event: 'broker-close', role }) + room[peerOf(role)]?.close() + room[role] = undefined + }) + }) + return new Promise((resolve) => wss.on('listening', () => resolve((wss.address() as { port: number }).port))) +} + +// ---- mode "relay" ---------------------------------------------------------------------------------- + +async function runRelay(): Promise { + const port = await startBroker() + const keys = genKeyPair() + const approveAfter = Number(process.env.FIXTURE_APPROVE_AFTER_MS ?? '0') + // A snapshot over the 256 KB chunk size, made of 3-byte code points so a chunk boundary splits + // one: the reassembler must join BYTES before decoding. + const bigSnapshot = 'SNAP-' + '€'.repeat(100_000) + '-END' + let sinks: DetachedSinks | null = null + let sessionCounter = 0 + + const pty: HostPtyManager = { + createDetached() { + throw new Error('not used') + }, + attachDetached(persistKey, s, options) { + sinks = s + const id = `sess-${++sessionCounter}` + emit({ event: 'attach', persistKey, cols: options?.cols, rows: options?.rows, adaptsToSize: s.adaptsToSize }) + setTimeout(() => s.onData(`hello ${persistKey}\r\n`), 20) + return id + }, + async captureSnapshot(persistKey) { + return persistKey === 'term-big-1' ? bigSnapshot : `screen of ${persistKey}` + }, + async sessionExists(persistKey) { + return persistKey !== 'term-new-1' + }, + write(clientId, sessionId, data) { + emit({ event: 'write', sessionId, data }) + if (data === 'exit\r') sinks?.onExit(7) + else sinks?.onData(`echo:${data}`) + }, + resize(clientId, sessionId, cols, rows) { + emit({ event: 'resize', sessionId, cols, rows }) + if (cols === 100) sinks?.onSize?.({ cols: 132, rows: 43 }) + }, + setFlow() {}, + kill(clientId, sessionId) { + emit({ event: 'kill', sessionId }) + } + } + + const blob = [ + JSON.stringify({ + version: 2, + activeProjectId: 'p1', + projects: [ + { + id: 'p1', + name: 'Demo', + color: '#0a84ff', + cwd: '/work/demo', + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [ + { id: 'term-abc-1', kind: 'terminal', title: 'Claude', color: '#d97757', agentId: 'claude', group: null, position: { x: 0, y: 0 }, size: { width: 1, height: 1 } }, + { id: 'note-1', kind: 'sticky', title: 'Note', color: '#fff', group: null, text: 'hi', position: { x: 0, y: 0 }, size: { width: 1, height: 1 } } + ], + kanban: { + columns: [{ id: 'c1', title: 'To Do', color: '#0a84ff' }], + assignments: [{ nodeId: 'term-abc-1', columnId: 'c1' }], + labels: [{ id: 'l1', name: 'bug', color: 'red' }], + meta: [{ nodeId: 'term-abc-1', labels: ['l1'], priority: 'high' }] + } + } + ] + }), + '--NT-PROJECTS-SPLIT--', + 'nt-term-abc-1', + '--NT-STATUS-SPLIT--', + JSON.stringify({ + v: 1, + updatedAt: 1, + nodes: { 'term-abc-1': { state: 'blocked', agentId: 'claude', sessionId: 's-1', name: 'fix bug', updatedAt: 2 } }, + inbox: { + events: [{ id: 'e1', ts: 3, nodeId: 'term-abc-1', kind: 'approval', title: 'Approve Bash', pendingId: 'term-abc-1-1700000000000-42' }], + nodes: { 'term-abc-1': { activity: 'Running npm test', updatedAt: 4 } } + } + }) + ].join('\n') + + let session: HostSession | null = null + const approveNow = (): void => { + if (session && !session.isApproved()) { + session.approve() + emit({ event: 'approved' }) + } + } + session = connectHostSession({ + url: `ws://127.0.0.1:${port}`, + token: 'host-room', + ourKeys: keys, + pty: pty as unknown as PtyManager, + getLatestCanvas: () => null, + subscribeCanvas: () => () => {}, + applyMutation: () => {}, + listProjects: async () => blob, + registerNode: async (projectId, node) => { + emit({ event: 'registerNode', projectId, node }) + return true + }, + destroyNode: async (nodeId) => { + emit({ event: 'destroyNode', nodeId }) + }, + nodeActions: { + wake: (nodeId) => (emit({ event: 'wake', nodeId }), true), + refresh: (nodeId) => (emit({ event: 'refresh', nodeId }), true), + rename: (nodeId, title) => (emit({ event: 'rename', nodeId, title }), true) + }, + kanban: { + ensureBoard: async (projectId) => (emit({ event: 'ensureBoard', projectId }), [{ id: 'c1', title: 'To Do', color: '#0a84ff' }]), + setCardColumn: async (projectId, nodeId, columnId) => (emit({ event: 'setCardColumn', projectId, nodeId, columnId }), true), + editCardLabels: async (projectId, nodeId, edit) => ( + emit({ event: 'editCardLabels', projectId, nodeId, edit }), + { edited: true, labels: [{ id: 'l1', name: 'bug', color: 'red' as const }], cardLabelIds: ['l1'] } + ) + }, + inbox: { + answerPermission: async (nodeId, pendingId, decision) => (emit({ event: 'answer', nodeId, pendingId, decision }), true), + ackRead: (nodeId) => emit({ event: 'ack', nodeId }) + }, + onPeerReady: (s) => { + emit({ event: 'peer-ready', sas: s.sas(), pub: s.peerPublicKeyB64() }) + if (approveAfter >= 0) setTimeout(approveNow, approveAfter) + }, + onClose: () => emit({ event: 'host-close' }) + }) + + emit({ ready: true, relayUrl: `ws://127.0.0.1:${port}`, clientToken: 'client-room', hostPublicKeyB64: publicKeyToB64(keys.publicKey) }) +} + +// ---- mode "pair" ----------------------------------------------------------------------------------- + +async function runPair(): Promise { + const keys = genKeyPair() + const api = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => (body += c)) + req.on('end', () => { + emit({ event: 'api', path: req.url, body: JSON.parse(body || '{}') }) + if (req.url === '/v1/relay/device') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ deviceToken: 'device-token-xyz', hostId: 'minted-host-id', exp: 123 })) + } else { + res.writeHead(404).end() + } + }) + }) + await new Promise((r) => api.listen(0, '127.0.0.1', r)) + const apiPort = (api.address() as { port: number }).port + const withRelay = process.env.FIXTURE_RELAY === '1' + const service = createPairingService( + { + getSettings: () => ({ phoneAccessEnabled: withRelay }) as never, + getEntitlement: () => null, + loadHostKeyPair: async () => keys, + relayEndpoint: 'wss://relay.example.test', + apiBase: `http://127.0.0.1:${apiPort}`, + relayAllowed: () => withRelay + }, + { timeoutMs: 60_000 } + ) + const started = await service.start((done) => emit({ event: 'done', ...done })) + emit({ ready: true, payload: started.payload, hostPublicKeyB64: publicKeyToB64(keys.publicKey), relayPlan: started.relayPlan }) +} + +const mode = process.argv[2] +;(mode === 'pair' ? runPair() : runRelay()).catch((err) => { + emit({ event: 'fatal', message: String((err as Error)?.stack ?? err) }) + process.exit(1) +}) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CryptoVectorsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CryptoVectorsTest.kt new file mode 100644 index 0000000000..fb56b4a087 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CryptoVectorsTest.kt @@ -0,0 +1,96 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.E2ee +import dev.nodeterm.protocol.crypto.TweetNacl +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Byte-for-byte agreement with the desktop's crypto. The vectors come from + * `scripts/gen-crypto-vectors.cjs`, which runs the desktop's own `tweetnacl` (the library + * `src/main/remote/e2ee.ts` uses) and node's HKDF — so a green run here means a box sealed on the + * phone opens on the desktop and vice versa. + */ +class CryptoVectorsTest { + private val vectors: JsonObject = Json.parseToJsonElement( + javaClass.getResource("/crypto-vectors.json")!!.readText() + ).jsonObject + + private fun JsonObject.b(key: String): ByteArray = B64.decode(this[key]!!.jsonPrimitive.content)!! + private fun JsonObject.s(key: String): String = this[key]!!.jsonPrimitive.content + + @Test + fun `public keys derive exactly as tweetnacl box keyPair fromSecretKey`() { + for (v in vectors["keypairs"]!!.jsonArray.map { it.jsonObject }) { + val kp = BoxKeyPair.fromSecretKey(v.b("secretKey")) + assertEquals(v.s("publicKey"), kp.publicKeyB64) + } + } + + @Test + fun `box before and SAS match the desktop`() { + for (v in vectors["before"]!!.jsonArray.map { it.jsonObject }) { + val shared = E2ee.deriveSharedKey(v.s("bPublic"), v.b("aSecret")) + assertContentEquals(v.b("shared"), shared) + assertEquals(v.s("sas"), E2ee.sasFromSharedKey(shared)) + } + } + + @Test + fun `secretbox seals and opens byte-identically across block boundaries`() { + for (v in vectors["secretbox"]!!.jsonArray.map { it.jsonObject }) { + val sealed = TweetNacl.secretbox(v.b("msg"), v.b("nonce"), v.b("key")) + assertContentEquals(v.b("box"), sealed, "len=${v.b("msg").size}") + val opened = TweetNacl.secretboxOpen(v.b("box"), v.b("nonce"), v.b("key")) + assertNotNull(opened) + assertContentEquals(v.b("msg"), opened) + } + } + + @Test + fun `a tampered box is refused, never partially decrypted`() { + val v = vectors["secretbox"]!!.jsonArray[10].jsonObject + val box = v.b("box") + for (i in box.indices step 7) { + val bad = box.copyOf().also { it[i] = (it[i].toInt() xor 1).toByte() } + assertNull(TweetNacl.secretboxOpen(bad, v.b("nonce"), v.b("key")), "flip at $i") + } + } + + @Test + fun `session key is RFC 5869 HKDF with host nonce first`() { + for (v in vectors["hkdf"]!!.jsonArray.map { it.jsonObject }) { + val key = E2ee.deriveSessionKey(v.b("base"), v.b("hostNonce"), v.b("clientNonce")) + assertContentEquals(v.b("sessionKey"), key) + } + } + + @Test + fun `host id is the relay broker room derivation`() { + for (v in vectors["hostIds"]!!.jsonArray.map { it.jsonObject }) { + assertEquals(v.s("hostId"), E2ee.hostIdFromPublicKeyB64(v.s("publicKey"))) + } + } + + @Test + fun `encrypt and decrypt round trip with a random nonce`() { + val a = BoxKeyPair.generate() + val b = BoxKeyPair.generate() + val ab = E2ee.deriveSharedKey(b.publicKey, a.secretKey) + val ba = E2ee.deriveSharedKey(a.publicKey, b.secretKey) + assertContentEquals(ab, ba) + val msg = "héllo nodeterm".toByteArray() + assertContentEquals(msg, E2ee.decrypt(E2ee.encrypt(msg, ab), ba)) + assertNull(E2ee.decrypt(ByteArray(10), ab)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt new file mode 100644 index 0000000000..b394f6b73b --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt @@ -0,0 +1,97 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * Runs `src/test/interop/host-fixture.ts` — the DESKTOP's own relay host / pairing code — in a node + * child process. Skips (never fails) when this checkout has no node or no `npm ci` yet, so a + * JVM-only contributor still gets a green protocol build; CI installs both and runs everything. + */ +class InteropHarness private constructor(private val process: Process) : AutoCloseable { + private val lines = LinkedBlockingQueue() + private val seen = ArrayList() + lateinit var ready: JsonObject + private set + + private fun startReader() { + Thread({ + process.inputStream.bufferedReader().forEachLine { line -> + val obj = runCatching { Json.parseToJsonElement(line).jsonObject }.getOrNull() + if (obj != null) lines.put(obj) else System.err.println("[fixture] $line") + } + }, "interop-stdout").apply { isDaemon = true }.start() + Thread({ + process.errorStream.bufferedReader().forEachLine { System.err.println("[fixture:err] $it") } + }, "interop-stderr").apply { isDaemon = true }.start() + } + + /** Wait for the next event matching [predicate] (earlier non-matching events are kept for later waits). */ + fun await(timeoutMs: Long = 10_000, predicate: (JsonObject) -> Boolean): JsonObject { + synchronized(seen) { + seen.firstOrNull(predicate)?.let { + seen.remove(it) + return it + } + } + val deadline = System.currentTimeMillis() + timeoutMs + while (true) { + val left = deadline - System.currentTimeMillis() + if (left <= 0) throw AssertionError("fixture event not seen within ${timeoutMs}ms; saw: ${synchronized(seen) { seen.toList() }}") + val next = lines.poll(left, TimeUnit.MILLISECONDS) ?: continue + if (next["event"]?.toString() == "\"fatal\"") throw AssertionError("fixture died: $next") + if (predicate(next)) return next + synchronized(seen) { seen.add(next) } + } + } + + fun awaitEvent(name: String, timeoutMs: Long = 10_000): JsonObject = + await(timeoutMs) { it["event"]?.toString() == "\"$name\"" } + + override fun close() { + process.destroy() + if (!process.waitFor(3, TimeUnit.SECONDS)) process.destroyForcibly() + } + + companion object { + val repoRoot: File = File(System.getProperty("nodeterm.repoRoot") ?: "../..").canonicalFile + + private val bundle: File by lazy { + val out = File(repoRoot, "android/protocol/build/interop/host-fixture.cjs") + val esbuild = File(repoRoot, "node_modules/.bin/esbuild") + val proc = ProcessBuilder( + esbuild.path, + "android/protocol/src/test/interop/host-fixture.ts", + "--bundle", "--platform=node", "--format=cjs", + "--outfile=${out.path}", + "--external:electron", "--external:ws", + "--alias:@shared=./src/shared", "--alias:@renderer=./src/renderer", + "--log-level=warning" + ).directory(repoRoot).redirectErrorStream(true).start() + val log = proc.inputStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "esbuild failed: $log" } + out + } + + private fun available(): Boolean { + val node = runCatching { ProcessBuilder("node", "--version").start().waitFor() == 0 }.getOrDefault(false) + return node && File(repoRoot, "node_modules/.bin/esbuild").exists() && + File(repoRoot, "node_modules/ws").exists() && File(repoRoot, "node_modules/tweetnacl").exists() + } + + fun start(mode: String, env: Map = emptyMap()): InteropHarness { + assumeTrue(available(), "node + repo node_modules (npm ci) are needed for interop tests") + val pb = ProcessBuilder("node", bundle.path, mode).directory(repoRoot) + pb.environment().putAll(env) + val h = InteropHarness(pb.start()) + h.startReader() + h.ready = h.await(20_000) { it["ready"] != null } + return h + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt new file mode 100644 index 0000000000..95be5bbbc5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt @@ -0,0 +1,101 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.ManagedAccount +import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.SessionBucket +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.relay.Framing +import dev.nodeterm.protocol.relay.Op +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ModelTest { + @Test + fun `a hostile or broken blob degrades section by section, never throws`() { + val snap = ProjectsParser.parseBlob("not json\n--NT-PROJECTS-SPLIT--\nnt-a\njunk\n--NT-STATUS-SPLIT--\n{\"nodes\":{\"a\":{\"state\":42,\"hibernated\":true}}}") + assertTrue(snap.projects.isEmpty()) + assertEquals(setOf("nt-a"), snap.liveSessions) + val st = snap.statusOf("a")!! + assertNull(st.state, "a wrong-typed state is absent, not a crash") + assertEquals(SessionBucket.SLEEPING, st.bucket) + assertTrue(ProjectsParser.parseBlob("").projects.isEmpty()) + } + + @Test + fun `nodes default to terminal and the legacy claude tag still means claude`() { + val snap = ProjectsParser.parseBlob("""{"version":2,"projects":[{"id":"p","name":"P","nodes":[{"id":"n1","title":"t","tags":["claude"]},{"id":"n2","kind":"group"}],"kanban":{"columns":"garbage"}}]}""") + val p = snap.projects.single() + assertEquals(listOf("n1"), p.sessions.map { it.id }) + assertEquals("claude", p.sessions.single().agentId) + assertNotNull(p.board, "columns present but malformed → an empty board, read tolerantly") + } + + @Test + fun `tmux names match tmux-naming ts`() { + assertEquals("nt-term-abc-1", TmuxNames.sessionName("term-abc-1")) + assertEquals("nt-a_b_c", TmuxNames.sessionName("a/b c")) + assertTrue(TmuxNames.isSessionName("nt-a_b")) + } + + @Test + fun `frames round trip with the stable header`() { + val f = Framing.decode(Framing.encode(Op.INPUT, 7, 0x1_0000_0002L, "hi".toByteArray()))!! + assertEquals(Op.INPUT, f.op) + assertEquals(7, f.streamId) + assertEquals(0x1_0000_0002L, f.seq) + assertEquals("hi", String(f.payload)) + assertNull(Framing.decode(ByteArray(15))) + assertNull(Framing.decode(Framing.encode(99, 1, 0, ByteArray(0))), "unknown opcode") + assertEquals(120 to 40, Framing.readSize(Framing.sizePayload(120, 40))) + } + + @Test + fun `pairing payloads parse like pairing-core builds them, and refuse plaintext relays`() { + val p = PairingPayload.parse("""{"v":1,"host":"192.168.1.5","port":22,"user":"me","token":"t","pairPort":5555,"nodeterm":true,"name":"Mac","hostKey":"${"A".repeat(43)}=","relay":{"hostId":"h","hostPublicKeyB64":"${"A".repeat(43)}=","relayEndpoint":"ws://evil.example"},"ssh":false}""")!! + assertEquals("Mac", p.name) + assertEquals(false, p.sshAvailable) + assertNull(p.relay, "ws:// to a non-loopback host is refused (pairing.ts R5)") + assertNull(PairingPayload.parse("""{"v":2,"host":"x"}""")) + assertNull(PairingPayload.parse("nodeterm://pair?code=abc"), "a desktop-peer offer is not a phone pairing") + } + + @Test + fun `paired host survives a JSON round trip`() { + val p = PairingPayload.parse("""{"v":1,"host":"10.0.0.2","user":"u","token":"t","pairPort":1,"nodeterm":true,"name":"Box"}""")!! + val h = PairedHost.from(p, dev.nodeterm.protocol.pairing.PairingResult("dev-1", "tok", null, null), now = 5) + assertEquals(h, PairedHost.fromJson(h.toJson())) + } + + @Test + fun `resume commands follow each agent's grammar and refuse unsafe ids`() { + assertEquals("claude --resume abc-1", Launch.resumeCommand(Agent.CLAUDE, "abc-1")) + assertEquals("codex resume abc", Launch.resumeCommand(Agent.CODEX, "abc")) + assertEquals("copilot --resume=abc", Launch.resumeCommand(Agent.COPILOT, "abc")) + assertEquals("opencode --session abc", Launch.resumeCommand(Agent.OPENCODE, "abc")) + assertNull(Launch.resumeCommand(Agent.CLAUDE, "abc; rm -rf ~")) + assertNull(Launch.resumeCommand(Agent.CLAUDE, "-flag")) + } + + @Test + fun `launch emits claude's permission mode only as the desktop would`() { + val s = MirrorSettings("auto", autoSupported = false, claudeAccounts = listOf(ManagedAccount("a1", "/Users/me/Library/Application Support/nodeterm/claude-accounts/a1")), codexApprovalValues = emptyList()) + assertEquals("claude", Launch.launchCommand(Agent.CLAUDE, s, null, null), "auto on an old claude degrades to the bare command") + assertEquals("claude --permission-mode auto", Launch.launchCommand(Agent.CLAUDE, s.copy(autoSupported = true), null, null)) + assertEquals("claude", Launch.launchCommand(Agent.CLAUDE, s.copy(claudePermissionMode = "constructor"), null, null)) + assertEquals( + "cd '/w/p' && CLAUDE_CONFIG_DIR='/Users/me/Library/Application Support/nodeterm/claude-accounts/a1' claude --permission-mode plan", + Launch.launchCommand(Agent.CLAUDE, s.copy(claudePermissionMode = "plan"), "a1", "/w/p") + ) + assertEquals("codex", Launch.launchCommand(Agent.CODEX, s.copy(claudePermissionMode = "plan"), "a1", null)) + assertEquals("gemini", Launch.launchCommand(Agent.GEMINI, s, null, "/it's/unsafe"), "a quote-bearing cwd is dropped, not escaped") + assertTrue(Regex("^term-[a-z0-9]+-[a-z0-9]{1,16}$").matches(Launch.newNodeId())) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt new file mode 100644 index 0000000000..35ce2cfb86 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt @@ -0,0 +1,97 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingException +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.SshIdentity +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The phone's `/pair` exchange against the DESKTOP's real `createPairingService` + * (src/main/pairing-service.ts), with HOME pointed at a temp dir so the authorized_keys line it + * writes can be inspected. Covers the E2EE-sealed exchange (the QR carries `hostKey`) with and + * without a relay leg. + */ +class PairingInteropTest { + private val cleanup = ArrayList() + + @AfterTest + fun tearDown() = cleanup.forEach { it.close() } + + private fun start(withRelay: Boolean): Pair { + val home = Files.createTempDirectory("nt-pair-home").toFile() + cleanup += AutoCloseable { home.deleteRecursively() } + val h = InteropHarness.start( + "pair", + mapOf("HOME" to home.path, "FIXTURE_USERDATA" to home.path, "FIXTURE_RELAY" to if (withRelay) "1" else "0") + ) + cleanup += h + return h to home + } + + private fun payloadOf(h: InteropHarness): PairingPayload = + assertNotNull(PairingPayload.parse(h.ready["payload"]!!.jsonPrimitive.content), "the desktop's QR must parse") + + @Test + fun `an E2EE pairing installs our key under the desktop's attributable comment`() = runBlocking { + val (h, home) = start(withRelay = false) + val payload = payloadOf(h) + assertEquals(h.ready["hostPublicKeyB64"]!!.jsonPrimitive.content, payload.hostKey, "QR carries the host box key") + assertTrue(payload.sshAvailable) + assertNull(payload.relay) + val identity = SshIdentity.generate() + val result = PairingClient().pair(payload, identity.authorizedKeysLine(), "Pixel Test", "android-device-1") + assertTrue(result.deviceId.isNotBlank()) + assertTrue(result.agentToken.isNotBlank()) + assertNull(result.relayDeviceToken) + val done = h.awaitEvent("done") + assertEquals("true", done["ok"]!!.jsonPrimitive.content) + val keys = File(home, ".ssh/authorized_keys").readText() + assertTrue( + keys.contains("ssh-ed25519 ${B64.encode(identity.publicKeyBlob)} nodeterm-ios-${result.deviceId}"), + "authorized_keys: $keys" + ) + val agent = File(home, ".nodeterm/agent.json").readText() + assertTrue(agent.contains("\"name\": \"Pixel Test\""), agent) + assertTrue(agent.contains("\"relayDeviceId\": \"android-device-1\""), agent) + } + + @Test + fun `with remote access on, the sealed answer carries the relay leg`() = runBlocking { + val (h, _) = start(withRelay = true) + val payload = payloadOf(h) + val relay = assertNotNull(payload.relay, "QR relay block") + assertEquals("wss://relay.example.test", relay.relayEndpoint) + val result = PairingClient().pair(payload, SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-2", priorDeviceToken = "old-token") + assertEquals("device-token-xyz", result.relayDeviceToken) + assertEquals("minted-host-id", result.relay?.hostId) + val api = h.awaitEvent("api") + val body = api["body"]!!.jsonObject + assertEquals("android-device-2", body["deviceId"]!!.jsonPrimitive.content, "the backend row is keyed by OUR id") + assertEquals("old-token", body["priorDeviceToken"]!!.jsonPrimitive.content) + assertEquals("Pixel", body["label"]!!.jsonPrimitive.content) + } + + @Test + fun `a wrong token is refused with the desktop's own words`() = runBlocking { + val (h, home) = start(withRelay = false) + val forged = payloadOf(h).copy(token = "not-the-token") + val e = assertFailsWith { + PairingClient().pair(forged, SshIdentity.generate().authorizedKeysLine(), "x", "y") + } + assertTrue(e.message!!.contains("bad token"), e.message) + assertTrue(!File(home, ".ssh/authorized_keys").exists()) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt new file mode 100644 index 0000000000..15cb36c73e --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -0,0 +1,263 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.host.RelayConnectStatus +import dev.nodeterm.protocol.host.RelayConnector +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.SessionBucket +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.ByteArrayOutputStream +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The Kotlin relay client against the DESKTOP's real host code: `connectHostSession` + + * `createHostHandlers` (host-service.ts) over `connectRelay` (relay-socket.ts, host role), through a + * local broker. Everything the phone does over the relay goes across this wire at least once. + */ +class RelayInteropTest { + private val harnesses = ArrayList() + + @AfterTest + fun tearDown() = harnesses.forEach { it.close() } + + private fun start(approveAfterMs: Long = 0): InteropHarness = + InteropHarness.start("relay", mapOf("FIXTURE_APPROVE_AFTER_MS" to approveAfterMs.toString())).also { harnesses += it } + + private fun InteropHarness.str(key: String) = ready[key]!!.jsonPrimitive.content + private fun JsonObject.str(key: String) = this[key]!!.jsonPrimitive.content + + private fun connect(h: InteropHarness, keys: BoxKeyPair = BoxKeyPair.generate(), statuses: MutableList? = null, approvalTimeoutMs: Long = 20_000) = + runBlocking { + RelayConnector.connect( + relayUrl = h.str("relayUrl"), + token = h.str("clientToken"), + deviceKeys = keys, + hostPublicKeyB64 = h.str("hostPublicKeyB64"), + onStatus = { statuses?.add(it) }, + approvalTimeoutMs = approvalTimeoutMs, + approvalPollMs = 200 + ) + } + + private class RecordingSink : TerminalSink { + val paints = CopyOnWriteArrayList() + val output = ByteArrayOutputStream() + val resized = CopyOnWriteArrayList>() + val exited = CountDownLatch(1) + @Volatile var exitCode: Int? = null + + override fun onPaint(text: String) { + paints += text + } + + override fun onOutput(bytes: ByteArray) { + synchronized(output) { output.write(bytes) } + } + + override fun onResized(cols: Int, rows: Int) { + resized += cols to rows + } + + override fun onExit(code: Int?) { + exitCode = code + exited.countDown() + } + + fun text(): String = synchronized(output) { output.toString(Charsets.UTF_8) } + + fun awaitText(needle: String, timeoutMs: Long = 5_000) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!text().contains(needle)) { + if (System.currentTimeMillis() > deadline) throw AssertionError("'$needle' not in output: ${text()}") + Thread.sleep(20) + } + } + } + + @Test + fun `handshake, approval wait and SAS agree with the desktop`() { + val h = start(approveAfterMs = 700) + val keys = BoxKeyPair.generate() + val statuses = ArrayList() + val connected = connect(h, keys, statuses) + connected.connection.use { + val peer = h.awaitEvent("peer-ready") + assertEquals(peer.str("sas"), connected.sas, "both ends must show the same code") + assertEquals(keys.publicKeyB64, peer.str("pub"), "the host pins exactly our box key") + assertTrue(statuses.any { it is RelayConnectStatus.AwaitingApproval }, "the wait was surfaced: $statuses") + h.awaitEvent("approved") + } + } + + @Test + fun `an unapproved phone is told so and never served`() { + val h = start(approveAfterMs = -1) + val e = assertFailsWith { connect(h, approvalTimeoutMs = 1_200) } + assertTrue(e.message!!.contains("approved"), e.message) + } + + @Test + fun `projects list parses the desktop blob`() { + val h = start() + val connected = connect(h) + connected.connection.use { + val snap = connected.first + val p = snap.projects.single() + assertEquals("Demo", p.name) + assertEquals(listOf("term-abc-1"), p.sessions.map { it.id }) + assertEquals(NodeKind.STICKY, p.nodes[1].kind) + assertTrue(snap.isLive("term-abc-1")) + val st = snap.statusOf("term-abc-1")!! + assertEquals(AgentState.BLOCKED, st.state) + assertEquals(SessionBucket.NEEDS_YOU, st.bucket) + assertEquals("fix bug", st.name) + val ev = snap.status!!.inbox!!.events.single() + assertEquals(InboxKind.APPROVAL, ev.kind) + assertEquals("term-abc-1-1700000000000-42", ev.pendingId) + assertEquals("Running npm test", snap.status!!.inbox!!.nodes["term-abc-1"]!!.activity) + val board = p.board!! + assertEquals("c1", board.columnOf("term-abc-1")) + assertEquals(listOf("l1"), board.metaOf("term-abc-1")!!.labels) + } + } + + @Test + fun `attach paints the snapshot, streams output and carries input`() = runBlocking { + val h = start() + val conn = connect(h).connection + conn.use { + val sink = RecordingSink() + val stream = conn.attach("term-abc-1", 90, 30, sink) + assertFalse(stream.fresh, "a live session is a warm join") + val attach = h.awaitEvent("attach") + assertEquals(90, attach.str("cols").toInt()) + assertEquals("false", attach.str("adaptsToSize"), "a phone is a size CEILING, like iOS") + sink.awaitText("hello term-abc-1") + assertEquals(listOf("screen of term-abc-1"), sink.paints) + stream.write("ls -la\r") + assertEquals("ls -la\r", h.awaitEvent("write").str("data")) + sink.awaitText("echo:ls -la") + stream.write("ünïcødé ✓\r") + sink.awaitText("echo:ünïcødé ✓") + } + } + + @Test + fun `a multi-chunk snapshot survives a code point split across chunks`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val sink = RecordingSink() + conn.attach("term-big-1", 80, 24, sink) + sink.awaitText("hello term-big-1") + assertEquals("SNAP-" + "€".repeat(100_000) + "-END", sink.paints.single()) + } + } + + @Test + fun `fresh reports a cold start, and resize round-trips a Resized frame`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val sink = RecordingSink() + val stream = conn.attach("term-new-1", 80, 24, sink) + assertTrue(stream.fresh) + sink.awaitText("hello term-new-1") + stream.resize(100, 30) + val r = h.awaitEvent("resize") + assertEquals(100, r.str("cols").toInt()) + assertEquals(30, r.str("rows").toInt()) + val deadline = System.currentTimeMillis() + 3_000 + while (sink.resized.isEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(20) + assertEquals(listOf(132 to 43), sink.resized.toList()) + } + } + + @Test + fun `the pane exiting reaches the sink with its code`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val sink = RecordingSink() + val stream = conn.attach("term-abc-1", 80, 24, sink) + sink.awaitText("hello") + stream.write("exit\r") + assertTrue(sink.exited.await(5, TimeUnit.SECONDS)) + assertEquals(7, sink.exitCode) + } + } + + @Test + fun `detach, end session, scroll and node actions reach the host`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val a = conn.attach("term-abc-1", 80, 24, RecordingSink()) + a.scroll(up = true, lines = 3) + repeat(3) { assertEquals("\u001b[<64;1;1M", h.awaitEvent("write").str("data")) } + a.detach() + h.awaitEvent("kill") + + val b = conn.attach("term-new-1", 80, 24, RecordingSink()) + b.endSession() + assertEquals("term-new-1", h.awaitEvent("destroyNode").str("nodeId")) + + conn.wake("term-abc-1") + assertEquals("term-abc-1", h.awaitEvent("wake").str("nodeId")) + conn.refresh("term-abc-1") + h.awaitEvent("refresh") + conn.rename("term-abc-1", "new\u001b[31m name") + assertEquals("new [31m name", h.awaitEvent("rename").str("title"), "control chars are stripped host-side") + } + } + + @Test + fun `board verbs carry null as the Ungrouped column`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + assertEquals(listOf("To Do"), conn.ensureBoard("p1")!!.map { it.title }) + assertTrue(conn.setCardColumn("p1", "term-abc-1", null)) + val moved = h.awaitEvent("setCardColumn") + assertEquals(JsonNull, moved["columnId"]) + assertTrue(conn.setCardColumn("p1", "term-abc-1", "c1")) + assertEquals("c1", h.awaitEvent("setCardColumn").str("columnId")) + val res = conn.editCardLabels("p1", "term-abc-1", CardLabelEdit(add = listOf("l1"), create = listOf("urgent" to "red")))!! + assertTrue(res.edited) + assertEquals(listOf("l1"), res.cardLabelIds) + val edit = h.awaitEvent("editCardLabels")["edit"].toString() + assertTrue(edit.contains("urgent"), edit) + assertTrue(conn.registerNode("p1", NewNode("term-kx1-abc", "Claude Code", "claude", null))) + assertEquals("p1", h.awaitEvent("registerNode").str("projectId")) + } + } + + @Test + fun `quick approve answers the held hook through the relay verb, never with keys`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val event = conn.listProjects().status!!.inbox!!.events.single() + assertEquals(QuickActions.Result.SENT, QuickActions.answerApproval(conn, event, allow = true)) + val answer = h.awaitEvent("answer") + assertEquals("allow", answer.str("decision")) + assertEquals("term-abc-1-1700000000000-42", answer.str("pendingId")) + conn.ackRead("term-abc-1", event.id) + assertEquals("term-abc-1", h.awaitEvent("ack").str("nodeId")) + assertNotNull(event) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelaySocketSecurityTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelaySocketSecurityTest.kt new file mode 100644 index 0000000000..c856a8efa6 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelaySocketSecurityTest.kt @@ -0,0 +1,150 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.E2ee +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RelaySocketListener +import dev.nodeterm.protocol.relay.RelayTransport +import dev.nodeterm.protocol.relay.RelayTransportEvents +import dev.nodeterm.protocol.relay.RelayTransportFactory +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The security rules of docs/ios-protocol-migration.md §2.3–2.4 / §7, pinned on the client with a + * scripted host: no re-key after ready, reflections (our own role) dropped, replays/reorders + * dropped. A regression in any of these is silent in every other test. + */ +class RelaySocketSecurityTest { + /** A hand-driven host end: speaks the handshake with real crypto, lets the test forge frames. */ + private class ScriptedHost(private val hostKeys: BoxKeyPair) : RelayTransportFactory { + lateinit var events: RelayTransportEvents + val fromClient = CopyOnWriteArrayList() + var sessionKey: ByteArray? = null + private var sendSeq = 0L + val hostNonce = E2ee.randomSessionNonce() + + override fun open(url: String, events: RelayTransportEvents): RelayTransport { + this.events = events + return object : RelayTransport { + override fun sendText(text: String): Boolean { + fromClient += text + onClientText(text) + return true + } + + override fun sendBinary(bytes: ByteArray): Boolean { + fromClient += bytes + onClientBinary(bytes) + return true + } + + override fun queuedBytes(): Long = 0 + override fun close() {} + } + } + + private fun onClientText(text: String) { + val o = Json.parseToJsonElement(text).jsonObject + if (o["type"]!!.jsonPrimitive.content != "e2ee_hello") return + val clientPub = o["publicKeyB64"]!!.jsonPrimitive.content + val clientNonce = B64.decode(o["nonceB64"]!!.jsonPrimitive.content)!! + val base = E2ee.deriveSharedKey(clientPub, hostKeys.secretKey) + sessionKey = E2ee.deriveSessionKey(base, hostNonce, clientNonce) + events.onText("""{"type":"e2ee_ready","nonceB64":"${B64.encode(hostNonce)}"}""") + } + + private fun onClientBinary(bytes: ByteArray) { + val plain = E2ee.decrypt(bytes, sessionKey!!) ?: return + val body = String(plain.copyOfRange(10, plain.size)) + if (body.contains("e2ee_auth")) sendRpc("""{"type":"e2ee_authenticated"}""") + } + + fun seal(role: Int, seq: Long, tag: Int, body: ByteArray, key: ByteArray = sessionKey!!): ByteArray { + val b = ByteBuffer.allocate(10 + body.size).order(ByteOrder.LITTLE_ENDIAN) + b.put(role.toByte()).putInt((seq ushr 32).toInt()).putInt(seq.toInt()).put(tag.toByte()).put(body) + return E2ee.encrypt(b.array(), key) + } + + fun sendRpc(json: String, role: Int = 1, seq: Long = sendSeq++) = + events.onBinary(seal(role, seq, 0x01, json.toByteArray())) + } + + private class Recorder : RelaySocketListener { + val notifies = CopyOnWriteArrayList() + var ready: String? = null + override fun onReady(sas: String) { + ready = sas + } + + override fun onNotify(method: String, params: JsonElement?) { + notifies += method + } + } + + private fun connect(): Triple { + val hostKeys = BoxKeyPair.generate() + val host = ScriptedHost(hostKeys) + val rec = Recorder() + val socket = RelaySocket("ws://127.0.0.1:1", "t", BoxKeyPair.generate(), hostKeys.publicKeyB64, host, rec) + assertNotNull(rec.ready, "handshake completes against the scripted host") + return Triple(socket, host, rec) + } + + @Test + fun `hello carries our key and a 16-byte session nonce, and the token rides the URL`() { + val (_, host, _) = connect() + val hello = Json.parseToJsonElement(host.fromClient.first() as String).jsonObject + assertEquals("e2ee_hello", hello["type"]!!.jsonPrimitive.content) + assertEquals(16, B64.decode(hello["nonceB64"]!!.jsonPrimitive.content)!!.size) + assertEquals("wss://r/x?a=1&token=a%2Fb%3D", RelaySocket.withToken("wss://r/x?a=1", "a/b=")) + } + + @Test + fun `a plaintext re-key after ready is ignored and the session keeps working`() { + val (socket, host, rec) = connect() + val sasBefore = socket.sas + val sentBefore = host.fromClient.size + // A MITM re-sends e2ee_ready with its own nonce: must not re-derive or answer. + host.events.onText("""{"type":"e2ee_ready","nonceB64":"${B64.encode(ByteArray(16) { 7 })}"}""") + assertEquals(sentBefore, host.fromClient.size, "no new e2ee_auth was sent") + assertEquals(sasBefore, socket.sas) + host.sendRpc("""{"kind":"notify","method":"canvas:state","params":{}}""") + assertEquals(listOf("canvas:state"), rec.notifies.toList(), "the original session key still decrypts") + } + + @Test + fun `a box carrying our own role is a reflection and is dropped`() { + val (_, host, rec) = connect() + host.sendRpc("""{"kind":"notify","method":"reflected","params":{}}""", role = 2) + assertTrue(rec.notifies.isEmpty()) + } + + @Test + fun `a replayed or reordered seq is dropped`() { + val (_, host, rec) = connect() + host.sendRpc("""{"kind":"notify","method":"first","params":{}}""", seq = 5) + host.sendRpc("""{"kind":"notify","method":"replay","params":{}}""", seq = 5) + host.sendRpc("""{"kind":"notify","method":"older","params":{}}""", seq = 3) + host.sendRpc("""{"kind":"notify","method":"newer","params":{}}""", seq = 6) + assertEquals(listOf("first", "newer"), rec.notifies.toList()) + } + + @Test + fun `a box sealed under another key never reaches the application`() { + val (_, host, rec) = connect() + val forged = host.seal(1, 99, 0x01, """{"kind":"notify","method":"forged","params":{}}""".toByteArray(), key = ByteArray(32) { 1 }) + host.events.onBinary(forged) + assertTrue(rec.notifies.isEmpty()) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt new file mode 100644 index 0000000000..73b5a4a132 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -0,0 +1,316 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.SshHostConnection +import kotlinx.coroutines.runBlocking +import org.apache.sshd.server.Environment +import org.apache.sshd.server.ExitCallback +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.channel.ChannelSession +import org.apache.sshd.server.command.Command +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The direct-SSH transport end to end: a real SSH server (Apache MINA) running every command + * through `/bin/sh` as the phone would get from sshd, against a fake HOME laid out like a desktop's + * (a v3 workspace index + project files + agent-status.json) and a REAL tmux — on a private + * `TMUX_TMPDIR`, so no test ever touches a tmux server a developer is using (issue #629's rule). + * + * A pty-requesting exec runs under `script`, standing in for sshd's pty allocation (MINA's process + * bridge has none of its own); that wrapper is harness, the scripts it runs are the product's. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SshTransportTest { + private lateinit var root: File + private lateinit var home: File + private lateinit var tmuxDir: File + private lateinit var server: SshServer + private val identity = SshIdentity.generate() + private var port = 0 + + private fun tmuxAvailable() = runCatching { ProcessBuilder("tmux", "-V").start().waitFor() == 0 }.getOrDefault(false) && + File("/usr/bin/script").exists() + + private fun childEnv(): Map = System.getenv().filterKeys { it != "TMUX" && it != "TMUX_PANE" } + + mapOf("HOME" to home.path, "TMUX_TMPDIR" to tmuxDir.path, "XDG_CONFIG_HOME" to File(home, ".config").path) + + private fun tmux(vararg args: String): Pair { + val pb = ProcessBuilder(listOf("tmux", "-L", "node-terminal") + args).redirectErrorStream(true) + pb.environment().clear() + pb.environment().putAll(childEnv()) + val p = pb.start() + val out = p.inputStream.bufferedReader().readText() + return p.waitFor() to out + } + + private inner class ShCommand(private val command: String) : Command { + private lateinit var input: InputStream + private lateinit var output: OutputStream + private lateinit var error: OutputStream + private lateinit var exit: ExitCallback + private var process: Process? = null + + override fun setInputStream(`in`: InputStream) { input = `in` } + override fun setOutputStream(out: OutputStream) { output = out } + override fun setErrorStream(err: OutputStream) { error = err } + override fun setExitCallback(callback: ExitCallback) { exit = callback } + + override fun start(channel: ChannelSession, env: Environment) { + val cols = env.env["COLUMNS"] ?: "80" + val lines = env.env["LINES"] ?: "24" + val pty = env.env.containsKey("TERM") + val argv = if (pty) listOf("script", "-qfec", "stty cols $cols rows $lines 2>/dev/null; $command", "/dev/null") + else listOf("/bin/sh", "-c", command) + val pb = ProcessBuilder(argv).directory(home) + pb.environment().clear() + pb.environment().putAll(childEnv()) + val p = pb.start() + process = p + fun pump(from: InputStream, to: OutputStream, closeTo: Boolean) = Thread { + runCatching { + val buf = ByteArray(8192) + while (true) { + val n = from.read(buf) + if (n < 0) break + to.write(buf, 0, n) + to.flush() + } + } + if (closeTo) runCatching { to.close() } + }.apply { isDaemon = true; start() } + val outPump = pump(p.inputStream, output, false) + val errPump = pump(p.errorStream, error, false) + // For a pty session, channel EOF must NOT become stdin EOF: util-linux `script` turns that + // into a ^D typed into the pty (measured), which ends the pane's shell — a harness artifact + // real sshd does not have (it closes the pty and the tmux client just detaches). + pump(input, p.outputStream, !pty) + Thread { + val code = p.waitFor() + outPump.join(2000) + errPump.join(2000) + exit.onExit(code) + }.apply { isDaemon = true; start() } + } + + override fun destroy(channel: ChannelSession) { + process?.destroy() + } + } + + @BeforeAll + fun setUp() { + assumeTrue(tmuxAvailable(), "tmux + script are needed for the SSH transport tests") + root = Files.createTempDirectory("nt-ssh").toFile() + home = File(root, "home").apply { mkdirs() } + tmuxDir = File(root, "tmux").apply { mkdirs() } + server = SshServer.setUpDefaultServer() + server.host = "127.0.0.1" + server.port = 0 + server.keyPairProvider = SimpleGeneratorHostKeyProvider(File(root, "hostkey.ser").toPath()) + val expected = identity.keyPair.public.encoded + server.publickeyAuthenticator = org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator { user, key, _ -> + user == "dev" && key.encoded.contentEquals(expected) + } + server.commandFactory = org.apache.sshd.server.command.CommandFactory { _, command -> ShCommand(command) } + server.start() + port = server.port + layOutDesktop() + } + + @AfterAll + fun tearDown() { + if (!::server.isInitialized) return + runCatching { tmux("kill-server") } + server.stop(true) + root.deleteRecursively() + } + + private val repo get() = File(root, "repo") + + private fun layOutDesktop() { + val ud = File(home, ".config/nodeterm").apply { mkdirs() } + File(repo, ".nodeterm").mkdirs() + File(repo, ".nodeterm/project.json").writeText( + """{"version":1,"rev":3,"savedAt":"x","name":"file name ignored","color":"#000", + "viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-a-1","kind":"terminal","title":"Claude","color":"#d97757","group":null, + "agentId":"claude","cwd":"./sub","position":{"x":0,"y":0},"size":{"width":1,"height":1}}], + "kanban":{"columns":[{"id":"c1","title":"Doing","color":"#0a84ff"}],"assignments":[{"nodeId":"term-a-1","columnId":"c1"}]}}""" + ) + File(ud, "inline-projects").mkdirs() + File(ud, "inline-projects/p3.json").writeText( + """{"version":1,"rev":2,"savedAt":"x","name":"n","color":"#000","viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-c-3","kind":"terminal","title":"From file","color":"#000","group":null,"position":{"x":0,"y":0},"size":{"width":1,"height":1}}]}""" + ) + File(ud, "workspace.json").writeText( + """{"version":3,"activeProjectId":"p1","entries":[ + {"id":"p1","name":"Repo","color":"#0a84ff","cwd":"${repo.path}"}, + {"id":"p2","name":"Server","color":"#ff9f0a","ssh":{"server":{"host":"box","user":"me"},"remoteCwd":"~"}, + "cache":{"version":1,"rev":1,"savedAt":"x","name":"n","color":"#000","viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-b-2","kind":"terminal","title":"Remote","color":"#000","group":null,"position":{"x":0,"y":0},"size":{"width":1,"height":1}}]}}, + {"id":"p3","name":"Scratch","color":"#bf5af2","dataFile":true, + "project":{"id":"p3","name":"Scratch","color":"#bf5af2","viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-c-3","kind":"terminal","title":"Stale cache","color":"#000","group":null,"position":{"x":0,"y":0},"size":{"width":1,"height":1}}]}}, + {"id":"p4","name":"Parked","color":"#8e8e93","closed":true,"project":{"id":"p4","name":"Parked","color":"#8e8e93","viewport":{"x":0,"y":0,"zoom":1},"nodes":[]}} + ]}""" + ) + File(ud, "agent-status.json").writeText( + """{"v":1,"updatedAt":1,"nodes":{"term-a-1":{"state":"working","agentId":"claude","updatedAt":5}}, + "inbox":{"events":[],"nodes":{}}}""" + ) + File(ud, "tmux.conf").writeText("set -g status off\n") + File(repo, "sub").mkdirs() + val (code, out) = tmux("-f", File(ud, "tmux.conf").path, "new-session", "-d", "-s", "nt-term-a-1", "-c", File(repo, "sub").path) + assertEquals(0, code, out) + } + + private fun connect(pin: HostKeyPin = MemoryPin()) = SshHostConnection.connect("127.0.0.1", port, "dev", identity, pin) + + private class MemoryPin(var value: String? = null) : HostKeyPin { + override fun pinned() = value + override fun pin(fingerprint: String) { + value = fingerprint + } + } + + private class Sink : TerminalSink { + val out = ByteArrayOutputStream() + override fun onPaint(text: String) {} + override fun onOutput(bytes: ByteArray) { + synchronized(out) { out.write(bytes) } + } + override fun onExit(code: Int?) {} + fun waitFor(needle: String, ms: Long = 8_000) { + val end = System.currentTimeMillis() + ms + while (!synchronized(out) { out.toString(Charsets.UTF_8) }.contains(needle)) { + if (System.currentTimeMillis() > end) throw AssertionError("'$needle' never appeared: ${synchronized(out) { out.toString(Charsets.UTF_8) }.takeLast(400)}") + Thread.sleep(50) + } + } + } + + @Test + fun `browse resolves the v3 index the way the desktop assembles it`() = runBlocking { + connect().use { conn -> + val snap = conn.listProjects() + assertEquals(listOf("Repo", "Server", "Scratch", "Parked"), snap.projects.map { it.name }, "names come from the ENTRY") + val repoProject = snap.projects[0] + val node = repoProject.nodes.single() + assertEquals("term-a-1", node.id) + assertEquals(repo.path + "/sub", node.cwd, "portable ./ cwds resolve against the folder") + assertEquals("c1", repoProject.board!!.columnOf("term-a-1")) + assertEquals("me@box", snap.projects[1].sshTarget) + assertEquals(listOf("term-b-2"), snap.projects[1].nodes.map { it.id }, "an ssh ref reads its offline cache") + assertEquals(listOf("From file"), snap.projects[2].nodes.map { it.title }, "the data file wins over the cache") + assertTrue(snap.projects[3].closed) + assertTrue(snap.isLive("term-a-1")) + assertFalse(snap.isLive("term-b-2")) + assertEquals(AgentState.WORKING, snap.statusOf("term-a-1")!!.state) + } + } + + @Test + fun `attach joins the live tmux session and carries keystrokes both ways`() = runBlocking { + connect().use { conn -> + conn.listProjects() + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + assertFalse(stream.fresh) + Thread.sleep(400) + stream.write("echo nt_\$((6*7))\r") + sink.waitFor("nt_42") + stream.detach() + Thread.sleep(300) + val (code, out) = tmux("has-session", "-t", "=nt-term-a-1") + assertEquals(0, code, "detaching never ends the session: $out / ${tmux("ls").second}") + } + } + + @Test + fun `attaching an unknown node reports a cold start and creates the session`() = runBlocking { + connect().use { conn -> + val sink = Sink() + val stream = conn.attach("term-z-9", 80, 24, sink) + assertTrue(stream.fresh) + Thread.sleep(600) + assertEquals(0, tmux("has-session", "-t", "=nt-term-z-9").first) + stream.detach() + conn.killSession("term-z-9") + assertEquals(1, tmux("has-session", "-t", "=nt-term-z-9").first) + } + } + + @Test + fun `send keys types literally, even text that starts with a dash`() = runBlocking { + connect().use { conn -> + conn.sendKeys("term-a-1", "-R; echo sk_\$((2+3))") + conn.sendKeys("term-a-1", "\r") + val end = System.currentTimeMillis() + 5_000 + var pane = "" + while (System.currentTimeMillis() < end) { + pane = tmux("capture-pane", "-p", "-t", "=nt-term-a-1:").second + if (pane.contains("sk_5")) break + Thread.sleep(100) + } + assertTrue(pane.contains("sk_5"), pane) + } + } + + @Test + fun `a held approval is answered through its answer file, exactly once`() = runBlocking { + val pending = File(home, ".nodeterm/pending").apply { mkdirs() } + val id = "term-a-1-1700000000000-99" + File(pending, "$id.json").writeText("{}") + val event = InboxEvent("e1", 1, "term-a-1", "claude", null, InboxKind.APPROVAL, "Approve", null, false, false, emptyList(), false, id) + connect().use { conn -> + assertEquals(ApprovalOutcome.SENT, conn.answerApproval(event, allow = false)) + assertEquals("deny", File(pending, "$id.answer").readText()) + File(pending, "$id.json").delete() + assertEquals(ApprovalOutcome.ALREADY_HANDLED, conn.answerApproval(event, allow = true)) + conn.ackRead("term-a-1", "e1") + assertEquals("e1", File(home, ".nodeterm/acks/term-a-1.seen").readText()) + } + } + + @Test + fun `the host key is pinned on first use and a changed key is refused`() { + val pin = MemoryPin() + connect(pin).close() + assertTrue(pin.value!!.startsWith("SHA256:")) + connect(pin).close() + assertFailsWith { connect(MemoryPin("SHA256:not-this-host")) } + } + + @Test + fun `a key the computer does not know is refused`() { + assertFailsWith { + SshHostConnection.connect("127.0.0.1", port, "dev", SshIdentity.generate(), MemoryPin()).close() + } + // …and the same session was never at risk. + assertEquals(0, tmux("has-session", "-t", "=nt-term-a-1").first) + TimeUnit.MILLISECONDS.sleep(1) + } +} diff --git a/android/protocol/src/test/resources/crypto-vectors.json b/android/protocol/src/test/resources/crypto-vectors.json new file mode 100644 index 0000000000..a7e468ed24 --- /dev/null +++ b/android/protocol/src/test/resources/crypto-vectors.json @@ -0,0 +1,198 @@ +{ + "generatedBy": "android/protocol/scripts/gen-crypto-vectors.cjs", + "tweetnacl": "1.0.3", + "keypairs": [ + { + "secretKey": "tn+dwcGawdKiz0NJwOhU2s41rukSt8SLQl2F/Sr7FN8=", + "publicKey": "nIYiolgjxDE2y0JyVXQG4HRLZu7f1lpOn0C1FGK2Sm0=" + }, + { + "secretKey": "KstxP6ASS63sn4f6RFOq48JboKaciG25++5n+2Eh4PE=", + "publicKey": "A23MIH+JtHGuVHQY7S5E37eCeEVvd2jTrSU4ftZuWVg=" + }, + { + "secretKey": "PfThXUZyqIn9XNrBSVvjgM0KrUqzZ8SHZXwK/BeVPps=", + "publicKey": "bd7yi6jKZlIIoCsoEd8Sgo1g4intSDdYdEKWOdYdXQY=" + }, + { + "secretKey": "64yIO6Yo0ZR/X232iL6Op5ttKLZ8p2U3gCEfrUm2m+w=", + "publicKey": "VuXyx99vMR22rHdUW8PFLypPYxVWC243fQcWFH9xBHc=" + }, + { + "secretKey": "uXp3/SU+hwc47aQfv6zVq7jvo04WBDPlTvJim8Nq7VU=", + "publicKey": "2PR8TwWw3xk2sRu2Mxp0AeF/pSyxJ995BJjI4Fb4fUY=" + } + ], + "before": [ + { + "aSecret": "dGUVr8QgXI1O2D0nATa3BxhyHRqnWlH9BTz3mTCG++k=", + "bPublic": "hg1WO7RcrZG6f8VUM8+oIHqxsn7q2x6pbiJQw78ZwzQ=", + "shared": "55X+ZDDPJHkqYxPEpDQ3jy/lN2df1kgchP9J5W1Nvpw=", + "sas": "679 843" + }, + { + "aSecret": "9w/jJ1BMb2WnvVy5Gti/V9sDtQ5EuN0wNNpjd92ugsM=", + "bPublic": "QH42fRFBsAp3fMQfbrN4esJFa6u+T0pKkyyx6PgjuCk=", + "shared": "KDOEbdcGhJNoZI0Oq5jXOpAvTzJT4wDw5aGNqbSI9uM=", + "sas": "667 384" + }, + { + "aSecret": "JMR6F+H6BpurObWCK4liEu9rC8aSDd33BMgspPosdzY=", + "bPublic": "5dvBQ7DuxRvn02EXwOgZ/7QZOlJEvrHmXu96dXkSmD4=", + "shared": "4y8T4LAYzuViyc/UEY/OJmICuZS9XYGnpeYZ63CXILY=", + "sas": "235 817" + }, + { + "aSecret": "d39z6bmav2NwbgFLCCvoBQ7/uQdPVBWTdT34YtfKTKc=", + "bPublic": "Iyuub8hqIFwu8lD832EquIT70ELBLVOcTvZaLoQHDTU=", + "shared": "QQ+aJsK3USkaPfAFWBRdDcXS32y2l5E/CgUva4m6DCk=", + "sas": "775 723" + } + ], + "secretbox": [ + { + "key": "7BXHz4L2OInyWMeEskFifaUD2z56LNc1qbrJHLVT/kA=", + "nonce": "8mqmyZC0NJmtInpFZFZh1y7iWmr6zH74", + "msg": "", + "box": "8KnEs/m/1DUx6ejSGrXVgg==" + }, + { + "key": "XxiTmij/YNyaWvLTF4kUJEFp9w6f94NXREVmnjA9BPU=", + "nonce": "V63mq9EG9QtYHUklUCWJsU2r8bxCuHne", + "msg": "hg==", + "box": "C0IgrbVFAI8McoU2d+eKc+s=" + }, + { + "key": "hwzkFWIKC/umdz0/S/Fwo1Nu0GOjXHVhgszgl2OJVXc=", + "nonce": "Bc8kaxJ1Wj3YYrULBc+wgbUcfJCgO9Xj", + "msg": "W7UpfS4+17xGNbGsCtMq", + "box": "6cI9UcoOxwARqy4F6zJAI7KoTaMn5d+njQLGmh7aGA==" + }, + { + "key": "KUiczC6QT9GxXAYI2OygyHSqE2cxGjukPojeV+Pltgc=", + "nonce": "GcZzweHLfHaYhidyPrpYxcJkjh44KqDz", + "msg": "8bpnLM+AO5Q2Hth19zAsrQ==", + "box": "1vO4JxX85ufI4KtIMxd770cLc8t7IVs3z3aTTMJZ1X8=" + }, + { + "key": "LkYkPB+4NBPb4m2ck7uMJ7OeG9+TrbnSw+WXcAxEzyY=", + "nonce": "m9zZDRCEkey7Opdw8SnOLTo/sPVGtFXX", + "msg": "f17kQ8UtEYc7MWWg51J3y+I=", + "box": "EnZ9ZuHM36IF9q3RUNj1tshXogrL2W5OrzOY8eX7TFv4" + }, + { + "key": "FOWhP+AzkyJYEeOMTEOrUKRMIt+T0XTG2vTZsmfZbYY=", + "nonce": "f6GGaG/hxFUwPbaBuAZPF4BTku5N7yFi", + "msg": "D5gLEYnxu+wK1JFleY9iOcKxyrZwZEHTn0vQr5QyQQ==", + "box": "/RHyrVhA9lSSKtkTHeooLpyM8wBCgHJPvYbdlVcNwIy/1De4vlt4L834f+dm6aM=" + }, + { + "key": "xVY6wKvSBsyuklEyBRkSjdOgQx57H1sHrfOuGqywU3A=", + "nonce": "cYMJjm6cPCM6422tUlAj1A6r1zUHf67G", + "msg": "h36HmizB6K3neaENiaxB5+XQUL08r4tzQOFETxbJZMw=", + "box": "bBddrNtxJHOEB3LTL7q4zKFKaAPYECgX3/vYpd4X9g/VSIs8MHQeGrPMSgnI04zP" + }, + { + "key": "tTx6QcSJ7TU0WURS0ZQ11ldeW1dCbFaJF0HYmaOdlGU=", + "nonce": "5r3aD5ldME0T3/joQlJpmC04yncD/31R", + "msg": "NdE1Y1wXiAHZPzdW+AlwuYB2KAlqDz/Qn8r0wGyW+JMd", + "box": "zkYDb1vhrTJ9sl/FehLFOJ7OeUnHGc970Bs818Q+eC8D2mWCKFG58t9GqmUaSXFL+Q==" + }, + { + "key": "8PvkCCyyZ7adUiBQFuMKP5lL+/0ZAC7/3wimYo+eZMQ=", + "nonce": "0zE3scspmkTggfddaBWUO4fS5GQro2Dp", + "msg": "4Y4XtZTEpwQVttRg+WMahztZi14ZR/TLFNIZuxnZ6fpfpDB2DjILFYer9hHgyUDGUNlkIL9C0UEvEFDIJGGp", + "box": "yPJKDHQVNjCK7+R6MBz62gPTG1NLVdbUanD5D9AH4tiy0K1lFXrnos1nErGCSO836r/BbunIBiKP38ZtMgkWYGbNoolQsnzE4kyXNTQanA==" + }, + { + "key": "tdvOKnOBtUUv6DlPNE91DVfxIRHPZNRYgVfUbuPRVls=", + "nonce": "vly2UELCU0TW58e+LsEOth9NNSZIQzK2", + "msg": "QHAlFUVSHEF89mqwyX8H5UBsFy1iTakyQ2sX7biVqWyeTtlLICNVBCFNwgXAzNEJ/5dD/j89fJKqrVBWD/qGvA==", + "box": "prY+HQZLigvgGyn3rQVg35NXvlddcrwuq69h0myYuaQo8O3uFVGap0Rj1kY/0ucSVpU81DLA4XABdlvpv1AVh/QdrKquJjZBlO191TuAkp0=" + }, + { + "key": "8aobAK7C5++1nKefhUUORn/GKBf6yk6BFWU6qO48Rs0=", + "nonce": "P280mI/sd+CY+M9gsZ7+h0J+PwOe09s1", + "msg": "x7/ttQoFJ0bAIqUg3TOxCEWE/1yuYsnaJSur8pt05qhDjvsRBko+c07JyYg4eqzTWMwmxiQEcc3H28ia2USpBOc=", + "box": "o01Qgr0PHfddbQuY8qfFa0Zv+dlnx37kfPmSViFKMZYDEcjzBHC1Dpye7EeSvmA53zlBFUDDvDP6K4PLpvxu2tPumhGrq5jtS0xxv7AeAmdE" + }, + { + "key": "a+6t4GzjsAsB6DGUyX9mJZnR/SzIUV25tgym9P2Ejqk=", + "nonce": "bRlNb4Vf5goV/I01yPFn/p9mda0q6jfD", + "msg": "ZTB4nraKce0/N2XtBmIIo2Ctcjv10qdstobBWPv99N+SwuXyyuc2owt1ULjcq1ahI5dVEPKdAQjZ/VyWTFIkNvrlQB6Dt8RfnRSccG2yvZQd+vSVhKsRnt4NYKaW6arpBtg90/V7rjAWxvXjlVDaLIEbuybikCNNnRdXi5Su5w==", + "box": "PPRQbrY0G/VR7n2QK6NWd2CliqCQ6/Mu6DhEDuiihLtGX1C4r8AQVoGgdnN3yNljqvFRN5xaxhcSh9D/jQMxpsSyLeXr7aUFSwJxRFYylp5LhtuQaKmP0GoOZ6vMnllYrmcNYvOC0WdBin3dm+NJirAPMpq1WtXgcFMw1QHBnzyarC6IClsr7azAkCQj6HI=" + }, + { + "key": "6ermqufVSmweMpY3U04LRtpOjn/Md6egCuODvgpNV6w=", + "nonce": "wj2BeZb/HWacpIlN4Vau88vB3gtbxJwM", + "msg": "bF20YlekRktjJcdc5N2t7CUEYCZ/61kjJZ5cwA8Z9RRdrTZv6xUfoZj4xUvZkpcgC5zAaZi6iw7ErbvEEF0ErdEiCjfZlR5NmJR83Sr9uW1HdkeI2x72hq8KxqdlonwUd19WF/n6uGythGk53Vnnt5U+hA6Ia65gxrLJ0EEr2og=", + "box": "dLPvsArftZdHY7NTXM2y6M8ZNR8p2kDcIpcRaKqCrxhm4OZMxt2El3e+760zJB4Jqn6TpAA2KR6sz2V3J/2eT8dWotiYV/xkMiM8fX7PU1rZ6q8RrgSjwlmFe86E5A3i8d2fWRlI4/sy2uED8Uuxdi+OhRJzP6XoL/yuHFv/srz2Kc9y5LL31YlXn5BVgmXJ" + }, + { + "key": "fKBRGsW6D7PpTk4YaxHvy8BU2Z2pgDBS0PapQcujHmo=", + "nonce": "6iM82RgDohv1323sPqre64XqsSlqxWCd", + "msg": "Ag9LV1+qnPlby2Cz4VnJk8pcgbu4I7B7euolFvoHinWQ/57ux4fk+nygnV0Up4hLLL9URpJjhilGzREG3kkftzxBvuYn26qpfSEOZv9Wnxw27j51ijDDzNpDVPibWrhaFcbPu6X4firWs7mvFMBFqFMqUyVLSq+8NNRusAadGfQ/", + "box": "krWDCp7CIUnM6j/5ONcwEjJNgsaK7y7QGAqTi526yoGTZkuvs2T6pRJ9tyXD+4y/UJ1oBUlGqdzxodMrQ/qIXLndvomvidUdoOLBGJHUpVno1l41g4hDsrHyYKF8QRFc1KVCKTrMcReK14ObR2OAqVcavCqhWQytJfW7MaYAmGXhrspX5UzvOfz1wtdDHzBQMQ==" + }, + { + "key": "2VyPRfaWZFpQhVGFkd0lpBFSswVOcaRKCe+14s9Xn3Y=", + "nonce": "SLv1mPcaWluewiWYSgUWvqWZLL8UJCye", + "msg": "6nQbIPn6RPgHSKqV7mwdHfEi/JUC3S3nbdxVx89r3hGjtJjyNljVs7q5mtXhPlC6T6S2HRGGumjjX+4wShwoRVBHHUu4Ck5LIhRHFdfv7WL8IXKTmTn5WqR/Flmqwii1sph2apjlU7IOdcEPqn6vEnodsgdXzfwYfJ8Pk4vzqK1T0cVEVkqC3U5QkwlSqcfs34Q+EWZOsqP7YqJRMfQnrd4mReGcf9qhoiL0UfmWI7/wQLGHRcoIfOfsRUo7Jva7QCIEDgUfD9w=", + "box": "zx03Wu3cx++kN53O+C57IT7XgFYxg8bIfSHHPgorr60u3CXCCM/AwULvPIoqrw7Ov5gRvRkmivKel2v/X7oVjd23Ygn8VEhz5MpNc7pUl+vaWKBVXCkd28T5VELXW1diFinZicgfLEOq8DLxxYpqNT9jDdCctnwkZT2Qk1BU5EHrVvaklro6etWrZJx9gD8wligcYm2R7B+iTK/Kt8XbXalUZgbbRfoNfVmYxCZpjgEsZx0lJrqL4NpqalrpTc5LKA5g/4JFdfiGMcs6l6KTdaN/jmD1t9vd" + }, + { + "key": "GTWr+lIzAOvKXcAfhCbwWlRbwlrm1pPeCzwU4BfnPIg=", + "nonce": "FCDNb8wvKNSdpfYlojKYHdbLTgvJp+W5", + "msg": "4PGF3o+SfxJNA1ZCF5MDaUmvxX42DgTybCXeRmbrGSwWRW6xi29WeWacrdMPa3o2mWiF+5iUK+mP3wirW9SeF4Tjj32jfPzJRtgwC1GC4gq05Jomjwwujq4bN+bKaUoVsUZksqwym5xZU+Z9nKJbZIuzB87QNd0yVtyXP3/rWnPT6T4mtoJxHvJMbDFJI3VlyXmRaPLk9C3xwmcNXwNF4DzCCr13AIceuRew1yDT3GVyYONpZ5OqeyE76QPmdtJQ1FUwdVL3KRiaS34JV77fS/IeLHMtYy8gcYdLCZTE84olk6g1sV18kvsG9yPuVoPM7/7V/pUe4ouaUvMFGez1o/mtc2Puas41eVKbhBb223XJASmHZLFln5cWRGvRKcGfBv+1VbGlk4rzFkt0v0JH+E8bg8g01Mvg9nsdqlnpre6ReWnSzIPJIJ5GgfQiO3lIg2MkrH+ry0HLhrATwEKmKYQC5vsseZYze3CNp7LcvbazyvjVK9jHcUXhicD8D3ea4E/o4/RCxnL+rfvDO2UlqxGlYUrEVkN06ZcUR3PWLqrbkq6YuK5xYdkcL7y+GQ93XV7FarqX7qI0BowXSI8rWoz+dh8UZrmNiysbtSJP0W7fzKEN5SVCR6Zi5U5QxutI8p3BWw18Q7cLWm9EsGiVdxZxeSLD0x0N4Hft57UStu/u6iNC2T7UAy/5U+/OYooy/J4PfvalfK2BOvkfkY8elnwEQ95SHSMklp9q3ZPWej9Hgr49GJ0A1yC4eVerckUTobVozi4iEiAtawVjTY1NPkOOjYwEWX9CfPFrYcWlW+24crUPfd09U/pgBc96or3MVkAHwkkqpJQIULkib6kLR9UP/FIviIsEABWM1aN644kFige9Jcw7BQn9fdLzHdkPlftIN2d1eGGYJJGSbzTjW7zQeZZWjcD3EOMOzVtAILbjCZfJxTLY/WITqXTY1g/GAlZROtqNb7gS8cQ7oDvIWTlu0NyYHMPx8jvewQCRrgVV/p1wKj+Jk/E81o3aVxYtcX5zLv5lPNnDDuyzEI7vDIRNzYjOqjU83/bYR3dC+t2Gv6OmgYdJuRUTJp268yZv2mKyTHKT3A+DHJWLFWaxE5BFXQxYgzInnpPvo2vpWT6Q6sVtwo5I90YC/D8FRYo0d0N8tywWk80SVa4YGgEanEN+FSPlPGJ1C4XoTl1lRPehHrVb9hf2jwsbrEmfQ5z20T9PfZhu4d7Ay8Ihg7nfyDgBR+NHgdBeYjgUxm8sJHnBIj9j1OdN6tgvYUCME+zqM5YTZOL3o6LmEku+DKPdUx5Mk4iu4qXpef3E8RvlwxuRoe0rM0X8xQ==", + "box": "rJAa8TamyAWmUFj5zIMMzvOa6KrBNf0//YOiygryt+3wncMaIvuXC2Otsla8yy1l5bxDGR6QJHqXQNs7mNw8XuJQcsl38BEa7sqZ8mUpCD0Bhogl4wf/IJjGujjKGTAQxxv6pzH0APcN6PV6q+URLsU5YM1/LVL+Ek/Ical5HGWvi/Vdy/HLe1IH8/a5xahdcpkCdL9E4zPPZ+2eACKzuz/tGgr06tOzcu/jgkj1Qv0Vpdjak1tMw6MF8/2aPSZSsZH0ow6aqJt0TOVNXVgTzOUvmydUNseY/psBG962GEMXjx4hfgrMtwsLuP0jfXPeiTOXg9fljJLfNtramWPxvpTdTVnrlw/qxahcvFY3pk+wNFeoQ5TW1BdxJmgy3dq+jQwGj9xSxBOb4C+WSEvqkC26bywjED961Wo7HEWgMGNpszgmZsG9NRCmHer9x3VtdK+KyV3cBQuGrD6MTI4Puk2jyDYxdWQwqARtFKVpEYjWcFQL3jF8BX1Zx2B8zZSHUpjXwNH9yo3i0IJvI4ctTe1T9Eht1LN/Tl0n7gsrQDZxg9CBnB/Aj/QFnxzCI3iXZSsea7BOaursQuSTU5Edn39SQbhWCOin/9SjOudY5j6oBnWg2EChzdExoz+xbvAzi6pA6a/jZk9xzPIR38zh9sR07PnGyjW6nLHAfqV20ZGTyiBEBuffv5yP6S8LfWyFjeNBVakr1UlzeRpQPYA124BTbQS+f/vO1aTxjMHEy+PxoK6G9I3yr8jlMSiSr0lc7eLnPNe0mgC3SxlMvGCKpxlFB0nymOVTS1R43EQcUDIRx6hZrH+l82gG10IzuglA2KkUdme07xJWjaCI/0He+TycwbWYzUL5Kv7tCEPkUt5M5/QdKTZyVh336f96tcj4g78meJq/M+vX7zrQExfYl2izspT49TPSc6D3yaDdcPuSwVy67eg0jecCSWsXCTgS2jUUe8gqGmjsNtZCbwJUQdO80t2kyNtRK3toKApHmtFjVdaclAxPWUV6IKowYJJ7wXIjQvz6Q5d2Y9giacMJqmS2fHT/ONueT6LQH65nJ7b4D++6LKX6MrE/QWe8gwZmaPhqU1VmEVukqMte/zAsSfW99LESjOkEds42RnarpQ/RAVC/ZBOnh5tYwPwMwD+zCAWNZDxRcvMNLsrcy/G2HrRX6M1syjNFAsKtCTCrUhd4YxHABEaVJBO4VtArFRBBFbrXaF7pKQbAaHT4SKTo4yXScegZbt+RqBiVgLfe9et1aM/W1IEAXSjPnBC4eX3cNsDvPqugwndjJ8j6l7VG6dRgUptfxy5fZ7kJqMnRR4iUdjZM2DB9Za4yRnfthwLh1IbvOB+hZQA=" + }, + { + "key": "C4+f/dQBZI6huZ0A3vu1XQk1xlePvW/RhZnb3Le8AaE=", + "nonce": "BW4EjrvXHt4pBfjzIv7F53CbIQvvLnox", + "msg": "GilTxhUKvMjSTfbFghBngyEaRbgAZFKZJVOCsgfsf9VNxdkAXJy3uCeGF3zIZ9OSLrvQ7MRcsuZhYkr24TZqK36QnURqQZGHaNfwW5v10tbpmmJcpo2cPQFgcbQrY6xvFWN91DN5RJnq9q4fpaurqG3b3bEUnBM9liZyFQVO8WmOzJ2I7Rp27dndUJ6HWVapvuhJqsLA1GOynfeCtiLBxBSBP1UXlikb0uI+A1o6WQn5zUfQzVmaZDNoE2zbJiFaLU2Jo+2TuUUN09Cf9AxPZ1EsuHeYFjojt8258Z0Pf8ohRFl/sXwa1uEXAigfqGNxjPn8BuUocVPQpHy13JeF3SCTw2UP10fv4dV1xMOnqQyT74aV8au+2e/q/BXmpedX9KwH766zROx9KtpBHxcgbjGP5FheoEjpz66cNNtTV0iYDMNk2+/iICVv3QJ5/cadlgeEc7S/bHRIhQCHB7R5wvgE1Q5HgWJGh7VMUBVQ66MqWXB1cpbZ40lyTPJYYsvfhsUux7Ju8LRGWB6dh8yb8Cy17e6TVr/PABYE8BnSMEJ3J70/F1LtFfoBwszaZMLU6xM1c0JIqAD6hCJ5dQtDScFvp+cQzjHHqmkESX4/HehQYPB3nQrGABHrrQVItVY0l9CJaN8SuFupyUCtz82j/wUaKkWK7Zhui7BwJdL0YCru4BG0HP93Skd/iKGnJxypJihdNZurmcoL8FKHO7J21L6IR+4MiGpy24i4WCze6Torj04Kp5V2RrQLhx0enaymu+lMnG5U+xpmKbxLPAYlleforDpqAG+p5DF2EBtqA3sgyoCDYJ7obC6MDOUv5FpE9DC2RtJsCNbp0OS2KQ1wZIqLZxy6nfZahJZ2u/KIJOPs22Juyn9WWH9gxdslW7InlHHf7r2g/8bXLJEQrhn7psXyplHVSQlhJEwaF317ywbHSYGGxBkvZKfjj8QEPqRDE97waFRMOH26lpKSdeCB7+FNU0LG2xcXZ+IgzFjOqXvu+ERKZHi/sQMdkHCHJqlWmJYyfzlq6OoBalxdhi61lnlyrHD9lg/SJgzx+C6f0jxob7NZcjP/MewiXG+Ke5ZOFTpGTyyUdrX1EnmcMyP263fFCBUrbIqkoj2HGvaeqRxxtn8i9aA4nfXFsQmUVFvLFFYVb/wY36XStWgyzU1igIu57rS2qo7oJSZ/hz1Hf01gQY5HFopaYzc0+wPjqCcvZ3WsclQMoG9buHOxFxWD8AviC2iUtxMGSHijsZPxmQdGyTRl9AfEsAUD+PuO6cD4mp2ImVrRKvMGi9t6YsZujEhA/nK5ykDYuDZ3/tvrVwAig3Uhz4A/tCSQVGYNPivcbmfVnZqA2cydfQmCeZ1UYeaawekU6K6skWAvXber/tACPWrHEzmnOfJ4k3jhVDyIi7E5isgWoyYyaXUuteSXOxDEIsEdKVRBHRIKMtqBkwcW/1Il+MH7P3JRn45O39oRMaeTVgTuYiPR3OdajJa8DoHeq3x45VrqNh8BhKB5sv4kgPefB5Pc0Gshgk7ZvUODqD+5bgaxbwWqfIhr964TqzuMZqYgGkbiGz3kqylWYMsLAs9gx0yCO49ZklY2f/KPipYy/3ejo9BuQuArJ7jMp8h+8felOJq+eB5XIylvpJ3w6gtAb6lfUC2wVHzDvNAWvrBK4gQG+7IxmV+HEQRTXD2UD9dW6CVfsIdWJIXSGL3Xd680U+IUMiiBZnMkF8LrcsHi/rLSMXTkVQmauTvV7nxkYDwRCK+CF2mCl2CMNQsebqcytEfFAxIlMB16NvWt0X0YeeaCLg7BgFbaqRmUBrf3/U2CEOLZ+dpZBeebi6VU0RwDBPoq19gpQJgAI2Rkji51G60fat0fiC+JVMbP0Jtj8OYMczeB0soyvMjAF+6HTBpRZZ9WsMuIqH4KOwa/933Niu5LtqWhJu1SMn5HEvKyOBrQSMadTNGGWSC/kevTgVEX7LcXtST9z0xRzq+pieokDsAAZNlvYhkGUcQ9NbJ2+LTp247dvRkqsK/g/f8FyxGFf+jgOTa9hHat6g0c853ZrLXc4tdachkewtAJVusYlGEO3sXjfnEQ8puZj9ozNTnaQopXmDUukfJO5oCycsvzMabWqzI8nXzqvXE3bRsA1I14Kc1qd5jjXmD2+UEgAUHqPcjU9ib+z/97wVygWSIhSdIyjkri/T/LFq060yRqfIWk5D3HnyCP0uDlphWXEMGZ4R6jXJUB69l53C5/XgIM/f2eQjZxF2MTs17/kNTEm8wWo32lie0buD6k70Dvi4Iixc/KhCDKc9gQ8nI/OgZMApTN+buL8/JR3On3whmVrCDh6uUsR4leoKnJnbs6ynqWMpj2BvKtk/N5r1ihruelaSyeu0CA31mGKcuGD23AikZpfmlitYPXG6i5rWSQVuVtDIPg0TrJzD4aXRtDOdZWBRpdyuiso0jyF6Ptkfl4Mz4ixkg093w+FCvAcxLHQidNV1RrQAxtYjBQh0twWvaTBcKaJazddbH9XxN+aSy2pdKUdAfyoaoFf6SuhWPObtJODAIuB+2g9Bna20hRcyOiKDp+sRz13KvzbMF8is7YiIHDgA5AqzkAlGkp0UVknb8k5tEX9C2PysGWzx/ZsCpdsGkRjc6dWgixmJ5xg5sLex2fQ7CFtBv5DqOwi3hYkBWt7tTYtkoB1qVAYiEMN6IeRF59yNYRcOvXtS+Af2JA/iUeufz8Ebmj8QtUQkXu9ECXciEELBhu9KmD3gGJgKm0KuFjrce1Sojr986BPONCEd1DcbINwuPIQtxJhspOJGuR7Xdtw+XriWRxSCQ9z4dqu2hAMRvyB+7K0aXFRt3qFTopGMkpWfwo8SGPVDrxGqGhuxxNUW9v+lOy26DOqe3SkVKIbCDsMSOgnDsEM/hAlIiwxWM5e87eHpn/wD//rAEc5L2sl+lsxz5v8XsajU4RTw6zdC+u4B1vvFASQKFlnKHxiImFTaraU9ahxC36tPVXdgBfvKD73SSQ/Z0eqyTCvEb73VkcPX4tgli/6mUTeaHV4shm+nsVt983OiWEoECAHbXSOsBYdnSzXJmWFI4Ftb3idGjZXUXSvVYTQ81w5yNXgQXTqry1xHGEbqAbFy7x8pG9RynHIoZg/KHMP8g6mPV0GJdwd6bbrIfvKQPfvm/H9xmXuI5NyqwrjxinB1eH8+/ZyI+9fWptNecPDQTyRE6q+jg1rY2u62R5eLZJ1MAsoqhMXBBkK1dysiPRcHOL0OAzSyxF2Bk26tglw/Hsd7Z6pjJzeYoLkpLZbD2n/x0vEJCgqYwlFMCvodCDwz2UbVpavnCG2mnYrKsbAnk2cDKyWd6oz7X4noPpRgB2x1tdkHKaVHkKO/LTmvsd258ehgD5bSW0W7bwRTgTPaiEKgzOMk2UnJhE0vKJyCY+nWIdpoI5GBcD0+do92izR96fpITy0SWxlOs78pGs/AwjdgPq+KMOCynvrrrSrthIHA8rIMcmFykB3v7oei1Uzt5DwxK7tJtT/SH9U9/Bza8VnXWnIVgn1BcAAhCpe46v/thN4A3r91n6+NFle4Q2jspTz8yKqHnJkLa2ppVOF9xtz/8ANWE9436UMeucwrIC7to9frqn02tE++tqEhx4ETWWnyLUyLVz+zWQ1mOt5pSFxAPRwOhOA6U1h+A8U1z0EtfG8d3fGSm/JlIuLTuLx7ZFrs6jZko9zLQ7wirjH0muit0n9uZD+Y37tT0TjxJ/iBAA4J9WpQPEdasSKx8b+ALDeILrKdjM09znnOazpJa6sa8CBaY/AA4bQM0o3YV9F0jdlQGP+T4dyljwlB4TiD1+udznDWTE+SU2MLozaEsLg+sY4TW4HOmsDUqfkhl6Qc1o7cIWEWJ3fuNyITDoqfzLMS2IDWgqSRDPmxKkUqW0oTV+wLw92JSmxz4OLdD4yKATMgLf17426YFxzW5J3+YZGd0aw23dvk+0ktsIv9iXigg8gLRhIjbgQV1jQthb2JAhr7gJcsV5TTFJ0eMURUozh4x9qQoTGKjz8cN30bE759hH0+HHGmGfekyvnpjvOcuQ34Br9ocrLUfKh/+pP15X5aGSnL1j2lbFMdtsos3jr+RAR/R7KcP6s6APnxFxr6vSHBSzqoLvVZYFZ8IT/2U+6csRTdv1sz9KAiHu4vdvAgXDIGlHau2cplcpglVdYFLw1fecfl7wPu03ZUXBZzZnn1wWDxoRLm2cqlsH8aRIip+8o7mlt4T5ikhTmGsRgHlGwLBqsEBR+qCwDwGnX8q8bMxpKBcmpb/ZhnTk3Qelmsm2RbVbfpe4GsgTgJMQnBtYSWBBVUO7PMm+BPsRiDM4D/cqFtvl9+m0lY3OeclNdUcO1XDTbiiJq9Z1WrkEUFHI8yyVvwF0drj8uI6ceIvzUyK19QmVC6pFBXPfZXKVr2h9O2wTzvwCSghACbaRfKv3QhaBg6bfHZ2/fCuUMwpGzwTZuHD7kH31951+XueXyYdjoZrsZUGfznSo2oSS9iSZJPo4qGrvQjmACNr144DoTitNiF4lU/yUYC+hl48BiEFlJpGkRHcM6xEpVpjjwHE63Aw6WM7r9eFwXHgRruPtS6SmL/sVrNEo8JTK1G5IcLSTJpzWJWa9ZeNLhILsVpvbOJaSFooRU+JWUPeq7xXIs0YKwIyD7IStuYTt+hUA81KVfG2MiwnnBgR37wSImpI05IcmfythYbCdurinbCJt61dgXVcCZ7lWxIDl4l6Y2AiMbOiVsTHGJQ5WFbmMfpzA+r0VWz4lijLhz3LHRzrCiSHQoPXkGiBhmT71HIE+l92QGVfKmxkuwzqeR66qSDzaNa0u8+HHsn4lP9cXc1umBi9yxAoLEyY9iCJ0DcB79swfauudHSRFCI42ShvFkGQ6l67M8HAmMAvSNNreZDF8GCrm6azRpJ2Hs9PMFZ3Yv2uTtnyfMs9VrhsPHWbxjyrxY8dABvMzzPLY3re3Ft/FEkZLNlR1S+2bJKT3kdQoFWYKILV8CBl4nV0WHsFtPE+uWdu/d6qTY66IRdzhCEwaqMdXcgjB06E4PvcXIpkTO88340xYYgavWOtcMycMISFfDsN3cFR7FaJ1bJH0CQ4fyLmBRp4XUJsdNBJpXlmd1pyuRZMFSt++gaEwM2jQlVy8eWzi9AQ9TiE9tPjs4CHokbhf/lD0/UCygJ89krRTvc16jeKJMFwyb99ZqeJPuwfDzSZzGBR988ZbqvdXWAfVwjBF9eUvqWK49KRKRe66sY8ywgn/JQ3KAmi6oVx6r+1mncFtzAiKWE4HdroV3JDfRDnrdrOWE6kntALzVG6e7KH2t71jo9CrXLJ1heOhzDZeLdf7RstPEq+vnPrtwYDmLGEgSaxJjD46anmqWNOIF1HfiGbcj1PFCO2HI2hFXuowZixnyZhCwRtrUbm3fq8nf8kBp8BaWwJjudYX6QTf4E8D4+pbCEv9rycKtvRFlw==", + "box": "WMvhao0hMyzkYfVfUkMXq253TVtxlPi72jlPSuVdGQJ5eRQdNhbSTWRyAH5JbkvqBpYC6vweZA7yZ1tA6YK8kLqgZ9Qa24+jTpfAoZrMml+HBe2LhsF+nEzoneE8u/lZzfFDcT8LlkFgn0SqYSD5ZwAcrKjbWT+sFwjHyxyWWqH6fgaSyRwKHAniIsyRIi9o4VpWeKb9DYpWYLQ12kl5vAEy18pfHhU5oPyo95puimxth6tYZGnXENsRkFSfIjSPsLwvyIvTI8/z8MeDiqIu2Lp6oCPSzXp7erDa91hW1/dNLUloWzv682hvw721J2zWr7Z+QBu5RE5A4ghYzyW8g9zeoobAzudhiVfkFNz9g7IB8muRq3bgAfgKtHIX+qDPesCfLCEbEEtHanUPkvg9i/XQH7r+qF30heotJ8EnssqA4gwJOo9x5IBVozLjT1Qj99ejN8UfghwsqV14ItNp8BezKaMt3v0uLbkZ4Zerf/6SPF8LQkEcQyA7JEQPsjJoxIvfIRBw9Nuee6PnXn0Z9VSW7TWGQeeJjrF8vQTsVKWm6qp0d0ZrSUVMkuRtF0BbHXSIQPtAshlMIqXt+ewF9wlDPVpDf6gOzuXViNbAJWQHkMGSyeKYCFlwgf13u7XFy8R9pFT9pciACtwu9YKoQyfu3fX+aj/pceaH/lo0POIxWdzx3wjoiL/OCZkJ81FxYIgNlx3WiV7MVF2sT27sgHju7fMo8fpPBb+Pl4DY6FAenPWSEZMMlLfuBXfTFyZ8TW0cvrxJtzde5EjqiIYD3ziRwBLUThjhT3TTNWNhIvBwPBqUc9nflp2H0BgUE9M28b9W+JEApUTgGlcL2UBiePJJB321v3N5EushukCoiv0/LMTWLjeNm0eZdxVWBsJRJP1ETcyMY7PuX9eordlQZPES7xOHplxn0/TrAKyJkvPfjYFmY3J6yBn1a0ssbfHTkgTvnKhYgRo7vxlhQN/RBKVIdmMrj+uY9tSEz+tk/ttFbcrXslXQaJ0LJ662Q9TGvkmboACy764qxYx2e8v5rnK8Emb3tZzmu+NoPv7y3vYSzJc9MU9b1BOR7XBHwIBIW64e3IvLw96BR6ryKEM+T1pRcOW0vvuRqPpd687TWPpY7WsfizRA7tjRBCQViNhoB0dK8rjDEWkZ5yCTaYqGmbSMSRPkQyXAmVhjwdzVm87nQOXFTRMIoKZU6Cira5g7Uq/EkDFoM69qdk4X5Eqp+g37aGhG9C3XmDhkcWE+kG9Htlx+lEYGVVnFJRPwVrqzZArxTAS5k5FNZB2CQuYqAnZMexOxnvYAIfhabFiJzvDCUdR+ewnfo2wRaKYgG+l4G9gXQk9b4tdDaehPjWCfYqRIW+IFm9Lq6BxVyAaxy1iXWEXrdzp0s8paiMZ/i3u3JOLTH9IrodNOwvC1ik2lNZVUP4Bemx8FHDgvxLo8oyl4/jRC8t+2lNWHXBlgVLsIAO1DlXRvbkZ7mwYJOD1cJPV4TObme3ECbCQJBlwYwt3ljG3bqEj74SWnmXTe6+XZ/H4N6GRycZvMCutqfwhKFC+3oCzxM4s32sFPIPVmdRJSmY9hdUOKziNuc3CtVKPC6JNzOXcGMFcVGXV17AwfZmWrYT1H3g+LgX8goxy569EEpBcwWUWkKnOkVDxl2n8VPx1ODmcZv6Ta+Txyj//3sThhQEnsHOzci9KO+CJzHny37a8oLut0AYUreQor4O91y+MHz9dm1eWkqa4JtpV03lX+o2xOMx6sZlA1Pxq15zgTaxOXVr22bEDpblnw5ECqFDh/vkha+zAQne5utnxs97Lu0O/PP8DHR6051O7ajmejVVzRFcpyce4kHcJ2rtNpFwRVRfpJsvgyeil2UjfAR3wpcXbpouIrin0TUQDKb6jsnVL9+illP62u0iWdenXMpBkA4KQET6nEWLoBQeJRS7m097cUzLtGS5wrOY4PgyG1f00UJhne1jsatLvJKotmNDlhRYMKs5m1Q193Cm6itvcCtHprRE0tyHV/NmSX1EO0i8ZhX2xnjy2PkwUQVBcOWUIgswEqSLwRldr4PYwXnLRkR0S+MF+Kk2SHmqGAGfZJKU60Pp7Y1wPImpJYJLusft5Erj9GzbOu8WHL8iC4WOtJP6CHSS/2lGQAv2xCPDzOc6I7uOHDKwf2c+LQ3F9G/ftqPNKe33Fz+o79bsslSCgk+CttkiuMaoApyD9R4/i0fQiexmqHV3J0uaA3ygec0vTZtM/qah9fn9tufGZ5QfrBo7GlHZWb1D1w5CAJh4JAXocFAHyEWmClwDucMkPSOP226DvtqW2Sjh8C2uouTN3kZlY0FZyLvOWInPBM98pt6ho2MuYgYd8/CD5pbdfDEE/XVxsMoFFz/ZvFiVA3MMv5zUEsYR6mqod9w3QPGEfmlRxrgU3O3Ua1SKkRxvuZ8l5lWlwpu216VilF4ue8QLinmkFr2ctabI5ef1S7tk1TIPiHipkIRG1MKp4wiSAU/W3CwZrc3YxVcS6wKD17SO3PSvquXPPlCTkz7EVJsZ7zCRUvsSgzk6I9jzcX53i4YPTmkQKdmN7VFnlTD/4t/R5h457z/diYZ04kFp3Vdg+luS7qL2Oyw7fmE/o8KUsizRV7YoEfj6xPh18RJ2Y5uXKFGPeln1X+LIoZ4T+1DBSnVfVgZZ4CEAsVqwW45BGLRFFlWXydun8R/TNkn6xi0PtFk6t645hfDOtHNW1mU7W1uGvz9k3w/jqF7pn0hUt+Fm21eOeIWV8eIxiB5XavA4TnoZz/82E1iXN+InYuLWHU8E/fRSi1u1eBzZDgb8XsjnEWgEyJjeJQw2YzISLmHoeAID75ZQl+i+DJAi4l7C3TJXoVI2LnOhdosefn8K9FFpdM5faSsGA3NMPxyhCOOjGGxNi5OPI/lTRakkkGkgMjgbaUN9/SbswZsTrwrNFAt8aGwtNb75onrPddHL6t6Xtq/Zjgj1xxqU1K7bLJSEN83FeRWEdJlbF/5IQ5urRZX06BtXzmECyXvceL5wtxADdbGemjUyjW2WEocajR8l8u4NayhKGaROVybll1zpLOVdcuUzjf91u7JVGiWesU955c+wJbCtSjT8e38g4AUWw+wTSHtPBIP7cWeaZm57t1KjoXtIAP+3nLmAjnAYwkbov3AV393QKudFiSvvSG5iopsOr1z1wHVNaV5o29jNUTa8jo/yjcJURypQ7J9gWRwWgsg0jAgbdVm5GCP09UHHKmQtXrnvozxihEB9/HiAMYe6QPY4CD2u52mWTlzrc3gtGQzShWzaSCfQDmHoX0DSlRqjoiNd4C9nEumKd/Bna6oxSF7RkciscCk3R0lfCaXjLKxJXjxjpaVdTpfIVcBOfIBo8oS+mPRxhnhlF2idju/eY61rzu5yDxwc2A5rObYOwVRc4Dw+n3laqlW0Va2QxlZHqwqJDi0VPdFKJq+aZypWA7CZoIj7PRTTx08IFBRChVntytr0BQIU/2GirdBaWPyi2nXyJzlQFvu6kHvCD7rwsL6TpSxZKWVLjxnF7Y5MwZmhkCttW8ZKJsDBttW+AFIcO3aVBO7Ax0IA4QQEmL/2wUkmvWLUoBLwi0a2DL47NyGjVxRuxC6kX9x/U8asymF7bTpgvyuOugo6hMT5wMdohmYArLXQHuE5TrnzLs9A6gkg3Y/TuwzP4ycueOB2KbZUotSZjvDI2fsCt+a+WSiTQLtTG7a+ULF3GsJRIu00w6Sl9cYq7PKi71YSVRvp1sgZR8CCkE4DFwpzYt0RJ2oXYiYVdjFeflUGBZnbsjXl/EE28BpWgQNuWpDs0sOS5APgfY1wu0PnJR0fKmRAr+WCPTAcIpoyiWF5tVaoNBfEGEK9A7Pq7e2Oh5IhiYK0iQXGfCRjcYFrVGeGW08kq2xngWmFqunYAd9FdSfHZh2sPyauLNgmOW2DGSsZ/YTJvYSmupmWVjy+yICDJvpby1N5JqgMs8gXP90iewtcBdjsRYZRljNGq3cyDjZZlK6KjVB9WL7lOR7gX5jWb4H1MPeFYPyI3uXm/lHGBlReIjAMYpsSgIRBd3NS6qbgAW9azx/Mt6ID7RSJnnfpGlekTbk7jbm6yU69rDl+B9854nyrxi+AayfOf8yM/EvyUrZ357h2B7rLITiWoesb8rSnrppGK0i/if3MF+v/736/VkVfnk5Y0QhMKGiDsWH7fj03h/ijUfulWnM2MThRRip+OEd1NYgHesXHtZCdpmKCZDCfSEjKt6SKhO/DXhgvSFxvOOTF+WMgfhdNARJvYLT9OR+LL2wvwO7jsUhpEW2Row6I4foHT2rEIiyeOwJgYm2XUk+cUOf6Z102qj1Cr/bOx5lvgduUUqTlAUHdQ8ZT8wem7Zvfoyxfl1j+7FNbZh7pNfSoklyuQvjfZhDMwBI91hcE1unNybUnsG5DNDT2fe4SEKO4UAj3gXUAAUc4MvFeN8aqLptKv2fSMNUSSYJDQlXdjvPN62YnPiDmfkRiJwNcYcPCEhTGNoMD71Mz0TSolrLCybOv5U+rHURnFkQCL2BzVv6OoOKVGcv6braX8gDU3jx01XXahocuTlkKlHukZRLh3UDLhsr1wACKt9ZVlcZ84mBSq8i974RtPxaI2/DN0Ixpj2fbtBKf+b4r/6eds3u3Et6i7H0Rz0AJV7qub/ozjT3k6M8SPo1f07JG7i+W8rwzZenIMLTWYgqOal2DVOfGnRjLV2pke5ulOjLw+jD364ZNsOyTVmvxtbX6VFenPfqJ4Mz6Gjy5BrHjEN9odaezXldxWLfSVmxWWhZWloiJk+SbPiSjHjMXZ2JaoHU+TpbQikT3mW4h4+Cjj4A+lVZ7H7lWuLdLJKqJj25lj56WLdY1WK2T1QnCmDUA2iX3xMMqzi/eiIUfYMY088W/CLu9mOwLvrdYaUI1EZByKkGIQYS1c4MuvbFEROo48nNXKpYX7jmAVekjXU2sjSQRWjTxSFtIGtoeA56IA+lQBaos//lnkhnrisQtk+RrN4u4j0tjK7KdE5/PKqehb3832JiSSjw04PxTTFxzAaouhe2lh2qNloLOLyK/lkbPbFCNVyij+WWeXgbQ+BKea28tAPacP8b8gdyyyKGOQ3TOZuuiBRLLkAKNQEDjQGK9d9nH1/kkfpENCOTfzhlnXWphrnty7jrSmydZjt4UyD5WweyKGrrN9OtBuuCOoxRKAdcg4xmhbC8hLaRdf4O+x61yvc+sM+IXQplvwmzq6JOKGTj8KI5PyIDhDwWJkNLcP7bTOoBEY773loWOBYmJlMArCGz9zG017i1iesp8ZqkNny1lWoP0NjvtoF4jvjT3W5oVYW2BxLn1p8Un1yEbYljj8ZOi1c0Ta4y7WUQLH5rJ2bX/W82uaGfC6y8xPVWaAhVEb9YNRh1E3GPqn6tB98/628tulTk6ppMGgX/mgM7AyQeGU5Ll34WerjNlopxurN16vUS4miU/oytuKdGfA0VT8whKM=" + } + ], + "hkdf": [ + { + "base": "6qSRIC9cXjjs9wjHKGA/+ZNxs/n+LlIcNHi974iMcQQ=", + "hostNonce": "LnlCAblfeRX4ekGWMdRi3A==", + "clientNonce": "TbL0CesVZRpu9MZXjtVI5Q==", + "sessionKey": "cbbBEZEwmIMezBw8/Tz/jZTFa6OecaSytMjLuuqsl2Y=" + }, + { + "base": "WITxl+9rsRTHU8MTCBFWTn8r23t2LjOY6Q2NpVVExtA=", + "hostNonce": "wTJNVngovHfTbRVgtrVpVA==", + "clientNonce": "DEAMiy5hNcTDog0gLFewpQ==", + "sessionKey": "ooAGaBScUO44Wz+qvM221EwGaufEzkodg0lucYdDkqw=" + }, + { + "base": "zIWLTEP5GBvucp7D5OpZfgJgdvG6KQkz0WvJ3v65m2o=", + "hostNonce": "oxG9zoym0ZQbTQGAAc2Lmw==", + "clientNonce": "K0CRBb/x0LP4qSz+5Ye5ew==", + "sessionKey": "U44SJCbGrTKH/Rj+PGW1QVivtvI+IjyS+NHXusWNn/Y=" + } + ], + "hostIds": [ + { + "publicKey": "nIYiolgjxDE2y0JyVXQG4HRLZu7f1lpOn0C1FGK2Sm0=", + "hostId": "FjJaGRioHGpL4nGksm-DsN" + }, + { + "publicKey": "A23MIH+JtHGuVHQY7S5E37eCeEVvd2jTrSU4ftZuWVg=", + "hostId": "bow5GgZud2f7i2Sd6pjQz7" + }, + { + "publicKey": "bd7yi6jKZlIIoCsoEd8Sgo1g4intSDdYdEKWOdYdXQY=", + "hostId": "iRIKuJCNZz4WgmkYR0Yiwh" + }, + { + "publicKey": "VuXyx99vMR22rHdUW8PFLypPYxVWC243fQcWFH9xBHc=", + "hostId": "UxYswzvmG7-gkYStmudJIO" + }, + { + "publicKey": "2PR8TwWw3xk2sRu2Mxp0AeF/pSyxJ995BJjI4Fb4fUY=", + "hostId": "LlMo1vMjn5bUvXtQO2taDd" + } + ] +} diff --git a/src/main/index.ts b/src/main/index.ts index 6d503ed9ce..7f019e346b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2767,26 +2767,37 @@ app.whenReady().then(async () => { // project: an SSH project's hook runs on the REMOTE host (write over its ControlMaster), a local // project's on THIS machine (write under os.homedir() — the hook uses $HOME, which may differ from // the project cwd). pendingId is validated before it is interpolated into any path/command. + // + // ONE writer for every answerer: the canvas IPC below and the relay phone's `approvals.answer` + // verb (host-service.ts `HostInboxOps`) both call `answerPermission`, so a phone answer can never + // take a different route than a desktop one. + async function answerPermission( + nodeId: string, + pendingId: string, + decision: 'allow' | 'deny' + ): Promise { + if (!isValidPendingId(pendingId)) return false + if (decision !== 'allow' && decision !== 'deny') return false + const sshProjectId = workspaceStore.sshProjectIdForNode(nodeId) + const ok = + sshProjectId && sshProjectManager + ? await sshProjectManager.writePendingAnswer(sshProjectId, pendingId, decision) + : await writePendingAnswerLocal(pendingId, decision, homedir()) + // Optimistic flip: on a successful write, emit the same synthetic "answered" transition the + // held hook's second POST will produce, so the NEEDS YOU badge clears instantly instead of + // waiting for that POST to round-trip. The later hook POST is an idempotent duplicate (a + // same-state working re-assert is a no-op). See docs/hook-reply-approvals.md. + if (ok) { + const ev = syntheticAnsweredEvent(nodeId, pendingId, decision) + if (ev) emitAgentStatus(ev) + } + return ok + } corePlatform.handle( IPC.agentAnswerPermission, async (payload: { nodeId: string; pendingId: string; decision: 'allow' | 'deny' }) => { const { nodeId, pendingId, decision } = payload ?? ({} as typeof payload) - if (!isValidPendingId(pendingId)) return false - if (decision !== 'allow' && decision !== 'deny') return false - const sshProjectId = workspaceStore.sshProjectIdForNode(nodeId) - const ok = - sshProjectId && sshProjectManager - ? await sshProjectManager.writePendingAnswer(sshProjectId, pendingId, decision) - : await writePendingAnswerLocal(pendingId, decision, homedir()) - // Optimistic flip: on a successful write, emit the same synthetic "answered" transition the - // held hook's second POST will produce, so the NEEDS YOU badge clears instantly instead of - // waiting for that POST to round-trip. The later hook POST is an idempotent duplicate (a - // same-state working re-assert is a no-op). See docs/hook-reply-approvals.md. - if (ok) { - const ev = syntheticAnsweredEvent(nodeId, pendingId, decision) - if (ev) emitAgentStatus(ev) - } - return ok + return answerPermission(nodeId, pendingId, decision) } ) // Read-a-finished-session ack (this feature): the renderer's unread-clear funnel calls it when the @@ -4005,6 +4016,17 @@ app.whenReady().then(async () => { rename: (nodeId: string, title: string) => deliver(IPC.agentRenameNode, { nodeId, title }) } })(), + // A relay phone's Inbox actions (`approvals.answer` / `inbox.ack`): the SAME answer writer the + // canvas Approve/Deny button uses, and the SAME ack pair the `~/.nodeterm/acks` sweep runs for a + // phone on direct SSH — `ackDone` (resolve the done event, dismiss other phones' activities) + // plus the desktop unread clear, WITHOUT a re-ack (the external-clear channel). + inbox: { + answerPermission, + ackRead: (nodeId: string) => { + ackDone(nodeId) + sendToMain(IPC.agentUnreadClear, nodeId) + } + }, // Jail roots beyond the active canvas: the phone browses EVERY project (projects.list), so // its fs/git access spans every local project root — not just the tab the desktop happens // to have focused (that gap read as "cwd is outside the shared project roots" on the phone). diff --git a/src/main/remote/host-inbox-verbs.test.ts b/src/main/remote/host-inbox-verbs.test.ts new file mode 100644 index 0000000000..039126efc9 --- /dev/null +++ b/src/main/remote/host-inbox-verbs.test.ts @@ -0,0 +1,134 @@ +// The relay phone's Inbox verbs (`approvals.answer` / `inbox.ack`). What these pin: +// - Absent injection ⇒ an honest "not served", never a silent success. +// - Every client-sent field is validated BEFORE the writer is asked: the pendingId becomes a file +// name (`~/.nodeterm/pending/.answer`), so a traversal-shaped id must never reach it, and the +// decision is exactly `allow` | `deny` — the literals the held hook script compares against. +// - A writer that answers false (hook already timed out, write failed) comes back as an ok ANSWER +// `{answered:false}`, so the phone can say "already handled" instead of showing a protocol error. +// - `inbox.ack` runs the SAME ack the `~/.nodeterm/acks` sweep runs for a phone on direct SSH. +import { describe, expect, it, vi } from 'vitest' +import { + createHostHandlers, + type HostFsOps, + type HostInboxOps, + type HostPtyManager, + type HostRelaySocket +} from './host-service' + +function makeFakes(over: Partial = {}, served = true) { + const responses: Array<{ id: string; ok: boolean; body: any }> = [] + const socket: HostRelaySocket = { + respond: (id, ok, body) => responses.push({ id, ok, body }), + sendFrame: () => true + } + const fs: HostFsOps = { + listDir: async () => [], + readText: async () => '', + readBinary: async () => '', + writeText: async () => true + } + const inbox: HostInboxOps = { + answerPermission: vi.fn(async () => true), + ackRead: vi.fn(), + ...over + } + const handlers = createHostHandlers( + {} as HostPtyManager, socket, fs, () => [], + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + served ? inbox : undefined + ) + return { handlers, responses, inbox } +} + +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + +describe('approvals.answer', () => { + it('routes a valid answer to the shared writer and reports it', async () => { + const { handlers, responses, inbox } = makeFakes() + handlers.onRpc({ + id: '1', + method: 'approvals.answer', + params: { nodeId: 'term-abc-1', pendingId: 'term-abc-1-1700000000000-4242', decision: 'allow' } + }) + await flush() + expect(inbox.answerPermission).toHaveBeenCalledWith('term-abc-1', 'term-abc-1-1700000000000-4242', 'allow') + expect(responses[0]).toEqual({ id: '1', ok: true, body: { answered: true } }) + }) + + it('answers {answered:false} — an ANSWER, not an error — when nothing was written', async () => { + const { handlers, responses } = makeFakes({ answerPermission: async () => false }) + handlers.onRpc({ + id: '2', + method: 'approvals.answer', + params: { nodeId: 'term-abc-1', pendingId: 'p-1', decision: 'deny' } + }) + await flush() + expect(responses[0]).toEqual({ id: '2', ok: true, body: { answered: false } }) + }) + + it('a writer that throws is still an answer, not a hung request', async () => { + const { handlers, responses } = makeFakes({ + answerPermission: async () => { + throw new Error('disk full') + } + }) + handlers.onRpc({ id: '3', method: 'approvals.answer', params: { nodeId: 'n', pendingId: 'p', decision: 'allow' } }) + await flush() + expect(responses[0]).toEqual({ id: '3', ok: true, body: { answered: false } }) + }) + + it.each([ + ['a traversal-shaped pendingId', { nodeId: 'n', pendingId: '../../etc/passwd', decision: 'allow' }], + ['an empty pendingId', { nodeId: 'n', pendingId: '', decision: 'allow' }], + ['a decision that is not allow/deny', { nodeId: 'n', pendingId: 'p', decision: 'always' }], + ['a missing decision', { nodeId: 'n', pendingId: 'p' }], + ['a control char in the node id', { nodeId: 'n\u001b[2J', pendingId: 'p', decision: 'allow' }], + ['a missing node id', { pendingId: 'p', decision: 'allow' }] + ])('refuses %s before the writer is asked', async (_label, params) => { + const { handlers, responses, inbox } = makeFakes() + handlers.onRpc({ id: '4', method: 'approvals.answer', params }) + await flush() + expect(inbox.answerPermission).not.toHaveBeenCalled() + expect(responses[0].ok).toBe(false) + }) + + it('is "not served" on a host without the injection', async () => { + const { handlers, responses } = makeFakes({}, false) + handlers.onRpc({ id: '5', method: 'approvals.answer', params: { nodeId: 'n', pendingId: 'p', decision: 'allow' } }) + await flush() + expect(responses[0].ok).toBe(false) + expect(responses[0].body.message).toMatch(/not served/) + }) +}) + +describe('inbox.ack', () => { + it('runs the shared read-ack for the node', async () => { + const { handlers, responses, inbox } = makeFakes() + handlers.onRpc({ id: '6', method: 'inbox.ack', params: { nodeId: 'term-abc-1' } }) + await flush() + expect(inbox.ackRead).toHaveBeenCalledWith('term-abc-1') + expect(responses[0]).toEqual({ id: '6', ok: true, body: {} }) + }) + + it('still answers when the ack throws (best-effort, like the file sweep)', async () => { + const { handlers, responses } = makeFakes({ + ackRead: () => { + throw new Error('boom') + } + }) + handlers.onRpc({ id: '7', method: 'inbox.ack', params: { nodeId: 'n' } }) + await flush() + expect(responses[0]).toEqual({ id: '7', ok: true, body: {} }) + }) + + it('refuses an invalid node id and a host without the injection', async () => { + const a = makeFakes() + a.handlers.onRpc({ id: '8', method: 'inbox.ack', params: { nodeId: '' } }) + const b = makeFakes({}, false) + b.handlers.onRpc({ id: '9', method: 'inbox.ack', params: { nodeId: 'n' } }) + await flush() + expect(a.inbox.ackRead).not.toHaveBeenCalled() + expect(a.responses[0].ok).toBe(false) + expect(b.responses[0].ok).toBe(false) + }) +}) diff --git a/src/main/remote/host-service.ts b/src/main/remote/host-service.ts index ab2adf9f1b..dddeec5f44 100644 --- a/src/main/remote/host-service.ts +++ b/src/main/remote/host-service.ts @@ -34,6 +34,7 @@ import { PtyManager, type DetachedSinks } from '../../core/pty-manager' import * as fsOps from '../../core/fs-ops' import { TITLE_MAX, type RemoteNodeInput } from '../../core/project-node-append' import { parseCardLabelEdit, type CardLabelEdit } from '../../core/project-kanban-write' +import { isValidPendingId } from '../../core/agents/pending-approvals' import { getStoredEntitlement, isPremium } from '../../core/license' import { publicKeyToB64, type KeyPair } from './e2ee' import { loadOrCreateHostKeyPair, HostKeyLockedError } from './host-identity' @@ -179,6 +180,27 @@ export interface HostKanbanOps { ): Promise<{ edited: boolean; labels: KanbanLabel[]; cardLabelIds: string[] } | null> } +/** + * The phone's Inbox actions that need the DESKTOP rather than the pane (`approvals.answer` / + * `inbox.ack`). A phone on direct SSH does both by writing files on the host (the hook-reply + * `~/.nodeterm/pending/.answer`, the `~/.nodeterm/acks/.seen` read-ack); a phone on the + * relay cannot — `fs.*` is jailed to the project roots, and rightly so — so without these verbs a + * relay-only phone (every Windows host, and any phone off the LAN) could neither answer a held + * approval nor tell the desktop it read a finished session. Typing `1` instead is NOT a substitute: + * while the hook holds the request the prompt is not on screen yet, so the keystroke would land in + * the agent's composer. + * + * Both land on the SAME functions the desktop's own surfaces use (the canvas Approve/Deny button's + * `agent:answer-permission` handler; the ack sweeper's `ackDone` + unread-clear), so a relay answer + * cannot drift from a local one. Absent ⇒ the verbs answer an honest "not served". + */ +export interface HostInboxOps { + /** Answer a held permission hook. Resolves false when nothing was written. */ + answerPermission(nodeId: string, pendingId: string, decision: 'allow' | 'deny'): Promise + /** The phone READ a finished session: resolve its done event(s) and clear the desktop unread. */ + ackRead(nodeId: string): void +} + interface Stream { sessionId: string /** The node id (tmux persistKey) this stream attached to. The ONLY tmux target a client can @@ -263,7 +285,9 @@ export function createHostHandlers( nodeActions?: HostNodeActions, // Kanban board writes on the phone's behalf (`projects.ensureBoard` / `projects.setCardColumn`). // Absent ⇒ the verbs answer an honest "not served". - kanban?: HostKanbanOps + kanban?: HostKanbanOps, + // Inbox actions (`approvals.answer` / `inbox.ack`). Absent ⇒ the verbs answer "not served". + inbox?: HostInboxOps ): HostHandlers { // streamId -> Stream. PTY callbacks close over their own `streamId` directly, so no // reverse (sessionId -> streamId) index is needed. @@ -659,6 +683,48 @@ export function createHostHandlers( .catch(() => socket.respond(req.id, true, { moved: false })) } + /** + * `approvals.answer {nodeId, pendingId, decision}` → `{answered}` and `inbox.ack {nodeId}` → `{}` + * (see `HostInboxOps`). Every field is client-sent and validated here before it reaches a path: the + * pendingId with the SAME rule the answer writer applies (`isValidPendingId` — it becomes a file + * name), the node id with the rule `node.*` applies. `answered:false` is an ANSWER (the hook already + * timed out, the host could not write), not a protocol error, so the phone can say "already handled". + */ + function handleInbox(req: RpcRequest): void { + if (!inbox) { + socket.respond(req.id, false, { message: `${req.method} is not served on this host.` }) + return + } + const p = asRecord(req.params) + const nodeId = str(p.nodeId) + // eslint-disable-next-line no-control-regex -- refusing control chars is the point + if (!nodeId || nodeId.length > REF_MAX_LEN || /[\x00-\x1f\x7f-\x9f]/.test(nodeId)) { + socket.respond(req.id, false, { message: 'Invalid node id.' }) + return + } + if (req.method === 'inbox.ack') { + try { + inbox.ackRead(nodeId) + } catch { + /* best-effort, exactly like the file-drop sweep it mirrors */ + } + socket.respond(req.id, true, {}) + return + } + const pendingId = str(p.pendingId) ?? '' + const decision = p.decision + if (!isValidPendingId(pendingId) || (decision !== 'allow' && decision !== 'deny')) { + socket.respond(req.id, false, { + message: 'approvals.answer requires a pendingId and a decision of allow or deny.' + }) + return + } + void inbox + .answerPermission(nodeId, pendingId, decision) + .then((answered) => socket.respond(req.id, true, { answered })) + .catch(() => socket.respond(req.id, true, { answered: false })) + } + function handleKill(req: RpcRequest): void { const streamId = num(asRecord(req.params).streamId, -1) const stream = streams.get(streamId) @@ -832,6 +898,10 @@ export function createHostHandlers( case 'node.rename': handleNodeAction(req) break + case 'approvals.answer': + case 'inbox.ack': + handleInbox(req) + break case 'projects.list': // Read-only enumeration of the host's projects/sessions/agent-status (no client params — // nothing to jail). Gated by the same pre-handler approval check in connectHostSession, so @@ -1076,6 +1146,9 @@ export interface HostSessionOptions { /** Kanban board writes for the phone's Board sheet (`projects.ensureBoard` / * `projects.setCardColumn`). Optional: absent ⇒ the verbs answer an honest "not served". */ kanban?: HostKanbanOps + /** Inbox actions for a relay phone (`approvals.answer` / `inbox.ack`). Optional: absent ⇒ the + * verbs answer an honest "not served". */ + inbox?: HostInboxOps /** Extra fs/git jail roots beyond the shared canvas's node cwds — production passes the * workspace's local project cwds: the phone browses EVERY project over `projects.list`, so a * canvas-only jail denied whichever project the desktop didn't happen to have focused. */ @@ -1200,7 +1273,8 @@ export function connectHostSession(opts: HostSessionOptions): HostSession { opts.destroyNode, opts.remoteViewer, opts.nodeActions, - opts.kanban + opts.kanban, + opts.inbox ) canvasSync = createHostCanvasSync(socket, opts.applyMutation) unsubCanvas = opts.subscribeCanvas(() => scheduleBroadcast()) @@ -1231,6 +1305,8 @@ export interface HostBridgeDeps { nodeActions?: HostNodeActions /** Kanban board writes for the phone's Board sheet — see main/index.ts's WorkspaceStore wiring. */ kanban?: HostKanbanOps + /** Inbox actions for a relay phone — the same answer writer / read-ack the desktop itself uses. */ + inbox?: HostInboxOps /** Workspace-level jail roots (local project cwds) merged with the canvas node cwds. */ workspaceRoots?: () => string[] } @@ -1305,6 +1381,7 @@ export function initRemoteHost( remoteViewer: bridge.remoteViewer, nodeActions: bridge.nodeActions, kanban: bridge.kanban, + inbox: bridge.inbox, extraRoots: bridge.workspaceRoots, // Typing attribution: this session's input frames are this phone's keystrokes. getClientId: () => phone.id(), diff --git a/src/main/remote/standing-host.ts b/src/main/remote/standing-host.ts index dbc94f60f8..cae310c91f 100644 --- a/src/main/remote/standing-host.ts +++ b/src/main/remote/standing-host.ts @@ -312,6 +312,7 @@ export function initStandingHost( remoteViewer: bridge.remoteViewer, nodeActions: bridge.nodeActions, kanban: bridge.kanban, + inbox: bridge.inbox, extraRoots: bridge.workspaceRoots, // Typing attribution: this pooled session's input frames are ITS phone's keystrokes. getClientId: () => pooled.presence.id(), From 2f589189684c30891434a5a60391e1be05539600 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:53:23 +0000 Subject: [PATCH 002/284] feat(android): Compose companion app with iOS parity Adds android/app, a Jetpack Compose phone app on top of android/protocol. Like the iOS app, it pairs with the desktop by QR, a pasted code or a nodeterm://pair link, then reaches the computer one of two ways: - direct SSH + tmux on the local network, with the host key pinned on first connect; - the end-to-end encrypted relay, with SAS approval on the computer the first time. Screens: - Sessions, grouped like the desktop sidebar. - A co-attached terminal: xterm.js in a WebView, a native input bar and special keys; swiping scrolls tmux, and OSC 52 copy works. - An offer to resume the agent when the computer has rebooted (a cold start). - New sessions registered on the canvas. - Wake, refresh, rename and end session. - The kanban board, with card moves and labels. - An inbox that answers held approvals by ticket, never with keystrokes, plus read-acks. - Usage. - Settings. Background notifications poll each computer's inbox with WorkManager, because the backend has no FCM leg. Protocol: an SSH transport drop is now reported through onClosed, so the app reconnects instead of reusing a dead socket. PairingPayload accepts the URL and bare-code forms of a pairing code. Desktop: the pairing copy names both phone apps and links to the Android folder. CI builds the protocol tests and the debug APK (.github/workflows/android.yml). docs/android.md records the protocol mapping, what is verified and the known gaps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KCfnRQxqi75Q2abUqLMDwE --- .github/workflows/android.yml | 80 ++++ CLAUDE.md | 16 +- CONTRIBUTING.md | 5 + README.md | 17 +- android/README.md | 71 ++++ android/app/build.gradle.kts | 79 ++++ android/app/proguard-rules.pro | 7 + android/app/src/main/AndroidManifest.xml | 42 +++ .../main/assets/terminal/LICENSE-xterm.txt | 21 ++ .../app/src/main/assets/terminal/addon-fit.js | 2 + .../app/src/main/assets/terminal/index.html | 19 + .../app/src/main/assets/terminal/terminal.js | 121 ++++++ .../app/src/main/assets/terminal/xterm.css | 218 +++++++++++ android/app/src/main/assets/terminal/xterm.js | 2 + .../dev/nodeterm/android/MainActivity.kt | 119 ++++++ .../dev/nodeterm/android/NodetermApp.kt | 51 +++ .../android/conn/ConnectionManager.kt | 258 +++++++++++++ .../dev/nodeterm/android/data/HostStore.kt | 103 ++++++ .../dev/nodeterm/android/data/SecureStore.kt | 94 +++++ .../nodeterm/android/notify/InboxNotifier.kt | 131 +++++++ .../dev/nodeterm/android/ui/BoardTab.kt | 317 ++++++++++++++++ .../dev/nodeterm/android/ui/HostScreen.kt | 170 +++++++++ .../dev/nodeterm/android/ui/HostsScreen.kt | 177 +++++++++ .../dev/nodeterm/android/ui/InboxTab.kt | 280 ++++++++++++++ .../dev/nodeterm/android/ui/PairScreen.kt | 177 +++++++++ .../dev/nodeterm/android/ui/SessionsTab.kt | 346 ++++++++++++++++++ .../dev/nodeterm/android/ui/SettingsScreen.kt | 157 ++++++++ .../nodeterm/android/ui/TerminalController.kt | 317 ++++++++++++++++ .../dev/nodeterm/android/ui/TerminalScreen.kt | 179 +++++++++ .../kotlin/dev/nodeterm/android/ui/Theme.kt | 97 +++++ .../res/drawable/ic_launcher_foreground.xml | 24 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/strings.xml | 8 + android/app/src/main/res/values/themes.xml | 9 + .../main/res/xml/network_security_config.xml | 7 + android/build.gradle.kts | 5 + android/gradle.properties | 4 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 251 +++++++++++++ android/gradlew.bat | 94 +++++ .../dev/nodeterm/protocol/model/JsonReads.kt | 2 +- .../protocol/pairing/PairingPayload.kt | 30 +- .../protocol/ssh/SshHostConnection.kt | 43 ++- .../kotlin/dev/nodeterm/protocol/ModelTest.kt | 16 +- android/settings.gradle.kts | 22 ++ docs/android.md | 102 ++++++ src/renderer/components/PhonePairPopover.tsx | 10 +- .../settings/sections/PhoneSection.tsx | 22 +- src/renderer/lib/links.ts | 4 + 51 files changed, 4314 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/assets/terminal/LICENSE-xterm.txt create mode 100644 android/app/src/main/assets/terminal/addon-fit.js create mode 100644 android/app/src/main/assets/terminal/index.html create mode 100644 android/app/src/main/assets/terminal/terminal.js create mode 100644 android/app/src/main/assets/terminal/xterm.css create mode 100644 android/app/src/main/assets/terminal/xterm.js create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/Theme.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 docs/android.md diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000000..997c84a27a --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,80 @@ +name: Android + +# The Android companion (android/): the pure-Kotlin protocol module, tested against this repo's own +# desktop host code, and the Compose app built against the Android SDK the runner ships. +on: + push: + paths: + - 'android/**' + - 'src/main/remote/**' + - 'src/main/pairing-*.ts' + - '.github/workflows/android.yml' + pull_request: + paths: + - 'android/**' + - 'src/main/remote/**' + - 'src/main/pairing-*.ts' + - '.github/workflows/android.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + protocol: + name: Protocol (JVM + desktop interop) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: 'npm' + + # The interop tests run the desktop's relay host / pairing code through esbuild + node. They + # need tweetnacl, ws and esbuild — not Electron or a rebuilt node-pty, hence no scripts. + - name: Install desktop dependencies (no scripts) + run: npm ci --ignore-scripts + + - name: tmux (SSH transport tests run a real, sandboxed tmux) + run: sudo apt-get update -q && sudo apt-get install -y -q tmux + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Protocol tests + working-directory: android + run: ./gradlew -p protocol test --stacktrace + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: protocol-test-report + path: android/protocol/build/reports/tests/test + + app: + name: App (assembleDebug) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Build debug APK + working-directory: android + run: ./gradlew :app:assembleDebug --stacktrace + + - uses: actions/upload-artifact@v4 + with: + name: nodeterm-android-debug + path: android/app/build/outputs/apk/debug/*.apk diff --git a/CLAUDE.md b/CLAUDE.md index d964d18ebc..11cc9f93b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5384,7 +5384,21 @@ sessions is a hazard whatever kills it; this removes the hazard, not a proven ca — outside contributors cannot see or PR it, so a mobile implication is raised in the desktop PR and **@eneskirca** is mentioned to carry it over (SwiftUI + SwiftTerm/Citadel, tmux-integrated, talks the `TerminalTransport`/RemoteTransport - protocol). + protocol). **The Android companion lives in THIS repo** (`android/`, docs/android.md): a + pure-Kotlin wire layer (`android/protocol`, JVM-tested against this repo's own + `connectHostSession` / `createPairingService` through a local broker, and against a real SSH + server + sandboxed tmux) under a Compose app. It speaks exactly what the standing phone host + serves — the legacy relay dialect of `host-service.ts` and the direct-SSH/tmux conventions — + so **a change to a `host-service.ts` verb, the `projects.list` blob, the pairing payload, the + mirror file, or the SSH-visible file contracts (`~/.nodeterm/pending`, `~/.nodeterm/acks`, + `~/.nodeterm/relay.json`) owes the Android client (and its interop fixture, + `android/protocol/src/test/interop/host-fixture.ts`) in the same PR**; the Android workflow + (`.github/workflows/android.yml`) runs on those paths. Two relay verbs exist for a relay-only + phone and must not be dropped: `approvals.answer` (answers a held hook-reply approval through + the SAME `answerPermission` the canvas button uses — typing `1` is wrong there, the prompt is + not on screen while the hook holds it) and `inbox.ack` (the read-ack the `~/.nodeterm/acks` + sweep runs for an SSH phone). An exact-match tmux PANE target is `=name:`; `=name` alone is + refused ("can't find pane", measured on 3.4) — session commands take `=name`. **The canvas and the kanban board are TWO VIEWS of the same nodes — treat the board as a first-class surface, not an afterthought.** Every session/node feature you add to a canvas node diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88a3e84177..6b26ce1276 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,11 @@ applicable here": cannot open a PR against it, so this is normally a follow-up note rather than same-PR work: say in your PR what the mobile side would need, and **mention @eneskirca** so it gets picked up there. "Not applicable" is a fine answer — just make it a stated one. + The **Android** companion is in this repo (`android/`, see docs/android.md). If you change what + a phone sees — a `host-service.ts` relay verb, the `projects.list` blob, the pairing payload, + the agent-status mirror, or the `~/.nodeterm/{pending,acks,relay.json}` files — update the + Android client in the same PR; `./gradlew -p protocol test` (from `android/`, after + `npm ci --ignore-scripts`) runs it against this repo's own host code. Anything reachable from `window.nodeTerminal` needs a **real** implementation in `src/renderer/bridge/`, or a deliberate, documented degrade. The `satisfies NodeTerminalApi` gate diff --git a/README.md b/README.md index 9d1dc01f52..53f531d145 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ turns that into a **map**: every shell is a node you can place, group, label, an into. Sessions are spatial and persistent, so your mental model stays intact across restarts. And because the app is built around a clean service seam, the same canvas runs three ways — as the **desktop app for macOS, Linux and Windows (beta)**, as a **self-hosted browser app** -you reach from anywhere (Server Edition), and an **iOS companion** that attaches to the -same live sessions. +you reach from anywhere (Server Edition), and **phone companions for iOS and Android** that +attach to the same live sessions. 📚 **Full documentation lives at [nodeterm.dev/docs](https://nodeterm.dev/docs)** — get started, concepts, agents, remote access, troubleshooting. @@ -100,7 +100,7 @@ assign teammates. Toggle with `⌘⇧B`. ### Your sessions, anywhere -**Pair your phone** with one QR — *scan with the nodeterm iOS app* — and the **same +**Pair your phone** with one QR — *scan with the nodeterm app on iPhone or Android* — and the **same live session continues in your pocket**, E2E encrypted **over the relay, not just your LAN**. The same canvas also runs self-hosted in any browser (Server Edition). @@ -147,9 +147,9 @@ GitHub Copilot / opencode / Grok / custom) · 📝 **Sticky note** (link to an a [setup and security details](./docs/github-issues-kanban.md). - **AI commit messages & terminal names** — bring-your-own local agent CLI run read-only on the staged diff or captured output. -- **Your sessions, in your pocket** — **nodeterm mobile** (iOS) attaches to the same live - tmux sessions: watch an agent work, answer a "needs you", or type into any terminal - from your phone — plus push notifications and a mobile board view. +- **Your sessions, in your pocket** — **nodeterm mobile** (iOS, and the Android app in + [`android/`](./android)) attaches to the same live tmux sessions: watch an agent work, answer a + "needs you", or type into any terminal from your phone — plus notifications and a mobile board view. - **Power & sleep** — while an agent is working, nodeterm keeps the machine from idle-sleeping, and lets go the moment it finishes (on by default; toggle in the setup tour or Settings → Behavior). No app can hold a machine awake through a closed lid — @@ -224,6 +224,8 @@ detects your platform. Everything is also listed at [report what breaks](https://github.com/eneskirca/nodeterm/issues). - **iOS** — **nodeterm mobile** on the [App Store](https://apps.apple.com/app/nodeterm/id6790581233). +- **Android** — build it from [`android/`](./android) (`./gradlew :app:assembleDebug`); CI also + attaches a debug APK to every run of the Android workflow. Not in a store yet. **Trying it out?** Removal is one script — it stops every process nodeterm started, reverts the status-hook/skill entries it merged into your agent CLIs' config (your own hooks and @@ -318,7 +320,8 @@ on macOS** and applies to both canvas and kanban terminals. - **React Flow is the single source of truth** for live nodes; projects persist serialized nodes to disk, and tmux keeps sessions alive across restarts. - **Three surfaces** — the desktop app, the browser **Server Edition**, and the - **mobile companion** (a separate SwiftUI repo) all ride the same core + transport seams. + **mobile companions** (iOS in a separate SwiftUI repo, Android in [`android/`](./android)) all + ride the same core + transport seams. See [`docs/SERVER.md`](./docs/SERVER.md) for the Server Edition, and the design docs under [`docs/`](./docs) for deeper notes. diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000000..c8a77ee5d3 --- /dev/null +++ b/android/README.md @@ -0,0 +1,71 @@ +# nodeterm for Android + +The Android companion to nodeterm: pair your phone with nodeterm on your computer, then watch your +agents, answer their questions and open any terminal on the canvas — on your network over SSH, or +from anywhere through the end-to-end encrypted relay. It is the Android counterpart of the iOS app +and speaks the same protocol to the same desktop; nothing on the computer needs to know which phone +it is talking to. + +## What it does + +| | Android | Notes | +|---|---|---| +| Pair by QR (or pasted code, or a `nodeterm://pair` link) | ✓ | E2EE-sealed `/pair`, Ed25519 key made on the phone | +| Direct connection on your network (SSH + tmux) | ✓ | TOFU-pinned host key; the desktop's own tmux socket | +| From anywhere (relay, E2EE, SAS approval) | ✓ | Pin-once: approve the phone on the computer the first time | +| Late relay adoption (paired while remote access was off) | ✓ | Reads `~/.nodeterm/relay.json` over SSH, mints its own device token | +| Sessions, grouped like the desktop sidebar | ✓ | Needs you / Running / Sleeping, activity + context % | +| Terminal (co-attach to the live tmux session) | ✓ | xterm.js renderer, native input bar, special keys, swipe = tmux scroll, OSC 52 copy | +| Cold-start resume offer (the computer rebooted) | ✓ | Offers the agent's own `--resume `; never types it unasked | +| New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches | +| Wake / refresh / rename / end session | relay (end: both) | `node.*` verbs; over SSH "end" stops the tmux session only | +| Kanban board, move cards, labels | relay (read-only on SSH) | `projects.ensureBoard/setCardColumn/editCardLabels` | +| Inbox: approvals, questions, finished turns | ✓ | Held hook approvals are answered deterministically (never with keystrokes) | +| Read-ack (reading a finished session clears it on the computer) | ✓ | `inbox.ack` over the relay, `~/.nodeterm/acks` over SSH | +| Usage (rate limits per account) | ✓ | From the agent-status mirror | +| Notifications | local | See "Notifications" below | + +## Build + +```bash +cd android +./gradlew :app:assembleDebug # → app/build/outputs/apk/debug/app-debug.apk +./gradlew -p protocol test # the wire layer; see below +``` + +Needs JDK 17+ and the Android SDK (Android Studio's, or `ANDROID_HOME`). `minSdk` 26, `targetSdk` 35. +CI builds the debug APK on every change under `android/` (`.github/workflows/android.yml`) and +attaches it to the run. + +## Layout + +- **`protocol/`** — everything that goes on the wire, as a plain Kotlin/JVM library with no Android + dependency, so it is tested on a JVM: NaCl box (a TweetNaCl port), the relay client, the host + RPC vocabulary, pairing, the direct-SSH transport, and the parsers for what the computer serves. + Its tests run the **desktop's own code** on the other end of the wire — `connectHostSession` and + `createPairingService` through a local relay broker (`src/test/interop/host-fixture.ts`, bundled + with the repo's esbuild), and the SSH transport against a real SSH server and a sandboxed tmux. + Run `npm ci --ignore-scripts` at the repo root first; without node the interop tests skip. +- **`app/`** — the Compose UI on top: pairing, the computers list, a computer's Sessions / Board / + Inbox / Usage tabs, the terminal screen, settings, background notifications. + +## Notifications + +The iOS app is woken by APNs pushes the nodeterm backend sends. That backend has no Android (FCM) +leg, so this app checks each computer's Inbox periodically in the background (WorkManager's floor +is ~15 minutes) and live every 8 seconds while a computer is open. Real-time push on Android needs +an FCM leg in the backend. + +## Security + +- The phone's relay identity (a Curve25519 box key) and SSH identity (an Ed25519 seed) are + generated on the device and stored encrypted under an Android Keystore AES-GCM key. Only their + public halves ever leave the phone. +- Relay traffic is end-to-end encrypted (NaCl box under a per-session HKDF key) and checked exactly + as the desktop checks it: role byte (no reflections), strictly increasing sequence numbers (no + replays), no re-key once ready, and the host key pinned from pairing. +- The SSH host key is pinned on first connect; a changed key is refused, never routed around. +- No cleartext HTTP anywhere; the LAN `/pair` POST runs over a raw socket and is sealed to the host + key from the QR. + +Design notes, the protocol mapping and known gaps: [`docs/android.md`](../docs/android.md). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000000..d8bbe331b4 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "dev.nodeterm.android" + compileSdk = 35 + + defaultConfig { + applicationId = "dev.nodeterm.android" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + // sshj + BouncyCastle rely on reflection; shrinking needs keep rules nobody has written yet. + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + resources { + // bcprov + bcpkix both ship this multi-release manifest; sshj pulls both in. + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE.md", + "META-INF/LICENSE-notice.md", + "META-INF/INDEX.LIST" + ) + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation("dev.nodeterm:protocol:0.1.0") + + val composeBom = platform("androidx.compose:compose-bom:2025.06.00") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-core") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.core:core-ktx:1.16.0") + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.1") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1") + implementation("androidx.lifecycle:lifecycle-process:2.9.1") + implementation("androidx.work:work-runtime-ktx:2.10.1") + + // QR pairing (ZXing; no Play Services dependency). + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + // sshj needs the FULL BouncyCastle provider on Android (the platform's "BC" is a stripped copy). + implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000000..6a44cea9a2 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# Minification is off (see build.gradle.kts). If it is ever turned on, sshj, BouncyCastle and +# EdDSA need keeps for their reflective provider/algorithm lookups. +-keep class net.schmizz.sshj.** { *; } +-keep class org.bouncycastle.** { *; } +-keep class net.i2p.crypto.eddsa.** { *; } +-dontwarn javax.naming.** +-dontwarn org.slf4j.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..60780f62c4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/terminal/LICENSE-xterm.txt b/android/app/src/main/assets/terminal/LICENSE-xterm.txt new file mode 100644 index 0000000000..4472336c9f --- /dev/null +++ b/android/app/src/main/assets/terminal/LICENSE-xterm.txt @@ -0,0 +1,21 @@ +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) +Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com) +Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/android/app/src/main/assets/terminal/addon-fit.js b/android/app/src/main/assets/terminal/addon-fit.js new file mode 100644 index 0000000000..9b25be35bb --- /dev/null +++ b/android/app/src/main/assets/terminal/addon-fit.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})())); +//# sourceMappingURL=addon-fit.js.map \ No newline at end of file diff --git a/android/app/src/main/assets/terminal/index.html b/android/app/src/main/assets/terminal/index.html new file mode 100644 index 0000000000..63301716ec --- /dev/null +++ b/android/app/src/main/assets/terminal/index.html @@ -0,0 +1,19 @@ + + + + + + + + + +
+ + + + + diff --git a/android/app/src/main/assets/terminal/terminal.js b/android/app/src/main/assets/terminal/terminal.js new file mode 100644 index 0000000000..faaddc73ad --- /dev/null +++ b/android/app/src/main/assets/terminal/terminal.js @@ -0,0 +1,121 @@ +// The phone's terminal: xterm.js (the desktop's emulator) as a RENDERER for a tmux client that +// lives on the computer. Android talks to it through two narrow channels: +// Android → page: window.nt.* functions, called with base64 so no byte ever needs JS escaping; +// page → Android: window.NodetermBridge (addJavascriptInterface), strings only. +// tmux owns scrolling (its mouse is on and the pane is on the alternate screen), so a vertical +// swipe becomes wheel notches the HOST writes into the pane — exactly what the relay's +// `pty.scroll` does — and xterm keeps no scrollback of its own worth scrolling. +(function () { + 'use strict' + var bridge = window.NodetermBridge + var fontSize = 13 + var term = new Terminal({ + fontSize: fontSize, + fontFamily: 'monospace', + cursorBlink: true, + scrollback: 1000, + allowProposedApi: true, + macOptionIsMeta: true, + theme: { background: '#000000', foreground: '#e6e6e6', cursor: '#e6e6e6' } + }) + var fit = new FitAddon.FitAddon() + term.loadAddon(fit) + var host = document.getElementById('term') + term.open(host) + + var lastCols = 0 + var lastRows = 0 + function doFit(force) { + try { fit.fit() } catch (e) { /* not laid out yet */ } + if (force || term.cols !== lastCols || term.rows !== lastRows) { + lastCols = term.cols + lastRows = term.rows + bridge.onResize(term.cols, term.rows) + } + } + window.addEventListener('resize', function () { doFit(false) }) + + function b64ToBytes(b64) { + var bin = atob(b64) + var out = new Uint8Array(bin.length) + for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out + } + function b64ToText(b64) { + return new TextDecoder('utf-8').decode(b64ToBytes(b64)) + } + + // Keystrokes typed INTO the terminal (a hardware keyboard, or the soft keyboard when the user + // taps the terminal itself) go straight to the pane. + term.onData(function (d) { bridge.onInput(d) }) + term.onBinary(function (d) { bridge.onInput(d) }) + + // Copy: tmux's copy-mode emits OSC 52 (set-clipboard on). Write-only — a read query ('?') is + // refused, exactly as the desktop's handler refuses it. + term.parser.registerOscHandler(52, function (data) { + var idx = data.indexOf(';') + var payload = idx >= 0 ? data.slice(idx + 1) : data + if (payload && payload !== '?') bridge.onCopy(payload) + return true + }) + + // Vertical swipe → tmux history scroll. + var startY = null + var acc = 0 + host.addEventListener('touchstart', function (e) { + if (e.touches.length === 1) { startY = e.touches[0].clientY; acc = 0 } + }, { passive: true }) + host.addEventListener('touchmove', function (e) { + if (startY === null || e.touches.length !== 1) return + var y = e.touches[0].clientY + acc += y - startY + startY = y + var step = fontSize * 1.4 + var up = 0 + var down = 0 + while (acc > step) { acc -= step; up++ } + while (acc < -step) { acc += step; down++ } + if (up) bridge.onScroll(true, up) + if (down) bridge.onScroll(false, down) + e.preventDefault() + }, { passive: false }) + host.addEventListener('touchend', function () { startY = null }) + + window.nt = { + write: function (b64) { term.write(b64ToBytes(b64)) }, + // The attach snapshot: the current screen, painted before live output. + paint: function (b64) { term.reset(); term.write(b64ToText(b64).replace(/\r?\n/g, '\r\n')) }, + reset: function () { term.reset() }, + focus: function () { term.focus() }, + blur: function () { term.blur() }, + setFontSize: function (n) { fontSize = n; term.options.fontSize = n; doFit(true) }, + refit: function () { doFit(true) }, + // A composed line from the native input bar. `term.paste` frames it as a bracketed paste when + // the client side asked for one, so a multi-line prompt reaches an agent CLI as ONE paste; the + // Enter is a separate, slightly later write (an Enter inside the paste write is what left + // Codex holding a rendered-but-unsubmitted envelope — see CLAUDE.md, settled submit). + // Special keys from the native key row. Arrows follow the pane's DECCKM state, which this + // emulator tracks because it parses the very stream the pane writes. + key: function (name) { + var app = term.modes.applicationCursorKeysMode + var csi = app ? '\x1bO' : '\x1b[' + var map = { + up: csi + 'A', down: csi + 'B', right: csi + 'C', left: csi + 'D', + home: '\x1b[H', end: '\x1b[F', pgup: '\x1b[5~', pgdn: '\x1b[6~', + esc: '\x1b', tab: '\t', stab: '\x1b[Z', enter: '\r', nl: '\x1b\r' + } + var seq = map[name] + if (seq) bridge.onInput(seq) + }, + submit: function (b64, enter) { + var text = b64ToText(b64) + if (text) term.paste(text) + if (enter) setTimeout(function () { bridge.onInput('\r') }, text ? 150 : 0) + } + } + + setTimeout(function () { + doFit(true) + bridge.onReady() + }, 30) +})() diff --git a/android/app/src/main/assets/terminal/xterm.css b/android/app/src/main/assets/terminal/xterm.css new file mode 100644 index 0000000000..e97b643905 --- /dev/null +++ b/android/app/src/main/assets/terminal/xterm.css @@ -0,0 +1,218 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm .xterm-scroll-area { + visibility: hidden; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility:not(.debug), +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .xterm-accessibility-tree:not(.debug) *::selection { + color: transparent; +} + +.xterm .xterm-accessibility-tree { + user-select: text; + white-space: pre; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} diff --git a/android/app/src/main/assets/terminal/xterm.js b/android/app/src/main/assets/terminal/xterm.js new file mode 100644 index 0000000000..7ca75f8e5e --- /dev/null +++ b/android/app/src/main/assets/terminal/xterm.js @@ -0,0 +1,2 @@ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=document.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},3551:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585),c=i(4725);let l=t.Linkifier=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=l=s([r(1,c.IMouseService),r(2,c.IRenderService),r(3,h.IBufferService),r(4,c.ILinkProviderService)],l)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let c=-1,l=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):h(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,c=o.extended.urlId):(l=-1,c=-1)}}t(s)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(3551),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),w=i(8969),y=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O=i(779);class P extends w.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new y.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new y.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new y.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new y.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new y.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new y.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new y.EventEmitter),this._onBlur=this.register(new y.EventEmitter),this._onA11yCharEmitter=this.register(new y.EventEmitter),this._onA11yTabEmitter=this.register(new y.EventEmitter),this._onWillOpen=this.register(new y.EventEmitter),this._setup(),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(O.LinkProviderService),this._instantiationService.setService(S.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,y.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,y.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this._viewportScrollArea=this._document.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this.register((0,r.addDisposableDomListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement),this.textarea=this._document.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this.register(this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.linkifier=this.register(this._instantiationService.createInstance(n.Linkifier,this.screenElement)),this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(e))return!1;if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):this.viewport?.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure(),this.viewport?.syncScrollArea(!0)}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderDimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderDimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderDimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(i=e),s=""}}return{bufferElements:r,cursorElement:i}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(4725),o=i(844),a=i(2585);let h=t.BufferDecorationRenderer=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],h)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(4725),a=i(844),h=i(2585),c={full:0,left:0,center:0,right:0},l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=t.OverviewRulerRenderer=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement);const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,a.toDisposable)((()=>{this._canvas?.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);l.full=this._canvas.width,l.left=e,l.center=t,l.right=e,this._refreshDrawHeightConstants(),d.full=0,d.left=0,d.center=l.left,d.right=l.left+l.center}_refreshDrawHeightConstants(){c.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);c.left=t,c.center=t,c.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-c[e.position||"full"]/2),l[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+c[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,o.IRenderService),r(5,h.IOptionsService),r(6,o.ICoreBrowserService)],_)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(6052),l=i(4725),d=i(8055),_=i(8460),u=i(844),f=i(2585),v="xterm-dom-renderer-owner-",p="xterm-rows",g="xterm-fg-",m="xterm-bg-",S="xterm-focus",C="xterm-selection";let b=1,w=t.DomRenderer=class extends u.Disposable{constructor(e,t,i,s,r,a,l,d,f,g,m,S,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=l,this._charSizeService=f,this._optionsService=g,this._bufferService=m,this._coreBrowserService=S,this._themeService=w,this._terminalClass=b++,this._rowElements=[],this._selectionRenderModel=(0,c.createSelectionRenderModel)(),this.onRequestRedraw=this.register(new _.EventEmitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(p),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(C),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(v+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,u.toDisposable)((()=>{this._element.classList.remove(v+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${p} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${p} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${p} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${C} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${C} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${C} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${g}${i} { color: ${s.css}; }${this._terminalSelector} .${g}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${m}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;this._selectionRenderModel.update(this._terminal,e,t,i);const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow;if(n>=this._bufferService.rows||o<0)return;const a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${v}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=w=s([r(7,f.IInstantiationService),r(8,l.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,l.ICoreBrowserService),r(12,l.IThemeService)],w)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(w&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){I.isInvisible()?y+=o.WHITESPACE_CELL_CHAR:y+=N,w++;continue}w&&(C.textContent=y),C=this._document.createElement("span"),w=0,y=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F&&this._coreService.isCursorInitialized)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),y=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===y&&(y=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===y&&(y=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.channels.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,G)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=y:w++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&w&&(C.textContent=y),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.computeNextVariantOffset=t.createRenderDimensions=t.treatGlyphAsBackgroundColor=t.allowRescaling=t.isEmoji=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},6052:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=void 0;class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),h=Math.min(o,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}t.createSelectionRenderModel=function(){return new i}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this.register(new d(this._optionsService))}catch{this._measureStrategy=this.register(new l(e,t,this._optionsService))}this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c extends a.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class l extends c{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends c{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(844),r=i(8460),n=i(3656);class o extends s.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=new a(this._window),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this.register(new r.EventEmitter),this.onWindowChange=this._onWindowChange.event,this.register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this.register((0,r.forwardEvent)(this._screenDprMonitor.onDprChange,this._onDprChange)),this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this.register(new s.MutableDisposable),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this.register((0,s.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,n.addDisposableDomListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},779:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(844);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this.register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(6193),o=i(4725),a=i(8460),h=i(844),c=i(7226),l=i(2585);let d=t.RenderService=class extends h.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,l,d){super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new h.MutableDisposable),this._pausedResizeTask=new c.DebouncedIdleTask,this._observerDisposable=this.register(new h.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new a.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new a.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new a.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new a.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),l),this.register(this._renderDebouncer),this.register(l.onDprChange((()=>this.handleDevicePixelRatioChange()))),this.register(o.onResize((()=>this._fullRefresh()))),this.register(o.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(o.cols,o.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(o.buffer.y,o.buffer.y,!0)))),this.register(d.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(l.window,t),this.register(l.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,h.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d=s([r(2,l.IOptionsService),r(3,o.ICharSizeService),r(4,l.IDecorationService),r(5,l.IBufferService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],d)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;let i=0,s=0,r=0,n=0;var o,a,h,c,l;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function _(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=l.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=l.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=l.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1]),s=parseInt(h[2]),r=parseInt(h[3]),n=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(c||(t.rgb=c={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,h=t>>16&255,c=t>>8&255,l=e>>24&255,d=e>>16&255,_=e>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=c.relativeLuminance(e>>8),n=c.relativeLuminance(i>>8);if(_(r,n)>8));if(o_(r,c.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),h=_(r,c.relativeLuminance(o>>8));if(h_(r,c.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(l||(t.rgba=l={})),t.toPaddedHex=d,t.contrastRatio=_},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.runAndSubscribe=t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))},t.runAndSubscribe=function(e,t){return t(void 0),e((e=>t(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(1480),g=i(6242),m=i(6351),S=i(5941),C={"(":0,")":1,"*":2,"+":3,"-":1,".":2},b=131072;function w(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let E=0;class k extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>b&&(n=this._parseStack.position+b)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthb)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(h){const e=f;let t=this._activeBuffer.x-m;for(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),m>0&&f instanceof l.BufferLine&&f.copyCellsFrom(e,t,0,m,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}else if(d&&(f.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,u)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!w(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!w(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(D(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,S.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,S.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=k;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function D(e){return 0<=e&&e<256}L=s([r(0,v.IBufferService)],L)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(1480),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,h=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rescaleOverlappingGlyphs:!1,rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this.register((0,r.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);class n{static extractShouldJoin(e){return 0!=(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let h=n.extractWidth(a);n.extractShouldJoin(a)&&(h-=n.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt new file mode 100644 index 0000000000..2037e92590 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -0,0 +1,119 @@ +package dev.nodeterm.android + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.core.content.ContextCompat +import dev.nodeterm.android.ui.HostScreen +import dev.nodeterm.android.ui.HostsScreen +import dev.nodeterm.android.ui.NodetermTheme +import dev.nodeterm.android.ui.PairScreen +import dev.nodeterm.android.ui.SettingsScreen +import dev.nodeterm.android.ui.TerminalScreen + +/** The screens. A plain back stack: five destinations, one deep link (the pairing URL). */ +sealed interface Route { + data object Hosts : Route + data class PairHost(val code: String? = null) : Route + data object Settings : Route + data class Host(val hostId: String, val tab: Int = 0) : Route + data class Terminal(val hostId: String, val nodeId: String, val title: String) : Route +} + +class Navigator(initial: Route) { + val stack = mutableStateListOf(initial) + val current: Route get() = stack.last() + + fun push(route: Route) { + stack.add(route) + } + + fun pop(): Boolean { + if (stack.size <= 1) return false + stack.removeAt(stack.lastIndex) + return true + } + + fun replaceAll(route: Route) { + stack.clear() + stack.add(route) + } +} + +class MainActivity : ComponentActivity() { + /** A `nodeterm://pair?code=` link that arrived (at launch or while running) and awaits the UI. */ + private var incomingPairCode by mutableStateOf(null) + + private fun takePairLink(intent: Intent?) { + val data = intent?.data ?: return + if (intent.action == Intent.ACTION_VIEW && data.scheme == "nodeterm" && data.host == "pair") { + incomingPairCode = data.toString() + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + takePairLink(intent) + } + + private val notificationPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* the Settings switch reflects it */ } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val graph = NodetermApp.graph(this) + if (Build.VERSION.SDK_INT >= 33 && graph.hosts.notificationsEnabled && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + val openHost = intent?.getStringExtra(EXTRA_HOST_ID) + takePairLink(intent) + setContent { + NodetermTheme { + val nav = remember { + Navigator(Route.Hosts).also { n -> + if (openHost != null && graph.hosts.get(openHost) != null) n.push(Route.Host(openHost, tab = 2)) + } + } + val code = incomingPairCode + LaunchedEffect(code) { + if (code != null) { + incomingPairCode = null + nav.push(Route.PairHost(code)) + } + } + AppContent(nav) + } + } + } + + companion object { + const val EXTRA_HOST_ID = "hostId" + } +} + +@Composable +private fun AppContent(nav: Navigator) { + BackHandler(enabled = nav.stack.size > 1) { nav.pop() } + when (val r = nav.current) { + Route.Hosts -> HostsScreen(nav) + is Route.PairHost -> PairScreen(nav, r.code) + Route.Settings -> SettingsScreen(nav) + is Route.Host -> HostScreen(nav, r.hostId, r.tab) + is Route.Terminal -> TerminalScreen(nav, r.hostId, r.nodeId, r.title) + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt b/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt new file mode 100644 index 0000000000..47616a3952 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt @@ -0,0 +1,51 @@ +package dev.nodeterm.android + +import android.app.Application +import android.content.Context +import dev.nodeterm.android.conn.ConnectionManager +import dev.nodeterm.android.data.HostStore +import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.android.notify.InboxNotifier +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.pairing.SshIdentity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security + +/** The app's singletons. Constructed once, in [NodetermApp.onCreate]. */ +class AppGraph(context: Context) { + val secure = SecureStore(context) + val hosts = HostStore(context) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val connections = ConnectionManager(this) + + /** The phone's persistent relay identity — the key every paired desktop PINS. */ + val boxKeys: BoxKeyPair by lazy { BoxKeyPair.fromSecretKey(secure.getOrCreate32(SecureStore.BOX_SECRET)) } + + /** The phone's SSH identity (Ed25519); its public half is in each computer's authorized_keys. */ + val sshIdentity: SshIdentity by lazy { SshIdentity.fromSeed(secure.getOrCreate32(SecureStore.SSH_SEED)) } +} + +class NodetermApp : Application() { + lateinit var graph: AppGraph + private set + + override fun onCreate() { + super.onCreate() + // Android ships a stripped "BC" provider under the same name; sshj asks for the "BC" provider + // BY NAME for its ciphers and key exchanges, so replace it before any SSH connection is made. + // Appended, not inserted first: nothing else in the app (TLS, the Keystore) should start + // resolving algorithms through it. + Security.removeProvider("BC") + Security.addProvider(BouncyCastleProvider()) + graph = AppGraph(this) + InboxNotifier.createChannels(this) + InboxNotifier.schedule(this, graph.hosts.notificationsEnabled) + } + + companion object { + fun graph(context: Context): AppGraph = (context.applicationContext as NodetermApp).graph + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt new file mode 100644 index 0000000000..5885e1ac38 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -0,0 +1,258 @@ +package dev.nodeterm.android.conn + +import dev.nodeterm.android.AppGraph +import dev.nodeterm.android.data.RoutePreference +import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.RelayConnectStatus +import dev.nodeterm.protocol.host.RelayConnector +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.RelayBlock +import dev.nodeterm.protocol.relay.RelayApi +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.SshHostConnection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +sealed interface ConnState { + data object Idle : ConnState + data class Connecting(val detail: String) : ConnState + /** The desktop is showing its approval dialog; the human compares this code. */ + data class AwaitingApproval(val sas: String) : ConnState + data class Connected(val kind: TransportKind) : ConnState + data class Failed(val message: String) : ConnState +} + +/** + * One paired computer's live connection and latest listing. Route choice mirrors the iOS app: the + * direct-SSH leg first when the computer installed a key for us (it is the LAN, it is fast, and it + * needs no relay), the E2EE relay when that fails or the computer is relay-only (Windows). + */ +class HostSession(val hostId: String, private val graph: AppGraph) { + private val scope: CoroutineScope get() = graph.scope + private val _state = MutableStateFlow(ConnState.Idle) + val state: StateFlow = _state.asStateFlow() + private val _snapshot = MutableStateFlow(ProjectsSnapshot.EMPTY) + val snapshot: StateFlow = _snapshot.asStateFlow() + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError.asStateFlow() + + private val mutex = Mutex() + @Volatile private var conn: HostConnection? = null + private var pollJob: Job? = null + private var watchers = 0 + + val connection: HostConnection? get() = conn + + /** A screen is showing this computer right now (so its connection is worth keeping open). */ + val isWatched: Boolean get() = synchronized(this) { watchers > 0 } + + suspend fun ensureConnected(): HostConnection { + conn?.let { return it } + return mutex.withLock { conn ?: connectLocked() } + } + + private suspend fun connectLocked(): HostConnection { + val host = graph.hosts.get(hostId) ?: throw HostException("This computer is no longer paired.") + val route = graph.hosts.route(hostId) + val errors = ArrayList() + + if (route != RoutePreference.RELAY_ONLY && host.sshAvailable) { + _state.value = ConnState.Connecting("Connecting on your network…") + try { + val ssh = withContext(Dispatchers.IO) { + SshHostConnection.connect( + host.host, host.port, host.user, graph.sshIdentity, pinFor(host), + connectTimeoutMs = if (route == RoutePreference.AUTO) 4_000 else 10_000 + ) + } + adopt(ssh) + scope.launch { runCatching { adoptRelayIfAdvertised(ssh, host) } } + return ssh + } catch (e: HostKeyChangedException) { + // A changed host key is a security signal, not a network hiccup: say so, and do not + // quietly route around it. + val msg = e.message ?: "The computer's SSH host key changed." + _state.value = ConnState.Failed(msg) + throw HostException(msg) + } catch (e: Exception) { + errors += "On your network: ${e.message ?: e.javaClass.simpleName}" + } + } + + if (route != RoutePreference.SSH_ONLY) { + val relay = host.relay + val token = graph.secure.getString(SecureStore.relayTokenKey(host.id)) + val hostKey = host.relayHostKeyB64 + if (relay != null && token != null && hostKey != null) { + _state.value = ConnState.Connecting("Connecting through the relay…") + try { + val join = RelayApi(graph.hosts.apiBase).join(token, relay.hostId) + val connected = RelayConnector.connect( + relayUrl = relay.relayEndpoint, + token = join.pairingToken, + deviceKeys = graph.boxKeys, + hostPublicKeyB64 = hostKey, + onStatus = { st -> + when (st) { + is RelayConnectStatus.AwaitingApproval -> _state.value = ConnState.AwaitingApproval(st.sas) + RelayConnectStatus.Handshaking -> _state.value = ConnState.Connecting("Verifying your computer…") + RelayConnectStatus.MintingToken -> Unit + } + } + ) + _snapshot.value = connected.first + adopt(connected.connection) + return connected.connection + } catch (e: Exception) { + errors += "Through the relay: ${e.message ?: e.javaClass.simpleName}" + } + } else if (route == RoutePreference.RELAY_ONLY || !host.sshAvailable) { + errors += "Remote access isn't set up for this computer yet. In nodeterm on the computer, open " + + "Settings → Phone and turn on remote access, then pair again — or connect once on the same network." + } + } + + val msg = errors.joinToString("\n").ifEmpty { "Couldn't connect." } + _state.value = ConnState.Failed(msg) + throw HostException(msg) + } + + private fun adopt(c: HostConnection) { + conn = c + _lastError.value = null + _state.value = ConnState.Connected(c.kind) + c.setOnClosed { reason -> + if (conn === c) { + conn = null + _state.value = ConnState.Failed("Disconnected" + (reason?.let { " ($it)" } ?: "") + ".") + if (watchers > 0) scope.launch { + delay(1_500) + refreshNow() + } + } + } + c.setOnChanged { scope.launch { refreshNow() } } + } + + private fun pinFor(host: PairedHost) = object : HostKeyPin { + override fun pinned(): String? = graph.hosts.get(host.id)?.sshHostKeyFingerprint + override fun pin(fingerprint: String) = graph.hosts.update(host.id) { it.copy(sshHostKeyFingerprint = fingerprint) } + } + + /** + * LATE ADOPTION (relay-advertise.ts): a phone paired while remote access was off has no relay leg. + * While the standing host is up, the computer advertises its relay identity in + * `~/.nodeterm/relay.json`; read it over this TOFU-verified SSH connection and mint our own device + * token, so the phone can reach the computer from anywhere without re-pairing. + */ + private suspend fun adoptRelayIfAdvertised(ssh: SshHostConnection, host: PairedHost) { + val tokenKey = SecureStore.relayTokenKey(host.id) + if (host.relay != null && graph.secure.getString(tokenKey) != null) return + val ad = ssh.readRelayAdvertisement() ?: return + val hostIdAd = J.str(ad["hostId"]) ?: return + val pub = J.str(ad["hostPublicKeyB64"]) ?: return + val endpoint = J.str(ad["relayEndpoint"])?.takeIf(PairingPayload::isAllowedRelayEndpoint) ?: return + val hostDeviceId = J.str(ad["hostDeviceId"]) ?: return + // The QR's host key is the identity we paired with: an advertisement naming another key is + // not this computer's relay identity. + if (host.hostKeyB64 != null && host.hostKeyB64 != pub) return + val minted = RelayApi(graph.hosts.apiBase).mintDevice( + deviceId = graph.hosts.deviceId, + hostDeviceId = hostDeviceId, + hostPublicKeyB64 = pub, + label = graph.hosts.deviceName, + priorDeviceToken = graph.secure.getString(tokenKey) + ) + graph.secure.putString(tokenKey, minted.deviceToken) + graph.hosts.update(host.id) { + it.copy(relay = RelayBlock(minted.hostId.ifEmpty { hostIdAd }, pub, endpoint)) + } + } + + /** Re-list now. Connects first when needed; failures land in [state]/[lastError], never throw. */ + suspend fun refreshNow() { + try { + val c = ensureConnected() + _snapshot.value = c.listProjects() + _lastError.value = null + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + _lastError.value = e.message + // A HostException is an ANSWER (the host refused, or the connection already reported its + // own drop). Anything else is an unexpected transport failure: drop the connection so the + // next refresh dials a fresh one instead of reusing a dead socket. + if (e !is HostException) disconnect() + } + } + + fun refresh() { + scope.launch { refreshNow() } + } + + /** While a screen shows this computer, re-list every 8 s (the iOS foreground cadence). */ + @Synchronized + fun startWatching() { + watchers++ + if (pollJob == null) { + pollJob = scope.launch { + while (isActive) { + refreshNow() + delay(POLL_MS) + } + } + } + } + + @Synchronized + fun stopWatching() { + watchers = (watchers - 1).coerceAtLeast(0) + if (watchers == 0) { + pollJob?.cancel() + pollJob = null + } + } + + fun disconnect() { + val c = conn + conn = null + runCatching { c?.close() } + _state.value = ConnState.Idle + } + + companion object { + const val POLL_MS = 8_000L + } +} + +class ConnectionManager(private val graph: AppGraph) { + private val sessions = HashMap() + + @Synchronized + fun session(hostId: String): HostSession = sessions.getOrPut(hostId) { HostSession(hostId, graph) } + + @Synchronized + fun forget(hostId: String) { + sessions.remove(hostId)?.disconnect() + } + + @Synchronized + fun all(): List = sessions.values.toList() +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt new file mode 100644 index 0000000000..05e6dfbabd --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt @@ -0,0 +1,103 @@ +package dev.nodeterm.android.data + +import android.content.Context +import dev.nodeterm.protocol.model.PairedHost +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import java.util.UUID + +/** How to reach a computer. Auto = direct SSH on the LAN first, the relay when that fails. */ +enum class RoutePreference { AUTO, SSH_ONLY, RELAY_ONLY } + +/** + * The paired computers (public facts only — see [PairedHost]) plus phone-level preferences. Secrets + * are in [SecureStore]. + */ +class HostStore(context: Context) { + private val prefs = context.getSharedPreferences("nodeterm.hosts", Context.MODE_PRIVATE) + private val _hosts = MutableStateFlow(load()) + val hosts: StateFlow> = _hosts.asStateFlow() + + private fun load(): List { + val raw = prefs.getString("hosts", null) ?: return emptyList() + return try { + (Json.parseToJsonElement(raw) as? JsonArray)?.mapNotNull { (it as? JsonObject)?.let(PairedHost::fromJson) } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + } + + private fun save(list: List) { + prefs.edit().putString("hosts", JsonArray(list.map { it.toJson() }).toString()).apply() + _hosts.value = list + } + + fun get(id: String): PairedHost? = _hosts.value.firstOrNull { it.id == id } + + /** Re-pairing the same computer (same host + user) replaces the old record. */ + @Synchronized + fun upsert(host: PairedHost) { + save(_hosts.value.filterNot { it.id == host.id || (it.host == host.host && it.user == host.user && it.name == host.name) } + host) + } + + @Synchronized + fun update(id: String, change: (PairedHost) -> PairedHost) { + save(_hosts.value.map { if (it.id == id) change(it) else it }) + } + + @Synchronized + fun remove(id: String) { + save(_hosts.value.filterNot { it.id == id }) + prefs.edit().remove("route.$id").apply() + } + + fun route(id: String): RoutePreference = + runCatching { RoutePreference.valueOf(prefs.getString("route.$id", null) ?: "AUTO") }.getOrDefault(RoutePreference.AUTO) + + fun setRoute(id: String, route: RoutePreference) { + prefs.edit().putString("route.$id", route.name).apply() + } + + /** The phone's own stable id — the key the relay backend stores its device row under. */ + val deviceId: String + get() = prefs.getString("deviceId", null) ?: UUID.randomUUID().toString().also { + prefs.edit().putString("deviceId", it).apply() + } + + var deviceName: String + get() = prefs.getString("deviceName", null) ?: (android.os.Build.MODEL ?: "Android phone") + set(value) { + prefs.edit().putString("deviceName", value.trim().ifEmpty { android.os.Build.MODEL ?: "Android phone" }).apply() + } + + var apiBase: String + get() = prefs.getString("apiBase", null) ?: dev.nodeterm.protocol.relay.RelayApi.DEFAULT_API_BASE + set(value) { + prefs.edit().putString("apiBase", value.trim().trimEnd('/')).apply() + } + + var notificationsEnabled: Boolean + get() = prefs.getBoolean("notify", true) + set(value) { + prefs.edit().putBoolean("notify", value).apply() + } + + var fontSize: Int + get() = prefs.getInt("fontSize", 13) + set(value) { + prefs.edit().putInt("fontSize", value.coerceIn(8, 24)).apply() + } + + /** Inbox events this phone has seen/read (phone-local, like iOS). Bounded. */ + fun seenEvents(): Set = prefs.getStringSet("seenEvents", emptySet()) ?: emptySet() + + fun markSeen(ids: Collection) { + if (ids.isEmpty()) return + val next = (seenEvents() + ids).toList().takeLast(500).toSet() + prefs.edit().putStringSet("seenEvents", next).apply() + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt new file mode 100644 index 0000000000..37b2e23f0d --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt @@ -0,0 +1,94 @@ +package dev.nodeterm.android.data + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Secrets at rest: AES-256-GCM under a key that lives in the Android Keystore (it never leaves the + * secure hardware / keystore daemon), ciphertext in app-private SharedPreferences. What it holds is + * exactly what the iOS app keeps in its Keychain: + * + * - the phone's persistent NaCl box secret key — the identity the desktop PINS on first approval, + * so it must survive restarts (a fresh key per launch would re-prompt every time and break pinning); + * - the phone's Ed25519 SSH seed (its public half sits in the computer's authorized_keys); + * - relay device tokens, one per paired computer (bearer credentials for `/v1/relay/join`). + * + * `allowBackup="false"` in the manifest keeps the ciphertext off cloud backups too; a restored + * copy could not be decrypted on another device anyway. + */ +class SecureStore(context: Context) { + private val prefs = context.getSharedPreferences("nodeterm.secure", Context.MODE_PRIVATE) + + private val key: SecretKey by lazy { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (ks.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + .apply { + init( + KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + } + .generateKey() + } + + @Synchronized + fun putBytes(name: String, value: ByteArray) { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + val sealed = cipher.iv + cipher.doFinal(value) + prefs.edit().putString(name, Base64.encodeToString(sealed, Base64.NO_WRAP)).apply() + } + + @Synchronized + fun getBytes(name: String): ByteArray? { + val raw = prefs.getString(name, null) ?: return null + return try { + val sealed = Base64.decode(raw, Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, IV_BYTES)) + cipher.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES) + } catch (_: Exception) { + // A value we cannot open (keystore wiped, restored onto another device) is not a crash: + // the caller regenerates / asks the user to re-pair. + null + } + } + + fun putString(name: String, value: String) = putBytes(name, value.toByteArray(Charsets.UTF_8)) + fun getString(name: String): String? = getBytes(name)?.toString(Charsets.UTF_8) + + @Synchronized + fun remove(name: String) { + prefs.edit().remove(name).apply() + } + + /** Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). */ + @Synchronized + fun getOrCreate32(name: String): ByteArray { + getBytes(name)?.takeIf { it.size == 32 }?.let { return it } + val fresh = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) } + putBytes(name, fresh) + return fresh + } + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val ALIAS = "nodeterm.secure.v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_BYTES = 12 + + const val BOX_SECRET = "box.secret" + const val SSH_SEED = "ssh.seed" + fun relayTokenKey(hostId: String) = "relay.token.$hostId" + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt new file mode 100644 index 0000000000..bfb7280ed5 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -0,0 +1,131 @@ +package dev.nodeterm.android.notify + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dev.nodeterm.android.MainActivity +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.R +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +/** + * Notifications for "an agent needs you" / "an agent finished". + * + * The iOS app gets these as APNs pushes fanned out by the nodeterm backend (src/core/push-notify.ts). + * That backend has no Android (FCM) leg, so this app cannot be woken the same way. What it does + * instead, honestly: a periodic WorkManager check (Android's floor is 15 minutes) that reads each + * paired computer's Inbox and raises a local notification for events it has not announced yet — plus + * the in-app 8 s refresh while a computer's screen is open. A real push leg is backend work. + */ +object InboxNotifier { + private const val CH_ATTENTION = "attention" + private const val CH_DONE = "done" + private const val WORK = "nodeterm.inbox" + + fun createChannels(context: Context) { + if (Build.VERSION.SDK_INT < 26) return + val nm = context.getSystemService(NotificationManager::class.java) ?: return + nm.createNotificationChannel( + NotificationChannel(CH_ATTENTION, context.getString(R.string.channel_attention), NotificationManager.IMPORTANCE_HIGH) + .apply { description = context.getString(R.string.channel_attention_desc) } + ) + nm.createNotificationChannel( + NotificationChannel(CH_DONE, context.getString(R.string.channel_done), NotificationManager.IMPORTANCE_DEFAULT) + .apply { description = context.getString(R.string.channel_done_desc) } + ) + } + + fun schedule(context: Context, enabled: Boolean) { + val wm = WorkManager.getInstance(context) + if (!enabled) { + wm.cancelUniqueWork(WORK) + return + } + val req = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + wm.enqueueUniquePeriodicWork(WORK, ExistingPeriodicWorkPolicy.KEEP, req) + } + + /** Announce events not announced before. Returns how many were posted. */ + fun announce(context: Context, host: PairedHost, snapshot: ProjectsSnapshot): Int { + val graph = NodetermApp.graph(context) + if (!graph.hosts.notificationsEnabled) return 0 + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) return 0 + val seen = graph.hosts.seenEvents() + val fresh = snapshot.status?.inbox?.events.orEmpty().filter { ev -> + ev.id !in seen && !ev.resolved && System.currentTimeMillis() - ev.ts < 6 * 3_600_000L + } + if (fresh.isEmpty()) return 0 + val nm = NotificationManagerCompat.from(context) + for (ev in fresh.takeLast(5)) { + nm.notify("${host.id}:${ev.id}".hashCode(), build(context, host, snapshot, ev)) + } + graph.hosts.markSeen(fresh.map { it.id }) + return fresh.size + } + + private fun build(context: Context, host: PairedHost, snapshot: ProjectsSnapshot, ev: InboxEvent): android.app.Notification { + val node = snapshot.findNode(ev.nodeId)?.second + val nodeTitle = snapshot.statusOf(ev.nodeId)?.name ?: node?.title ?: "Session" + val headline = when (ev.kind) { + InboxKind.APPROVAL, InboxKind.QUESTION -> "Needs you — $nodeTitle" + InboxKind.DONE -> "Completed — $nodeTitle" + } + val open = PendingIntent.getActivity( + context, + host.id.hashCode(), + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.EXTRA_HOST_ID, host.id) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(context, if (ev.kind == InboxKind.DONE) CH_DONE else CH_ATTENTION) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(headline) + .setContentText(ev.title + (ev.detail?.let { " — $it" } ?: "")) + .setStyle(NotificationCompat.BigTextStyle().bigText(listOfNotNull(ev.title, ev.detail).joinToString("\n"))) + .setSubText(host.name) + .setWhen(ev.ts) + .setAutoCancel(true) + .setContentIntent(open) + .build() + } +} + +class InboxWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val graph = NodetermApp.graph(applicationContext) + for (host in graph.hosts.hosts.value) { + val session = graph.connections.session(host.id) + val watched = session.isWatched + withTimeoutOrNull(45_000) { session.refreshNow() } + InboxNotifier.announce(applicationContext, host, session.snapshot.value) + // Don't hold a socket open in the background for a screen nobody is looking at. + if (!watched && !session.isWatched) session.disconnect() + } + return Result.success() + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt new file mode 100644 index 0000000000..cd7e79b083 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt @@ -0,0 +1,317 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.Route +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.model.KanbanLabel +import dev.nodeterm.protocol.model.NodeInfo +import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.launch + +/** The board palette (`KANBAN_LABEL_COLORS`, @shared/kanban-labels), for chips and for "create". */ +private val LABEL_COLORS = listOf("gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red") + +private fun labelColor(name: String): Color = when (name) { + "gray" -> Color(0xFF8E8E93) + "brown" -> Color(0xFFA2845E) + "orange" -> Color(0xFFFF9F0A) + "yellow" -> Color(0xFFFFD60A) + "green" -> Color(0xFF32D74B) + "blue" -> Color(0xFF0A84FF) + "purple" -> Color(0xFFBF5AF2) + "pink" -> Color(0xFFFF375F) + "red" -> Color(0xFFFF453A) + else -> Color(0xFF636366) +} + +private const val UNGROUPED = "__ungrouped__" + +/** + * The project's kanban board, as the desktop's `KanbanView` derives it: cards are the session nodes + * (terminal / sticky / browser — `toKanbanSession`), the virtual Ungrouped column holds every card + * with no (or a dangling) assignment, and assignment is board metadata only. Moves and labels go + * through the host's `projects.*` verbs — never a file write from the phone. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: ProjectsSnapshot) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val projects = snapshot.openProjects() + var projectId by remember { mutableStateOf(null) } + val project = projects.firstOrNull { it.id == projectId } ?: projects.firstOrNull() + var projectMenu by remember { mutableStateOf(false) } + var moving by remember { mutableStateOf(null) } + var labeling by remember { mutableStateOf(null) } + val writable = session.connection?.capabilities?.boardWrites == true + + if (project == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No projects yet.") } + return + } + + fun write(label: String, block: suspend () -> Unit) { + scope.launch { + try { + block() + session.refreshNow() + } catch (e: Exception) { + Toast.makeText(context, "$label: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Box { + OutlinedButton(onClick = { projectMenu = true }) { + ColorDot(parseHex(project.color, NtColors.accent)) + Spacer(Modifier.width(8.dp)) + Text(project.name) + } + DropdownMenu(expanded = projectMenu, onDismissRequest = { projectMenu = false }) { + projects.forEach { p -> + DropdownMenuItem(text = { Text(p.name) }, onClick = { + projectId = p.id + projectMenu = false + }) + } + } + } + Spacer(Modifier.width(8.dp)) + if (!writable) { + Text( + "Read-only here — board edits need the relay connection.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + val cards = project.nodes.filter { it.kind == NodeKind.TERMINAL || it.kind == NodeKind.STICKY || it.kind == NodeKind.BROWSER } + val board = project.board + val columns = board?.columns?.map { it.id to it.title } ?: emptyList() + val known = columns.map { it.first }.toSet() + val byColumn = cards.groupBy { card -> board?.columnOf(card.id)?.takeIf { it in known } ?: UNGROUPED } + // Order within a column = the assignments' relative order (Ungrouped keeps canvas order). + val order = board?.assignments?.map { it.first }?.withIndex()?.associate { it.value to it.index } ?: emptyMap() + val allColumns = listOf(UNGROUPED to "Ungrouped") + + (columns.ifEmpty { dev.nodeterm.protocol.model.KanbanBoard.DEFAULT_COLUMN_TITLES.map { "default:$it" to it } }) + + Row(Modifier.fillMaxSize().horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp)) { + allColumns.forEach { (colId, title) -> + val colCards = byColumn[colId].orEmpty().sortedBy { order[it.id] ?: Int.MAX_VALUE } + Column( + Modifier + .width(260.dp) + .fillMaxHeight() + .padding(4.dp) + .background(NtColors.panel, RoundedCornerShape(10.dp)) + .padding(8.dp) + ) { + val color = board?.columns?.firstOrNull { it.id == colId }?.color + Row(verticalAlignment = Alignment.CenterVertically) { + if (color != null) { + ColorDot(parseHex(color)) + Spacer(Modifier.width(6.dp)) + } + Text(title, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(6.dp)) + Text("${colCards.size}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 8.dp)) { + items(colCards, key = { it.id }) { card -> + BoardCard( + card, project, snapshot, + onClick = { + if (card.kind == NodeKind.TERMINAL) nav.push(Route.Terminal(hostId, card.id, displayTitle(card, snapshot))) + }, + onLongClick = { if (writable) moving = card } + ) + } + } + } + } + } + } + + moving?.let { card -> + AlertDialog( + onDismissRequest = { moving = null }, + title = { Text(displayTitle(card, snapshot)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text("Move to", style = MaterialTheme.typography.labelLarge) + val cols = project.board?.columns?.map { it.id to it.title } + TextButton(onClick = { + moving = null + write("Move") { session.ensureConnected().setCardColumn(project.id, card.id, null) } + }) { Text("Ungrouped") } + if (cols == null) { + // No board yet: ask the host to seed the default one, then move into its column. + dev.nodeterm.protocol.model.KanbanBoard.DEFAULT_COLUMN_TITLES.forEachIndexed { i, t -> + TextButton(onClick = { + moving = null + write("Move") { + val conn = session.ensureConnected() + val seeded = conn.ensureBoard(project.id) ?: error("this project can't have a board written") + val target = seeded.firstOrNull { it.title == t } ?: seeded.getOrNull(i) ?: error("no such column") + conn.setCardColumn(project.id, card.id, target.id) + } + }) { Text(t) } + } + } else { + cols.forEach { (id, t) -> + TextButton(onClick = { + moving = null + write("Move") { session.ensureConnected().setCardColumn(project.id, card.id, id) } + }) { Text(t) } + } + } + TextButton(onClick = { + labeling = card + moving = null + }) { Text("Labels…") } + } + }, + confirmButton = { TextButton(onClick = { moving = null }) { Text("Close") } } + ) + } + + labeling?.let { card -> + LabelsDialog( + card = card, + project = project, + onDismiss = { labeling = null }, + onApply = { edit -> + labeling = null + write("Labels") { session.ensureConnected().editCardLabels(project.id, card.id, edit) } + } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun BoardCard(card: NodeInfo, project: ProjectInfo, snapshot: ProjectsSnapshot, onClick: () -> Unit, onLongClick: () -> Unit) { + val meta = project.board?.metaOf(card.id) + val labels = meta?.labels.orEmpty().mapNotNull { id -> project.board?.labels?.firstOrNull { it.id == id } } + Column( + Modifier + .fillMaxWidth() + .background(NtColors.panel2, RoundedCornerShape(8.dp)) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ColorDot(parseHex(card.color)) + Spacer(Modifier.width(6.dp)) + Text(if (card.kind == NodeKind.STICKY) (card.text ?: card.title).take(80) else displayTitle(card, snapshot), maxLines = 2) + } + if (labels.isNotEmpty() || meta?.priority != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + meta?.priority?.let { LabelChip(it, if (it == "urgent" || it == "high") "red" else "gray") } + labels.forEach { LabelChip(it.name, it.color) } + } + } + StatusBadge(snapshot.statusOf(card.id)?.bucket) + } +} + +@Composable +private fun LabelChip(text: String, color: String) { + val c = labelColor(color) + Text( + text, + fontSize = 11.sp, + color = c, + modifier = Modifier.background(c.copy(alpha = 0.18f), RoundedCornerShape(4.dp)).padding(horizontal = 6.dp, vertical = 1.dp) + ) +} + +@Composable +private fun LabelsDialog(card: NodeInfo, project: ProjectInfo, onDismiss: () -> Unit, onApply: (CardLabelEdit) -> Unit) { + val palette: List = project.board?.labels.orEmpty() + val current = project.board?.metaOf(card.id)?.labels.orEmpty().toSet() + var selected by remember(card.id) { mutableStateOf(current) } + var newName by remember { mutableStateOf("") } + var newColor by remember { mutableStateOf("blue") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Labels") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + palette.forEach { l -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = l.id in selected, onCheckedChange = { on -> selected = if (on) selected + l.id else selected - l.id }) + LabelChip(l.name, l.color) + } + } + OutlinedTextField(value = newName, onValueChange = { newName = it.take(60) }, label = { Text("New label") }, singleLine = true) + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + LABEL_COLORS.forEach { c -> + TextButton(onClick = { newColor = c }) { + Text(if (c == newColor) "● $c" else c, color = labelColor(c), fontSize = 12.sp) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + val add = (selected - current).toList() + val remove = (current - selected).toList() + val create = if (newName.isNotBlank()) listOf(newName.trim() to newColor) else emptyList() + if (add.isEmpty() && remove.isEmpty() && create.isEmpty()) onDismiss() + else onApply(CardLabelEdit(add = add, remove = remove, create = create)) + }) { Text("Apply") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt new file mode 100644 index 0000000000..88c4cda3bb --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -0,0 +1,170 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.conn.ConnState +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.TransportKind + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val host = graph.hosts.get(hostId) + if (host == null) { + LaunchedEffect(hostId) { nav.pop() } + return + } + val session = remember(hostId) { graph.connections.session(hostId) } + val state by session.state.collectAsState() + val snapshot by session.snapshot.collectAsState() + var tab by rememberSaveable { mutableIntStateOf(initialTab) } + var newSession by remember { mutableStateOf(false) } + + DisposableEffect(hostId) { + session.startWatching() + onDispose { session.stopWatching() } + } + + val needsYou = snapshot.status?.inbox?.events?.count { it.actionable } ?: 0 + val canCreate = (state as? ConnState.Connected)?.kind == TransportKind.RELAY && + snapshot.projects.any { !it.closed && it.sshTarget == null } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(host.name) + Text( + when (val s = state) { + is ConnState.Connected -> if (s.kind == TransportKind.SSH) "On your network" else "Through the relay · end-to-end encrypted" + is ConnState.Connecting -> s.detail + is ConnState.AwaitingApproval -> "Waiting for approval" + is ConnState.Failed -> "Offline" + ConnState.Idle -> "" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + actions = { IconButton(onClick = { session.refresh() }) { Icon(Icons.Filled.Refresh, "Refresh") } } + ) + }, + floatingActionButton = { + if (tab == 0 && canCreate) { + ExtendedFloatingActionButton( + onClick = { newSession = true }, + icon = { Icon(Icons.Filled.Add, null) }, + text = { Text("New session") } + ) + } + } + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + ConnectionBanner(state, session) + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Sessions") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Board") }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text(if (needsYou > 0) "Inbox ($needsYou)" else "Inbox") }) + Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Usage") }) + } + when (tab) { + 0 -> SessionsTab(nav, hostId, session, snapshot) + 1 -> BoardTab(nav, hostId, session, snapshot) + 2 -> InboxTab(nav, hostId, session, snapshot) + else -> UsageTab(snapshot) + } + } + } + + if (newSession) { + NewSessionDialog( + snapshot = snapshot, + onDismiss = { newSession = false }, + onCreate = { request -> + newSession = false + PendingLaunches.put(request) + nav.push(dev.nodeterm.android.Route.Terminal(hostId, request.nodeId, request.title)) + Toast.makeText(context, "Starting ${request.title}…", Toast.LENGTH_SHORT).show() + } + ) + } +} + +@Composable +private fun ConnectionBanner(state: ConnState, session: HostSession) { + when (state) { + is ConnState.AwaitingApproval -> Column( + Modifier.fillMaxWidth().background(NtColors.panel2).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text("Approve this phone on your computer", fontWeight = FontWeight.SemiBold) + Text( + state.sas, + fontSize = 34.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = NtColors.accent + ) + Text( + "nodeterm on your computer is showing a code. Approve only if it matches this one — a different code " + + "means someone is in the middle. You only do this once per phone.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + is ConnState.Failed -> Column( + Modifier.fillMaxWidth().background(NtColors.attention.copy(alpha = 0.12f)).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(state.message, style = MaterialTheme.typography.bodySmall) + Row { Button(onClick = { session.refresh() }) { Text("Try again") } } + } + else -> Unit + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt new file mode 100644 index 0000000000..9e7a481a28 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt @@ -0,0 +1,177 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.Route +import dev.nodeterm.android.conn.ConnState +import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.PairedHost + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun HostsScreen(nav: Navigator) { + val graph = NodetermApp.graph(LocalContext.current) + val hosts by graph.hosts.hosts.collectAsState() + var removing by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("nodeterm") }, + actions = { + IconButton(onClick = { nav.push(Route.Settings) }) { Icon(Icons.Filled.Settings, "Settings") } + } + ) + }, + floatingActionButton = { + if (hosts.isNotEmpty()) { + ExtendedFloatingActionButton( + onClick = { nav.push(Route.PairHost()) }, + icon = { Icon(Icons.Filled.Add, null) }, + text = { Text("Pair a computer") } + ) + } + } + ) { padding -> + if (hosts.isEmpty()) { + Column( + Modifier.fillMaxSize().padding(padding).padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Your terminals, in your pocket", style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Spacer(Modifier.padding(8.dp)) + Text( + "Pair this phone with nodeterm on your computer to watch your agents, answer their questions and " + + "open any terminal on the canvas — on your network, or from anywhere through the end-to-end " + + "encrypted relay.", + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.padding(12.dp)) + Button(onClick = { nav.push(Route.PairHost()) }) { Text("Pair a computer") } + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp)) { + items(hosts, key = { it.id }) { host -> + val session = graph.connections.session(host.id) + val state by session.state.collectAsState() + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .combinedClickable( + onClick = { nav.push(Route.Host(host.id)) }, + onLongClick = { removing = host } + ) + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + ColorDot(stateColor(state)) + Spacer(Modifier.width(10.dp)) + Text(host.name, style = MaterialTheme.typography.titleMedium) + } + Text( + "${host.user}@${host.host}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + routeSummary(host, graph.secure.getString(SecureStore.relayTokenKey(host.id)) != null) + + stateSuffix(state), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + removing?.let { host -> + AlertDialog( + onDismissRequest = { removing = null }, + title = { Text("Forget ${host.name}?") }, + text = { + Text( + "This phone forgets the pairing and its keys for this computer. To revoke the phone's access on " + + "the computer too, remove it under nodeterm → Settings → Phone → Paired devices." + ) + }, + confirmButton = { + TextButton(onClick = { + graph.connections.forget(host.id) + graph.secure.remove(SecureStore.relayTokenKey(host.id)) + graph.hosts.remove(host.id) + removing = null + }) { Text("Forget") } + }, + dismissButton = { TextButton(onClick = { removing = null }) { Text("Cancel") } } + ) + } +} + +fun routeSummary(host: PairedHost, hasRelayToken: Boolean): String { + val legs = buildList { + if (host.sshAvailable) add("On your network") + if (host.relay != null && hasRelayToken) add("From anywhere") + } + return if (legs.isEmpty()) "No route configured" else legs.joinToString(" · ") +} + +private fun stateSuffix(state: ConnState): String = when (state) { + is ConnState.Connected -> if (state.kind == TransportKind.SSH) " — connected (network)" else " — connected (relay)" + is ConnState.Connecting -> " — connecting…" + is ConnState.AwaitingApproval -> " — waiting for approval" + is ConnState.Failed -> " — offline" + ConnState.Idle -> "" +} + +fun stateColor(state: ConnState) = when (state) { + is ConnState.Connected -> NtColors.success + is ConnState.Connecting, is ConnState.AwaitingApproval -> NtColors.warning + is ConnState.Failed -> NtColors.attention + ConnState.Idle -> NtColors.muted +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt new file mode 100644 index 0000000000..594c3f6918 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -0,0 +1,280 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.Route +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.UsageAccount +import dev.nodeterm.protocol.model.UsageLimit +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The Agents feed (docs/mobile-usage-inbox.md): unresolved approvals and questions on top — with + * deterministic Approve/Deny for a held hook-reply approval — then live activity, then the archive. + * Read/unread is phone-local, like iOS; `resolved` from the computer only moves cards out of the + * actionable list. + */ +@Composable +fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: ProjectsSnapshot) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val scope = rememberCoroutineScope() + var showArchive by remember { mutableStateOf(false) } + val inbox = snapshot.status?.inbox + val events = inbox?.events.orEmpty().sortedByDescending { it.ts } + val actionable = events.filter { it.actionable } + val archived = events.filterNot { it.actionable } + val working = snapshot.status?.nodes.orEmpty() + .filter { (id, st) -> st.state == AgentState.WORKING && inbox?.nodes?.get(id)?.activity != null } + + LaunchedEffect(actionable.map { it.id }) { graph.hosts.markSeen(actionable.map { it.id }) } + + fun titleOf(nodeId: String): String = + snapshot.findNode(nodeId)?.second?.let { displayTitle(it, snapshot) } ?: snapshot.statusOf(nodeId)?.name ?: "Session" + + fun open(nodeId: String) = nav.push(Route.Terminal(hostId, nodeId, titleOf(nodeId))) + + fun run(label: String, block: suspend () -> QuickActions.Result, nodeId: String) { + scope.launch { + try { + when (block()) { + QuickActions.Result.SENT -> Toast.makeText(context, label, Toast.LENGTH_SHORT).show() + QuickActions.Result.ALREADY_HANDLED -> Toast.makeText(context, "Already handled.", Toast.LENGTH_SHORT).show() + QuickActions.Result.OPEN_SESSION -> open(nodeId) + } + session.refreshNow() + } catch (e: Exception) { + Toast.makeText(context, e.message ?: "Couldn't reach the computer.", Toast.LENGTH_LONG).show() + } + } + } + + if (events.isEmpty() && working.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text("Nothing needs you right now.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + return + } + + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(actionable, key = { "a-${it.id}" }) { ev -> + EventCard(ev, titleOf(ev.nodeId), snapshot, highlight = true, onOpen = { open(ev.nodeId) }) { + if (ev.kind == InboxKind.APPROVAL) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + run("Approved.", { QuickActions.answerApproval(session.ensureConnected(), ev, allow = true) }, ev.nodeId) + }) { Text("Approve") } + OutlinedButton(onClick = { + run("Denied.", { QuickActions.answerApproval(session.ensureConnected(), ev, allow = false) }, ev.nodeId) + }) { Text("Deny") } + TextButton(onClick = { open(ev.nodeId) }) { Text("Open") } + } + } else if (ev.options.isNotEmpty() && !ev.multiSelect) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + ev.options.forEachIndexed { i, opt -> + OutlinedButton(onClick = { + run("Answered.", { QuickActions.answerQuestion(session.ensureConnected(), ev, i) }, ev.nodeId) + }, modifier = Modifier.fillMaxWidth()) { Text("${i + 1}. $opt", maxLines = 2) } + } + } + } else { + TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } + } + } + } + items(working.keys.toList(), key = { "w-$it" }) { nodeId -> + val now = inbox?.nodes?.get(nodeId) + Column( + Modifier.fillMaxWidth().background(NtColors.panel, RoundedCornerShape(10.dp)).clickable { open(nodeId) }.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + StatusBadge(dev.nodeterm.protocol.model.SessionBucket.RUNNING) + Spacer(Modifier.width(8.dp)) + Text(titleOf(nodeId), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + now?.prompt?.let { Text("You: $it", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) } + now?.activity?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + now?.contextPercent?.let { Text("${it.toInt()}% context", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + } + if (archived.isNotEmpty()) { + item(key = "archive-toggle") { + TextButton(onClick = { showArchive = !showArchive }) { + Text(if (showArchive) "Hide archive" else "Archive (${archived.size})") + } + } + if (showArchive) { + items(archived, key = { "r-${it.id}" }) { ev -> + EventCard(ev, titleOf(ev.nodeId), snapshot, highlight = false, onOpen = { open(ev.nodeId) }) {} + } + } + } + } +} + +@Composable +private fun EventCard( + ev: InboxEvent, + title: String, + snapshot: ProjectsSnapshot, + highlight: Boolean, + onOpen: () -> Unit, + actions: @Composable () -> Unit +) { + val accent = when (ev.kind) { + InboxKind.APPROVAL -> NtColors.attention + InboxKind.QUESTION -> NtColors.warning + InboxKind.DONE -> NtColors.success + } + Column( + Modifier + .fillMaxWidth() + .background(if (highlight) accent.copy(alpha = 0.10f) else NtColors.panel, RoundedCornerShape(10.dp)) + .clickable(onClick = onOpen) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + when (ev.kind) { + InboxKind.APPROVAL -> "NEEDS APPROVAL" + InboxKind.QUESTION -> "QUESTION" + InboxKind.DONE -> if (ev.interrupted) "INTERRUPTED" else "FINISHED" + }, + color = accent, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.width(8.dp)) + Text(title, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text(relativeAge(ev.ts), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(ev.title, fontWeight = FontWeight.SemiBold) + ev.detail?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 4) } + Agent.of(ev.agentId ?: snapshot.statusOf(ev.nodeId)?.agentId)?.let { + Text(it.label, style = MaterialTheme.typography.labelSmall, color = parseHex(it.color)) + } + actions() + } +} + +/** + * Usages (docs/mobile-usage-inbox.md): per account, each limit's consumption and reset. Colour is + * the provider's severity when given, else derived (≥90 red, ≥70 amber, else green). + */ +@Composable +fun UsageTab(snapshot: ProjectsSnapshot) { + val usage = snapshot.status?.usage + if (usage == null || usage.accounts.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "This computer isn't reporting usage. (Usage is shared by nodeterm on the computer for its local accounts.)", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(usage.accounts, key = { it.accountId ?: "system" }) { account -> UsageCard(account) } + item { Text("Updated ${relativeAge(usage.updatedAt)} ago", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } +} + +@Composable +private fun UsageCard(account: UsageAccount) { + Column( + Modifier.fillMaxWidth().background(NtColors.panel, RoundedCornerShape(10.dp)).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(account.label ?: account.email ?: if (account.accountId == null) "System account" else account.accountId!!, fontWeight = FontWeight.SemiBold) + if (account.email != null && account.label != null) Text(account.email!!, style = MaterialTheme.typography.bodySmall) + if (account.status != "ok" && account.limits.isEmpty()) { + Text(if (account.status == "error") "Could not read usage." else "Usage unavailable.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + account.limits.forEach { UsageBar(it) } + } +} + +@Composable +private fun UsageBar(limit: UsageLimit) { + val pct = limit.usedPercent.coerceIn(0.0, 100.0) + val color: Color = when (limit.severity) { + "critical", "error", "red" -> NtColors.attention + "warning", "amber", "yellow" -> NtColors.warning + "ok", "normal", "green" -> NtColors.success + else -> if (pct >= 90) NtColors.attention else if (pct >= 70) NtColors.warning else NtColors.success + } + val name = when (limit.kind) { + "session" -> "Session window" + "weekly_all" -> "Weekly (all models)" + "weekly_scoped" -> "Weekly · ${limit.scopeLabel ?: "model"}" + else -> limit.scopeLabel ?: limit.kind + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row { + Text(name, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Text("${pct.toInt()}%" + (limit.resetsAt?.let { " · resets ${resetLabel(it)}" } ?: ""), style = MaterialTheme.typography.bodySmall) + } + LinearProgressIndicator( + progress = { (pct / 100.0).toFloat() }, + color = color, + trackColor = NtColors.panel2, + modifier = Modifier.fillMaxWidth().height(6.dp) + ) + } +} + +/** `<24h → "16:40"` clock time, else `"3d 16h"` — the iOS usage tab's reset label. */ +private fun resetLabel(resetsAt: Long, now: Long = System.currentTimeMillis()): String { + val left = resetsAt - now + if (left <= 0) return "now" + return if (left < 86_400_000L) SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(resetsAt)) + else "${left / 86_400_000L}d ${(left % 86_400_000L) / 3_600_000L}h" +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt new file mode 100644 index 0000000000..f8e522f5bb --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt @@ -0,0 +1,177 @@ +package dev.nodeterm.android.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.Route +import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingPayload +import kotlinx.coroutines.launch + +/** + * The phone side of Settings → Phone → "Pair a phone" on the desktop. Scanning the QR (or pasting + * its text) gives a one-time token; the phone answers with its Ed25519 public key and its own id, + * sealed to the host key the QR carries, and gets back a device id plus — when remote access is on + * — the relay leg. Nothing secret leaves the phone. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PairScreen(nav: Navigator, initialCode: String? = null) { + val graph = NodetermApp.graph(LocalContext.current) + val scope = rememberCoroutineScope() + var raw by remember { mutableStateOf("") } + var payload by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + fun accept(text: String) { + val p = PairingPayload.parse(text) + if (p == null) { + error = "That isn't a nodeterm pairing code. On your computer open nodeterm → Settings → Phone → Pair a phone." + } else { + error = null + payload = p + } + } + + LaunchedEffect(initialCode) { initialCode?.let { accept(it) } } + + val scanner = rememberLauncherForActivityResult(ScanContract()) { result -> + result.contents?.let { accept(it) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Pair a computer") }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).padding(20.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + val p = payload + if (p == null) { + Text("1. On your computer, open nodeterm → Settings → Phone and choose Pair a phone.") + Text("2. Scan the code it shows. Your phone must be on the same network as the computer for this one step.") + Button(onClick = { + scanner.launch( + ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setPrompt("Scan the pairing code on your computer") + .setBeepEnabled(false) + .setOrientationLocked(false) + ) + }, modifier = Modifier.fillMaxWidth()) { Text("Scan the pairing code") } + Text("Or paste the code's text:", color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField( + value = raw, + onValueChange = { raw = it }, + modifier = Modifier.fillMaxWidth().height(120.dp), + placeholder = { Text("{\"v\":1,\"host\":…}") } + ) + OutlinedButton(onClick = { accept(raw) }, enabled = raw.isNotBlank()) { Text("Use this code") } + } else { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(p.name, style = MaterialTheme.typography.titleLarge) + Text("${p.user}@${p.host}", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(if (p.hostKey != null) "✓ The pairing exchange is end-to-end encrypted" else "⚠ This computer's nodeterm is too old to encrypt pairing") + Text(if (p.sshAvailable) "✓ Direct connection on your network (SSH)" else "Relay-only computer (no SSH)") + Text(if (p.relay != null) "✓ Reachable from anywhere (remote access is on)" else "Remote access is off — this phone will only reach the computer on your network") + } + } + if (!p.sshAvailable && p.relay == null) { + Text( + "This computer can only be reached through remote access, which is off. Turn it on in nodeterm → Settings → Phone and scan the new code.", + color = NtColors.warning + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Button(enabled = !busy, onClick = { + busy = true + error = null + scope.launch { + try { + // The backend's free-tier re-registration wants the phone's PREVIOUS device + // token for this computer (pairing-service.ts `priorDeviceToken`). + val previous = graph.hosts.hosts.value.firstOrNull { it.hostKeyB64 != null && it.hostKeyB64 == p.hostKey } + val prior = previous?.let { graph.secure.getString(SecureStore.relayTokenKey(it.id)) } + val result = PairingClient().pair( + payload = p, + sshPublicKeyLine = graph.sshIdentity.authorizedKeysLine("nodeterm-android"), + deviceName = graph.hosts.deviceName, + deviceId = graph.hosts.deviceId, + priorDeviceToken = prior + ) + val host = PairedHost.from(p, result) + result.relayDeviceToken?.let { graph.secure.putString(SecureStore.relayTokenKey(host.id), it) } + previous?.let { + graph.connections.forget(it.id) + if (it.id != host.id) graph.secure.remove(SecureStore.relayTokenKey(it.id)) + graph.hosts.remove(it.id) + } + graph.hosts.upsert(host) + nav.replaceAll(Route.Hosts) + nav.push(Route.Host(host.id)) + } catch (e: Exception) { + error = e.message ?: "Pairing failed." + } finally { + busy = false + } + } + }) { Text("Pair") } + Spacer(Modifier.size(12.dp)) + OutlinedButton(onClick = { payload = null }, enabled = !busy) { Text("Scan again") } + if (busy) { + Spacer(Modifier.size(12.dp)) + CircularProgressIndicator(Modifier.size(24.dp)) + } + } + } + error?.let { Text(it, color = NtColors.attention) } + } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt new file mode 100644 index 0000000000..0c237c6e55 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -0,0 +1,346 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.Route +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.NodeInfo +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.SessionBucket +import dev.nodeterm.protocol.ssh.SshHostConnection +import kotlinx.coroutines.launch + +/** A phone-started session waiting for its terminal screen to type the launch line and register it. */ +data class LaunchRequest( + val nodeId: String, + val projectId: String, + val title: String, + val agentId: String?, + val accountId: String?, + val command: String? +) + +/** Hand-off from the New-session dialog to the terminal screen that performs the launch. */ +object PendingLaunches { + private val byNode = HashMap() + + @Synchronized + fun put(r: LaunchRequest) { + byNode[r.nodeId] = r + } + + @Synchronized + fun take(nodeId: String): LaunchRequest? = byNode.remove(nodeId) +} + +private val BUCKET_ORDER = listOf(SessionBucket.NEEDS_YOU, SessionBucket.RUNNING, SessionBucket.SLEEPING, SessionBucket.UNKNOWN) + +private fun bucketTitle(b: SessionBucket) = when (b) { + SessionBucket.NEEDS_YOU -> "Waiting for your response" + SessionBucket.RUNNING -> "Running" + SessionBucket.SLEEPING -> "Sleeping" + SessionBucket.UNKNOWN -> "Other sessions" +} + +/** What a session row is called: the agent's own session name, else the node title, else the agent. */ +fun displayTitle(node: NodeInfo, snapshot: ProjectsSnapshot): String = + snapshot.statusOf(node.id)?.name?.takeIf { it.isNotBlank() } + ?: node.title.takeIf { it.isNotBlank() } + ?: Agent.of(node.agentId)?.label + ?: "Terminal" + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SessionsTab(nav: Navigator, hostId: String, session: HostSession, snapshot: ProjectsSnapshot) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var menuFor by remember { mutableStateOf(null) } + var renaming by remember { mutableStateOf(null) } + var ending by remember { mutableStateOf(null) } + + fun act(label: String, block: suspend () -> Unit) { + scope.launch { + try { + block() + session.refreshNow() + } catch (e: Exception) { + Toast.makeText(context, "$label: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + val projects = snapshot.openProjects().filter { it.sessions.isNotEmpty() } + if (projects.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + if (snapshot.fetchedAt == 0L) "Loading sessions…" else "No sessions on this computer yet.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 96.dp)) { + for (project in projects) { + item(key = "p-${project.id}") { ProjectHeader(project) } + val grouped = project.sessions.groupBy { snapshot.statusOf(it.id)?.bucket ?: SessionBucket.UNKNOWN } + for (bucket in BUCKET_ORDER) { + val rows = grouped[bucket].orEmpty().sortedByDescending { snapshot.statusOf(it.id)?.updatedAt ?: 0 } + if (rows.isEmpty()) continue + item(key = "b-${project.id}-$bucket") { + Text( + bucketTitle(bucket).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 10.dp, bottom = 4.dp) + ) + } + items(rows, key = { "n-${project.id}-${it.id}" }) { node -> + Box { + SessionRow( + node, snapshot, + onClick = { nav.push(Route.Terminal(hostId, node.id, displayTitle(node, snapshot))) }, + onLongClick = { menuFor = node } + ) + DropdownMenu(expanded = menuFor?.id == node.id, onDismissRequest = { menuFor = null }) { + val conn = session.connection + DropdownMenuItem(text = { Text("Open") }, onClick = { + menuFor = null + nav.push(Route.Terminal(hostId, node.id, displayTitle(node, snapshot))) + }) + if (conn?.capabilities?.nodeActions == true) { + if (snapshot.statusOf(node.id)?.hibernated == true) { + DropdownMenuItem(text = { Text("Wake") }, onClick = { + menuFor = null + act("Wake") { conn.wake(node.id) } + }) + } + DropdownMenuItem(text = { Text("Refresh view on computer") }, onClick = { + menuFor = null + act("Refresh") { conn.refresh(node.id) } + }) + DropdownMenuItem(text = { Text("Rename…") }, onClick = { + menuFor = null + renaming = node + }) + } + DropdownMenuItem(text = { Text("End session…", color = NtColors.attention) }, onClick = { + menuFor = null + ending = node + }) + } + } + } + } + } + } + + renaming?.let { node -> + var title by remember(node.id) { mutableStateOf(displayTitle(node, snapshot)) } + AlertDialog( + onDismissRequest = { renaming = null }, + title = { Text("Rename session") }, + text = { OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true) }, + confirmButton = { + TextButton(enabled = title.isNotBlank(), onClick = { + renaming = null + val conn = session.connection ?: return@TextButton + act("Rename") { conn.rename(node.id, title.trim()) } + }) { Text("Rename") } + }, + dismissButton = { TextButton(onClick = { renaming = null }) { Text("Cancel") } } + ) + } + + ending?.let { node -> + val conn = session.connection + val viaSsh = conn?.kind == TransportKind.SSH + AlertDialog( + onDismissRequest = { ending = null }, + title = { Text("End ${displayTitle(node, snapshot)}?") }, + text = { + Text( + if (viaSsh) "This stops the session's tmux session on the computer, and everything running in it. " + + "The node stays on the canvas until you remove it there." + else "This permanently ends the session and removes the node from the canvas — like its × on the computer." + ) + }, + confirmButton = { + TextButton(onClick = { + ending = null + val c = conn ?: return@TextButton + act("End session") { + if (c is SshHostConnection) { + c.killSession(node.id) + } else { + val quiet = object : TerminalSink { + override fun onPaint(text: String) {} + override fun onOutput(bytes: ByteArray) {} + override fun onExit(code: Int?) {} + } + c.attach(node.id, 80, 24, quiet).endSession() + } + } + }) { Text("End session", color = NtColors.attention) } + }, + dismissButton = { TextButton(onClick = { ending = null }) { Text("Cancel") } } + ) + } +} + +@Composable +private fun ProjectHeader(project: ProjectInfo) { + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 18.dp), verticalAlignment = Alignment.CenterVertically) { + ColorDot(parseHex(project.color, NtColors.accent), 12) + Spacer(Modifier.width(10.dp)) + Text(project.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + project.sshTarget?.let { + Spacer(Modifier.width(8.dp)) + Text("on $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SessionRow(node: NodeInfo, snapshot: ProjectsSnapshot, onClick: () -> Unit, onLongClick: () -> Unit) { + val status = snapshot.statusOf(node.id) + val now = snapshot.status?.inbox?.nodes?.get(node.id) + val agent = Agent.of(node.agentId ?: status?.agentId) + val live = snapshot.isLive(node.id) + Row( + Modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (node.iconEmoji != null) Text(node.iconEmoji!!) else ColorDot(parseHex(node.color ?: agent?.color, NtColors.muted), 12) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(displayTitle(node, snapshot), maxLines = 1, overflow = TextOverflow.Ellipsis) + val detail = buildList { + add(agent?.label ?: "Terminal") + now?.activity?.let { add(it) } + now?.contextPercent?.let { add("${it.toInt()}% context") } + status?.accountLabel?.let { add(it) } + if (!live) add("not running") + }.joinToString(" · ") + Text( + detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End) { + StatusBadge(status?.bucket) + status?.updatedAt?.let { + Text(relativeAge(it), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** New session: a project on THIS computer, an agent (or a plain shell), and for Claude an account. */ +@Composable +fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate: (LaunchRequest) -> Unit) { + val projects = snapshot.openProjects().filter { it.sshTarget == null } + var projectId by remember { mutableStateOf(projects.firstOrNull()?.id) } + var agent by remember { mutableStateOf(Agent.CLAUDE) } + val accounts = snapshot.status?.settings?.claudeAccounts.orEmpty() + var accountId by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New session") }, + text = { + LazyColumn(verticalArrangement = Arrangement.spacedBy(2.dp)) { + item { Text("Project", style = MaterialTheme.typography.labelLarge) } + items(projects, key = { "proj-${it.id}" }) { p -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = projectId == p.id, onClick = { projectId = p.id }) + Text(p.name) + } + } + item { Text("Run", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) } + items(listOf(null) + Agent.entries, key = { "agent-${it?.id ?: "shell"}" }) { a -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = agent == a, onClick = { agent = a }) + Text(a?.label ?: "Terminal (shell)") + } + } + if (agent == Agent.CLAUDE && accounts.isNotEmpty()) { + item { Text("Claude account", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) } + items(listOf(null) + accounts.map { it.id }, key = { "acct-${it ?: "system"}" }) { id -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = accountId == id, onClick = { accountId = id }) + Text(id ?: "System account") + } + } + } + } + }, + confirmButton = { + TextButton(enabled = projectId != null, onClick = { + val p = projects.first { it.id == projectId } + val a = agent + val acct = if (a == Agent.CLAUDE) accountId else null + val cmd = if (a != null) Launch.launchCommand(a, snapshot.status?.settings, acct, p.cwd) + else p.cwd?.let { if (Regex("^/[^'\\u0000-\\u001f]*$").matches(it)) "cd '$it'" else null } + onCreate( + LaunchRequest( + nodeId = Launch.newNodeId(), + projectId = p.id, + title = a?.label ?: "Terminal", + agentId = a?.id, + accountId = acct, + command = cmd + ) + ) + }) { Text("Start") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt new file mode 100644 index 0000000000..8c56326ac8 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -0,0 +1,157 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.data.RoutePreference +import dev.nodeterm.android.notify.InboxNotifier +import dev.nodeterm.protocol.crypto.B64 +import java.security.MessageDigest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(nav: Navigator) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val hosts by graph.hosts.hosts.collectAsState() + var name by remember { mutableStateOf(graph.hosts.deviceName) } + var apiBase by remember { mutableStateOf(graph.hosts.apiBase) } + var notify by remember { mutableStateOf(graph.hosts.notificationsEnabled) } + var routes by remember { mutableStateOf(hosts.associate { it.id to graph.hosts.route(it.id) }) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = { + graph.hosts.deviceName = name + if (apiBase.startsWith("https://")) graph.hosts.apiBase = apiBase + nav.pop() + }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + } + ) + } + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("This phone", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = name, + onValueChange = { name = it.take(60) }, + label = { Text("Name shown on your computer") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Notifications") + Text( + "Checked about every 15 minutes in the background, and live while a computer is open.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = notify, onCheckedChange = { + notify = it + graph.hosts.notificationsEnabled = it + InboxNotifier.schedule(context, it) + }) + } + + if (hosts.isNotEmpty()) { + HorizontalDivider() + Text("How to reach each computer", style = MaterialTheme.typography.titleMedium) + hosts.forEach { host -> + Text(host.name, style = MaterialTheme.typography.labelLarge) + listOf( + RoutePreference.AUTO to "Automatic (network first, then relay)", + RoutePreference.SSH_ONLY to "Only on my network (SSH)", + RoutePreference.RELAY_ONLY to "Only through the relay" + ).forEach { (route, label) -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = routes[host.id] == route, onClick = { + graph.hosts.setRoute(host.id, route) + graph.connections.session(host.id).disconnect() + routes = routes + (host.id to route) + }) + Text(label) + } + } + } + } + + HorizontalDivider() + Text("Identity", style = MaterialTheme.typography.titleMedium) + Text( + "SSH key: SHA256:" + B64.encode(MessageDigest.getInstance("SHA-256").digest(graph.sshIdentity.publicKeyBlob)).trimEnd('='), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall + ) + Text( + "Relay key: " + graph.boxKeys.publicKeyB64.take(16) + "…", + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall + ) + Text( + "Both keys were made on this phone and never leave it. Your computer keeps only their public halves.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + Text("Advanced", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = apiBase, + onValueChange = { apiBase = it.trim() }, + label = { Text("Relay API (https)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + TextButton(onClick = { apiBase = dev.nodeterm.protocol.relay.RelayApi.DEFAULT_API_BASE }) { Text("Reset to default") } + + HorizontalDivider() + Text("About", style = MaterialTheme.typography.titleMedium) + Text( + "nodeterm for Android · open source components: xterm.js (MIT), ZXing (Apache-2.0), sshj (Apache-2.0), " + + "BouncyCastle (MIT), EdDSA-Java (CC0), OkHttp (Apache-2.0), kotlinx (Apache-2.0), AndroidX (Apache-2.0).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt new file mode 100644 index 0000000000..24c5a1a01b --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -0,0 +1,317 @@ +package dev.nodeterm.android.ui + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dev.nodeterm.android.AppGraph +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.Launch +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream + +sealed interface TermState { + data object Connecting : TermState + data object Attached : TermState + data class Ended(val message: String) : TermState +} + +/** + * One terminal screen's plumbing: the WebView running xterm.js on one side, a [TerminalStream] on + * the other. Output is batched onto the main thread every frame (one `evaluateJavascript` per + * frame, not per packet); input, resize and scroll go the other way from the JS bridge thread. + */ +class TerminalController( + private val graph: AppGraph, + private val session: HostSession, + private val nodeId: String +) { + var state by mutableStateOf(TermState.Connecting) + private set + /** Offered after a COLD attach of an agent node: its resume line (the desktop's cold restore). */ + var resumeOffer by mutableStateOf?>(null) + private set + /** A desktop viewer sized the shared pty differently from this screen. */ + var sizedElsewhere by mutableStateOf?>(null) + private set + var ctrlArmed by mutableStateOf(false) + + private val main = Handler(Looper.getMainLooper()) + private var webView: WebView? = null + private var pageReady = false + private val pendingJs = ArrayList() + private var stream: TerminalStream? = null + private var attachJob: Job? = null + private var cols = 0 + private var rows = 0 + private var disposed = false + + private val outBuf = ByteArrayOutputStream() + private var flushScheduled = false + private val flush = Runnable { + val data = synchronized(outBuf) { + flushScheduled = false + outBuf.toByteArray().also { outBuf.reset() } + } + var off = 0 + while (off < data.size) { + val n = minOf(CHUNK, data.size - off) + js("nt.write('${b64(data.copyOfRange(off, off + n))}')") + off += n + } + } + + private val sink = object : TerminalSink { + override fun onPaint(text: String) { + main.post { js("nt.paint('${b64(text.toByteArray(Charsets.UTF_8))}')") } + } + + override fun onOutput(bytes: ByteArray) { + synchronized(outBuf) { + outBuf.write(bytes) + if (!flushScheduled) { + flushScheduled = true + main.postDelayed(flush, 16) + } + } + } + + override fun onResized(cols: Int, rows: Int) { + main.post { sizedElsewhere = if (cols != this@TerminalController.cols || rows != this@TerminalController.rows) cols to rows else null } + } + + override fun onExit(code: Int?) { + main.post { + stream = null + state = TermState.Ended(if (code == null) "Disconnected." else "The session ended (exit $code).") + } + } + } + + inner class Bridge { + @JavascriptInterface + fun onReady() { + main.post { + pageReady = true + pendingJs.forEach { webView?.evaluateJavascript(it, null) } + pendingJs.clear() + } + } + + @JavascriptInterface + fun onResize(c: Int, r: Int) { + main.post { + if (c <= 0 || r <= 0) return@post + cols = c + rows = r + sizedElsewhere = null + val s = stream + if (s != null) s.resize(c, r) else if (attachJob == null && state == TermState.Connecting) attach() + } + } + + @JavascriptInterface + fun onInput(data: String) { + var out = data + if (ctrlArmed && data.length == 1) { + val ch = data[0].uppercaseChar() + if (ch in '@'..'_') out = (ch.code - 64).toChar().toString() + main.post { ctrlArmed = false } + } + stream?.write(out) + } + + @JavascriptInterface + fun onScroll(up: Boolean, notches: Int) { + val s = stream ?: return + graph.scope.launch { runCatching { s.scroll(up, notches) } } + } + + @JavascriptInterface + fun onCopy(b64: String) { + val text = runCatching { String(Base64.decode(b64, Base64.DEFAULT), Charsets.UTF_8) }.getOrNull() ?: return + main.post { + val ctx = webView?.context ?: return@post + val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("nodeterm", text)) + val lines = text.count { it == '\n' } + 1 + Toast.makeText(ctx, "Copied $lines line${if (lines == 1) "" else "s"}", Toast.LENGTH_SHORT).show() + } + } + } + + @SuppressLint("SetJavaScriptEnabled") + fun createWebView(context: Context): WebView = WebView(context).apply { + setBackgroundColor(Color.BLACK) + settings.javaScriptEnabled = true + settings.allowFileAccess = false // assets stay readable; nothing else on disk is + settings.allowContentAccess = false + settings.setSupportZoom(false) + settings.builtInZoomControls = false + settings.displayZoomControls = false + addJavascriptInterface(Bridge(), "NodetermBridge") + webViewClient = object : WebViewClient() { + // The page never navigates; a link the user taps opens in the browser, outside this bridge. + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val url = request.url + if (url.scheme == "http" || url.scheme == "https") { + runCatching { view.context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url.toString()))) } + } + return true + } + } + loadUrl("file:///android_asset/terminal/index.html") + webView = this + js("nt.setFontSize(${graph.hosts.fontSize})") + } + + private fun js(code: String) { + val wv = webView ?: return + if (!pageReady) { + pendingJs += code + return + } + wv.evaluateJavascript(code, null) + } + + fun attach() { + if (disposed) return + state = TermState.Connecting + resumeOffer = null + attachJob = graph.scope.launch { + try { + val conn = session.ensureConnected() + val c = if (cols > 0) cols else 80 + val r = if (rows > 0) rows else 24 + val s = conn.attach(nodeId, c, r, sink) + if (disposed) { + s.detach() + return@launch + } + main.post { + stream = s + state = TermState.Attached + if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) + } + afterAttach(s) + } catch (e: Exception) { + main.post { state = TermState.Ended(e.message ?: "Couldn't open the terminal.") } + } finally { + main.post { attachJob = null } + } + } + } + + private suspend fun afterAttach(s: TerminalStream) { + val conn = session.connection ?: return + val launch = PendingLaunches.take(nodeId) + if (launch != null) { + // A session this phone just started: type its launch line once the shell has settled + // (a line delivered across the rc-file tty flush comes out mangled), THEN put it on the + // canvas — registering first would let the desktop mount it cold and launch the agent too. + delay(900) + launch.command?.let { s.write(it + "\r") } + if (conn.capabilities.registerNode) { + runCatching { + conn.registerNode(launch.projectId, NewNode(nodeId, launch.title, launch.agentId, launch.accountId)) + } + } + session.refreshNow() + return + } + val snap = session.snapshot.value + val node = snap.findNode(nodeId)?.second + val status = snap.statusOf(nodeId) + if (s.fresh) { + // The computer's tmux session was gone (a reboot): the conversation is on disk, not in the + // pane. Offer the agent's own resume — never type it unasked into a pane we cannot see. + val agent = Agent.of(node?.agentId ?: status?.agentId) + val sid = status?.sessionId ?: node?.agentSessionId + if (agent != null && sid != null) { + Launch.resumeCommand(agent, sid)?.let { cmd -> main.post { resumeOffer = agent.label to cmd } } + } + } + // Reading a finished session on the phone is a READ: tell the computer (unread clears there, + // other phones archive the card), exactly what the SSH read-ack file does. + if (status?.state == AgentState.DONE) { + val ev = snap.status?.inbox?.events?.lastOrNull { it.nodeId == nodeId && it.kind == InboxKind.DONE && !it.resolved } + if (ev != null) { + runCatching { conn.ackRead(nodeId, ev.id) } + graph.hosts.markSeen(listOf(ev.id)) + } + } + } + + fun acceptResume() { + val offer = resumeOffer ?: return + resumeOffer = null + stream?.write(offer.second + "\r") + } + + fun dismissResume() { + resumeOffer = null + } + + fun fitHere() { + val s = stream ?: return + if (cols > 0) s.resize(cols, rows) + sizedElsewhere = null + } + + fun submit(text: String, enter: Boolean) { + js("nt.submit('${b64(text.toByteArray(Charsets.UTF_8))}', $enter)") + } + + fun key(name: String) = js("nt.key('$name')") + + fun raw(data: String) { + stream?.write(data) + } + + fun focusTerminal() = js("nt.focus()") + + fun setFontSize(size: Int) { + graph.hosts.fontSize = size + js("nt.setFontSize(${graph.hosts.fontSize})") + } + + fun dispose() { + disposed = true + val s = stream + stream = null + if (s != null) graph.scope.launch { runCatching { s.detach() } } + main.removeCallbacks(flush) + webView?.let { + it.removeJavascriptInterface("NodetermBridge") + it.destroy() + } + webView = null + } + + companion object { + private const val CHUNK = 192 * 1024 + private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt new file mode 100644 index 0000000000..ec620a6a1c --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -0,0 +1,179 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String) { + val graph = NodetermApp.graph(LocalContext.current) + val session = remember(hostId) { graph.connections.session(hostId) } + val controller = remember(hostId, nodeId) { TerminalController(graph, session, nodeId) } + var draft by remember { mutableStateOf("") } + + DisposableEffect(controller) { + session.startWatching() + onDispose { + controller.dispose() + session.stopWatching() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(title, maxLines = 1) }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + actions = { + TextButton(onClick = { controller.setFontSize(graph.hosts.fontSize - 1) }) { Text("A−") } + TextButton(onClick = { controller.setFontSize(graph.hosts.fontSize + 1) }) { Text("A+") } + } + ) + } + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).imePadding()) { + Box(Modifier.weight(1f).fillMaxWidth().background(NtColors.canvas)) { + AndroidView(factory = { ctx -> controller.createWebView(ctx) }, modifier = Modifier.fillMaxSize()) + when (val st = controller.state) { + TermState.Connecting -> Row( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator(Modifier.padding(2.dp)) + Text("Opening terminal…") + } + is TermState.Ended -> Column( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.attach() }) { Text("Reattach") } + } + TermState.Attached -> Unit + } + controller.resumeOffer?.let { (agent, cmd) -> + Column( + Modifier.align(Alignment.TopCenter).fillMaxWidth().background(NtColors.panel2).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text("This session had ended on the computer (it restarted). Resume the $agent conversation?") + Text(cmd, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { controller.acceptResume() }) { Text("Resume") } + OutlinedButton(onClick = { controller.dismissResume() }) { Text("Not now") } + } + } + } + controller.sizedElsewhere?.let { (c, r) -> + Row( + Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Sized to another screen (${c}×$r)", Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { controller.fitHere() }) { Text("Fit this screen") } + } + } + } + KeyRow(controller) + Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Type a command or a prompt") }, + maxLines = 4, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { + controller.submit(draft, enter = true) + draft = "" + }) + ) + IconButton(onClick = { + controller.submit(draft, enter = true) + draft = "" + }) { Icon(Icons.AutoMirrored.Filled.Send, "Send") } + } + } + } +} + +/** The keys a phone keyboard does not have, one tap each. Arrows honour the pane's cursor mode. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KeyRow(controller: TerminalController) { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + FilterChip(selected = controller.ctrlArmed, onClick = { controller.ctrlArmed = !controller.ctrlArmed }, label = { Text("Ctrl") }) + KeyChip("Esc") { controller.key("esc") } + KeyChip("Tab") { controller.key("tab") } + KeyChip("⇧Tab") { controller.key("stab") } + KeyChip("↑") { controller.key("up") } + KeyChip("↓") { controller.key("down") } + KeyChip("←") { controller.key("left") } + KeyChip("→") { controller.key("right") } + KeyChip("⏎") { controller.key("enter") } + KeyChip("⇧⏎") { controller.key("nl") } + KeyChip("^C") { controller.raw("\u0003") } + KeyChip("^D") { controller.raw("\u0004") } + KeyChip("^R") { controller.raw("\u0012") } + KeyChip("^L") { controller.raw("\u000c") } + KeyChip("Home") { controller.key("home") } + KeyChip("End") { controller.key("end") } + KeyChip("PgUp") { controller.key("pgup") } + KeyChip("PgDn") { controller.key("pgdn") } + KeyChip("⌨") { controller.focusTerminal() } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KeyChip(label: String, onClick: () -> Unit) { + FilterChip(selected = false, onClick = onClick, label = { Text(label, fontFamily = FontFamily.Monospace) }) +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/Theme.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Theme.kt new file mode 100644 index 0000000000..270e86c786 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Theme.kt @@ -0,0 +1,97 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nodeterm.protocol.model.SessionBucket + +/** The desktop's dark palette (styles.css `:root`, Apple system colours). */ +object NtColors { + val accent = Color(0xFF0A84FF) + val working = Color(0xFFD97757) + val attention = Color(0xFFFF453A) + val warning = Color(0xFFFF9F0A) + val success = Color(0xFF32D74B) + val muted = Color(0xFF8E8E93) + val panel = Color(0xFF1C1C1E) + val panel2 = Color(0xFF2C2C2E) + val canvas = Color(0xFF000000) + val text = Color(0xFFE6E6E6) +} + +private val scheme = darkColorScheme( + primary = NtColors.accent, + onPrimary = Color.White, + secondary = NtColors.working, + background = NtColors.canvas, + onBackground = NtColors.text, + surface = NtColors.panel, + onSurface = NtColors.text, + surfaceVariant = NtColors.panel2, + onSurfaceVariant = Color(0xFFB0B0B5), + error = NtColors.attention +) + +@Composable +fun NodetermTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = scheme, content = content) +} + +/** Parse a stored `#rrggbb` node/project colour; anything else falls back. */ +fun parseHex(hex: String?, fallback: Color = NtColors.muted): Color { + val h = hex?.trim()?.removePrefix("#") ?: return fallback + if (h.length != 6) return fallback + val v = h.toLongOrNull(16) ?: return fallback + return Color(0xFF000000 or v) +} + +@Composable +fun ColorDot(color: Color, size: Int = 10) { + Box(Modifier.size(size.dp).clip(CircleShape).background(color)) +} + +/** The RUNNING / NEEDS YOU / SLEEPING badges the canvas header shows. */ +@Composable +fun StatusBadge(bucket: SessionBucket?) { + val (label, color) = when (bucket) { + SessionBucket.RUNNING -> "RUNNING" to NtColors.working + SessionBucket.NEEDS_YOU -> "NEEDS YOU" to NtColors.attention + SessionBucket.SLEEPING -> "SLEEPING" to NtColors.muted + else -> return + } + Text( + label, + color = color, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color.copy(alpha = 0.16f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) +} + +/** Short relative age ("now", "5m", "3h", "2d") for session rows and inbox cards. */ +fun relativeAge(ts: Long, now: Long = System.currentTimeMillis()): String { + if (ts <= 0) return "" + val s = (now - ts) / 1000 + return when { + s < 45 -> "now" + s < 3600 -> "${s / 60}m" + s < 86_400 -> "${s / 3600}h" + else -> "${s / 86_400}d" + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..3c04f1c173 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..f3ac264f8d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..e3531ced22 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0B0B0F + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..2b3a88b122 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + nodeterm + Needs you + An agent needs an approval or an answer + Finished + An agent finished its turn + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..964958b773 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..612e580599 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000000..4d4ee86cdc --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000000..5f4cff3710 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 zcma&OWmKeVvL#I6?i3D%6z=Zs?ofE*?rw#G$eqJB ziT4y8-Y@s9rkH0Tz>ll(^xkcTl)CY?rS&9VNd66Yc)g^6)JcWaY(5$5gt z8gr3SBXUTN;~cBgz&})qX%#!Fxom2Yau_`&8)+6aSN7YY+pS410rRUU*>J}qL0TnJ zRxt*7QeUqTh8j)Q&iavh<}L+$Jqz))<`IfKussVk%%Ah-Ti?Eo0hQH!rK%K=#EAw0 zwq@@~XNUXRnv8$;zv<6rCRJ6fPD^hfrh;0K?n z=p!u^3xOgWZ%f3+?+>H)9+w^$Tn1e;?UpVMJb!!;f)`6f&4|8mr+g)^@x>_rvnL0< zvD0Hu_N>$(Li7|Jgu0mRh&MV+<}`~Wi*+avM01E)Jtg=)-vViQKax!GeDc!xv$^mL z{#OVBA$U{(Zr8~Xm|cP@odkHC*1R8z6hcLY#N@3E-A8XEvpt066+3t9L_6Zg6j@9Q zj$$%~yO-OS6PUVrM2s)(T4#6=JpI_@Uz+!6=GdyVU?`!F=d;8#ZB@(5g7$A0(`eqY z8_i@3w$0*es5mrSjhW*qzrl!_LQWs4?VfLmo1Sd@Ztt53+etwzAT^8ow_*7Jp`Y|l z*UgSEwvxq+FYO!O*aLf-PinZYne7Ib6ny3u>MjQz=((r3NTEeU4=-i0LBq3H-VJH< z^>1RE3_JwrclUn9vb7HcGUaFRA0QHcnE;6)hnkp%lY1UII#WPAv?-;c?YH}LWB8Nl z{sx-@Z;QxWh9fX8SxLZk8;kMFlGD3Jc^QZVL4nO)1I$zQwvwM&_!kW+LMf&lApv#< zur|EyC|U@5OQuph$TC_ZU`{!vJp`13e9alaR0Dbn5ikLFH7>eIz4QbV|C=%7)F=qo z_>M&5N)d)7G(A%c>}UCrW!Ql_6_A{?R7&CL`;!KOb3 z8Z=$YkV-IF;c7zs{3-WDEFJzuakFbd*4LWd<_kBE8~BFcv}js_2OowRNzWCtCQ6&k z{&~Me92$m*@e0ANcWKuz)?YjB*VoSTx??-3Cc0l2U!X^;Bv@m87eKHukAljrD54R+ zE;@_w4NPe1>3`i5Qy*3^E9x#VB6?}v=~qIprrrd5|DFkg;v5ixo0IsBmik8=Y;zv2 z%Bcf%NE$a44bk^`i4VwDLTbX=q@j9;JWT9JncQ!+Y%2&HHk@1~*L8-{ZpY?(-a9J-1~<1ltr9i~D9`P{XTIFWA6IG8c4;6bFw*lzU-{+?b&%OcIoCiw00n>A1ra zFPE$y@>ebbZlf(sN_iWBzQKDV zmmaLX#zK!@ZdvCANfwV}9@2O&w)!5gSgQzHdk2Q`jG6KD7S+1R5&F)j6QTD^=hq&7 zHUW+r^da^%V(h(wonR(j?BOiC!;y=%nJvz?*aW&5E87qq;2z`EI(f zBJNNSMFF9U{sR-af5{IY&AtoGcoG)Iq-S^v{7+t0>7N(KRoPj;+2N5;9o_nxIGjJ@ z7bYQK)bX)vEhy~VL%N6g^NE@D5VtV+Q8U2%{ji_=6+i^G%xeskEhH>Sqr194PJ$fB zu1y^){?9Vkg(FY2h)3ZHrw0Z<@;(gd_dtF#6y_;Iwi{yX$?asr?0N0_B*CifEi7<6 zq`?OdQjCYbhVcg+7MSgIM|pJRu~`g?g3x?Tl+V}#$It`iD1j+!x+!;wS0+2e>#g?Z z*EA^k7W{jO1r^K~cD#5pamp+o@8&yw6;%b|uiT?{Wa=4+9<}aXWUuL#ZwN1a;lQod zW{pxWCYGXdEq9qAmvAB904}?97=re$>!I%wxPV#|f#@A*Y=qa%zHlDv^yWbR03%V0 zprLP+b(#fBqxI%FiF*-n8HtH6$8f(P6!H3V^ysgd8de-N(@|K!A< z^qP}jp(RaM9kQ(^K(U8O84?D)aU(g?1S8iWwe)gqpHCaFlJxb*ilr{KTnu4_@5{K- z)n=CCeCrPHO0WHz)dDtkbZfUfVBd?53}K>C5*-wC4hpDN8cGk3lu-ypq+EYpb_2H; z%vP4@&+c2p;thaTs$dc^1CDGlPG@A;yGR5@$UEqk6p58qpw#7lc<+W(WR;(vr(D>W z#(K$vE#uBkT=*q&uaZwzz=P5mjiee6>!lV?c}QIX%ZdkO1dHg>Fa#xcGT6~}1*2m9 zkc7l3ItD6Ie~o_aFjI$Ri=C!8uF4!Ky7iG9QTrxVbsQroi|r)SAon#*B*{}TB-?=@ z8~jJs;_R2iDd!$+n$%X6FO&PYS{YhDAS+U2o4su9x~1+U3z7YN5o0qUK&|g^klZ6X zj_vrM5SUTnz5`*}Hyts9ADwLu#x_L=nv$Z0`HqN`Zo=V>OQI)fh01n~*a%01%cx%0 z4LTFVjmW+ipVQv5rYcn3;d2o4qunWUY!p+?s~X~(ost@WR@r@EuDOSs8*MT4fiP>! zkfo^!PWJJ1MHgKS2D_hc?Bs?isSDO61>ebl$U*9*QY(b=i&rp3@3GV@z>KzcZOxip z^dzA~44;R~cnhWz7s$$v?_8y-k!DZys}Q?4IkSyR!)C0j$(Gm|t#e3|QAOFaV2}36 z?dPNY;@I=FaCwylc_;~kXlZsk$_eLkNb~TIl8QQ`mmH&$*zwwR8zHU*sId)rxHu*K z;yZWa8UmCwju%aSNLwD5fBl^b0Ux1%q8YR*uG`53Mi<`5uA^Dc6Ync)J3N7;zQ*75)hf%a@{$H+%S?SGT)ks60)?6j$ zspl|4Ad6@%-r1t*$tT(en!gIXTUDcsj?28ZEzz)dH)SV3bZ+pjMaW0oc~rOPZP@g! zb9E+ndeVO_Ib9c_>{)`01^`ZS198 z)(t=+{Azi11$eu%aU7jbwuQrO`vLOixuh~%4z@mKr_Oc;F%Uq01fA)^W&y+g16e?rkLhTxV!EqC%2}sx_1u7IBq|}Be&7WI z4I<;1-9tJsI&pQIhj>FPkQV9{(m!wYYV@i5h?A0#BN2wqlEwNDIq06|^2oYVa7<~h zI_OLan0Do*4R5P=a3H9`s5*>xU}_PSztg`+2mv)|3nIy=5#Z$%+@tZnr> zLcTI!Mxa`PY7%{;KW~!=;*t)R_sl<^b>eNO@w#fEt(tPMg_jpJpW$q_DoUlkY|uo> z0-1{ouA#;t%spf*7VjkK&$QrvwUERKt^Sdo)5@?qAP)>}Y!h4(JQ!7{wIdkA+|)bv z&8hBwoX4v|+fie}iTslaBX^i*TjwO}f{V)8*!dMmRPi%XAWc8<_IqK1jUsApk)+~R zNFTCD-h>M5Y{qTQ&0#j@I@tmXGj%rzhTW5%Bkh&sSc=$Fv;M@1y!zvYG5P2(2|(&W zlcbR1{--rJ&s!rB{G-sX5^PaM@3EqWVz_y9cwLR9xMig&9gq(voeI)W&{d6j1jh&< zARXi&APWE1FQWh7eoZjuP z;vdgX>zep^{{2%hem;e*gDJhK1Hj12nBLIJoL<=0+8SVEBx7!4Ea+hBY;A1gBwvY<)tj~T=H`^?3>zeWWm|LAwo*S4Z%bDVUe z6r)CH1H!(>OH#MXFJ2V(U(qxD{4Px2`8qfFLG+=a;B^~Te_Z!r3RO%Oc#ZAHKQxV5 zRYXxZ9T2A%NVJIu5Pu7!Mj>t%YDO$T@M=RR(~mi%sv(YXVl`yMLD;+WZ{vG9(@P#e zMo}ZiK^7^h6TV%cG+;jhJ0s>h&VERs=tuZz^Tlu~%d{ZHtq6hX$V9h)Bw|jVCMudd zwZ5l7In8NT)qEPGF$VSKg&fb0%R2RnUnqa){)V(X(s0U zkCdVZe6wy{+_WhZh3qLp245Y2RR$@g-!9PjJ&4~0cFSHMUn=>dapv)hy}|y91ZWTV zCh=z*!S3_?`$&-eZ6xIXUq8RGl9oK0BJw*TdU6A`LJqX9eS3X@F)g$jLkBWFscPhR zpCv8#KeAc^y>>Y$k^=r|K(DTC}T$0#jQBOwB#@`P6~*IuW_8JxCG}J4va{ zsZzt}tt+cv7=l&CEuVtjD6G2~_Meh%p4RGuY?hSt?(sreO_F}8r7Kp$qQdvCdZnDQ zxzc*qchE*E2=WK)^oRNa>Ttj`fpvF-JZ5tu5>X1xw)J@1!IqWjq)ESBG?J|ez`-Tc zi5a}GZx|w-h%5lNDE_3ho0hEXMoaofo#Z;$8|2;EDF&*L+e$u}K=u?pb;dv$SXeQM zD-~7P0i_`Wk$#YP$=hw3UVU+=^@Kuy$>6?~gIXx636jh{PHly_a2xNYe1l60`|y!7 z(u%;ILuW0DDJ)2%y`Zc~hOALnj1~txJtcdD#o4BCT68+8gZe`=^te6H_egxY#nZH&P*)hgYaoJ^qtmpeea`35Fw)cy!w@c#v6E29co8&D9CTCl%^GV|X;SpneSXzV~LXyRn-@K0Df z{tK-nDWA!q38M1~`xUIt_(MO^R(yNY#9@es9RQbY@Ia*xHhD&=k^T+ zJi@j2I|WcgW=PuAc>hs`(&CvgjL2a9Rx zCbZyUpi8NWUOi@S%t+Su4|r&UoU|ze9SVe7p@f1GBkrjkkq)T}X%Qo1g!SQ{O{P?m z-OfGyyWta+UCXH+-+(D^%kw#A1-U;?9129at7MeCCzC{DNgO zeSqsV>W^NIfTO~4({c}KUiuoH8A*J!Cb0*sp*w-Bg@YfBIPZFH!M}C=S=S7PLLcIG zs7K77g~W)~^|+mx9onzMm0qh(f~OsDTzVmRtz=aZTllgR zGUn~_5hw_k&rll<4G=G+`^Xlnw;jNYDJz@bE?|r866F2hA9v0-8=JO3g}IHB#b`hy zA42a0>{0L7CcabSD+F7?pGbS1KMvT{@1_@k!_+Ki|5~EMGt7T%u=79F)8xEiL5!EJ zzuxQ`NBliCoJMJdwu|);zRCD<5Sf?Y>U$trQ-;xj6!s5&w=9E7)%pZ+1Nh&8nCCwM zv5>Ket%I?cxr3vVva`YeR?dGxbG@pi{H#8@kFEf0Jq6~K4>kt26*bxv=P&jyE#e$| zDJB_~imk^-z|o!2njF2hL*|7sHCnzluhJjwLQGDmC)Y9 zr9ZN`s)uCd^XDvn)VirMgW~qfn1~SaN^7vcX#K1G`==UGaDVVx$0BQnubhX|{e z^i0}>k-;BP#Szk{cFjO{2x~LjK{^Upqd&<+03_iMLp0$!6_$@TbX>8U-f*-w-ew1?`CtD_0y_Lo|PfKi52p?`5$Jzx0E8`M0 zNIb?#!K$mM4X%`Ry_yhG5k@*+n4||2!~*+&pYLh~{`~o(W|o64^NrjP?-1Lgu?iK^ zTX6u3?#$?R?N!{599vg>G8RGHw)Hx&=|g4599y}mXNpM{EPKKXB&+m?==R3GsIq?G zL5fH={=zawB(sMlDBJ+{dgb)Vx3pu>L=mDV0{r1Qs{0Pn%TpopH{m(By4;{FBvi{I z$}x!Iw~MJOL~&)p93SDIfP3x%ROjg}X{Sme#hiJ&Yk&a;iR}V|n%PriZBY8SX2*;6 z4hdb^&h;Xz%)BDACY5AUsV!($lib4>11UmcgXKWpzRL8r2Srl*9Y(1uBQsY&hO&uv znDNff0tpHlLISam?o(lOp#CmFdH<6HmA0{UwfU#Y{8M+7od8b8|B|7ZYR9f<#+V|ZSaCQvI$~es~g(Pv{2&m_rKSB2QQ zMvT}$?Ll>V+!9Xh5^iy3?UG;dF-zh~RL#++roOCsW^cZ&({6q|?Jt6`?S8=16Y{oH zp50I7r1AC1(#{b`Aq5cw>ypNggHKM9vBx!W$eYIzD!4KbLsZGr2o8>g<@inmS3*>J zx8oG((8f!ei|M@JZB`p7+n<Q}?>h249<`7xJ?u}_n;Gq(&km#1ULN87CeTO~FY zS_Ty}0TgQhV zOh3T7{{x&LSYGQfKR1PDIkP!WnfC1$l+fs@Di+d4O=eVKeF~2fq#1<8hEvpwuqcaH z4A8u~r^gnY3u6}zj*RHjk{AHhrrDqaj?|6GaVJbV%o-nATw}ASFr!f`Oz|u_QPkR# z0mDudY1dZRlk@TyQ?%Eti=$_WNFtLpSx9=S^be{wXINp%MU?a`F66LNU<c;0&ngifmP9i;bj6&hdGMW^Kf8e6ZDXbQD&$QAAMo;OQ)G zW(qlHh;}!ZP)JKEjm$VZjTs@hk&4{?@+NADuYrr!R^cJzU{kGc1yB?;7mIyAWwhbeA_l_lw-iDVi7wcFurf5 z#Uw)A@a9fOf{D}AWE%<`s1L_AwpZ?F!Vac$LYkp<#A!!`XKaDC{A%)~K#5z6>Hv@V zBEqF(D5?@6r3Pwj$^krpPDCjB+UOszqUS;b2n>&iAFcw<*im2(b3|5u6SK!n9Sg4I z0KLcwA6{Mq?p%t>aW0W!PQ>iUeYvNjdKYqII!CE7SsS&Rj)eIw-K4jtI?II+0IdGq z2WT|L3RL?;GtGgt1LWfI4Ka`9dbZXc$TMJ~8#Juv@K^1RJN@yzdLS8$AJ(>g!U9`# zx}qr7JWlU+&m)VG*Se;rGisutS%!6yybi%B`bv|9rjS(xOUIvbNz5qtvC$_JYY+c& za*3*2$RUH8p%pSq>48xR)4qsp!Q7BEiJ*`^>^6INRbC@>+2q9?x(h0bpc>GaNFi$K zPH$6!#(~{8@0QZk=)QnM#I=bDx5vTvjm$f4K}%*s+((H2>tUTf==$wqyoI`oxI7>C z&>5fe)Yg)SmT)eA(|j@JYR1M%KixxC-Eceknf-;N=jJTwKvk#@|J^&5H0c+%KxHUI z6dQbwwVx3p?X<_VRVb2fStH?HH zFR@Mp=qX%#L3XL)+$PXKV|o|#DpHAoqvj6uQKe@M-mnhCSou7Dj4YuO6^*V`m)1lf z;)@e%1!Qg$10w8uEmz{ENb$^%u}B;J7sDd zump}onoD#!l=agcBR)iG!3AF0-63%@`K9G(CzKrm$VJ{v7^O9Ps7Zej|3m= zVXlR&yW6=Y%mD30G@|tf=yC7-#L!16Q=dq&@beWgaIL40k0n% z)QHrp2Jck#evLMM1RGt3WvQ936ZC9vEje0nFMfvmOHVI+&okB_K|l-;|4vW;qk>n~ z+|kk8#`K?x`q>`(f6A${wfw9Cx(^)~tX7<#TpxR#zYG2P+FY~mG{tnEkv~d6oUQA+ z&hNTL=~Y@rF`v-RZlts$nb$3(OL1&@Y11hhL9+zUb6)SP!;CD)^GUtUpCHBE`j1te zAGud@miCVFLk$fjsrcpjsadP__yj9iEZUW{Ll7PPi<$R;m1o!&Xdl~R_v0;oDX2z^!&8}zNGA}iYG|k zmehMd1%?R)u6R#<)B)1oe9TgYH5-CqUT8N7K-A-dm3hbm_W21p%8)H{O)xUlBVb+iUR}-v5dFaCyfSd zC6Bd7=N4A@+Bna=!-l|*_(nWGDpoyU>nH=}IOrLfS+-d40&(Wo*dDB9nQiA2Tse$R z;uq{`X7LLzP)%Y9aHa4YQ%H?htkWd3Owv&UYbr5NUDAH^<l@Z0Cx%`N+B*i!!1u>D8%;Qt1$ zE5O0{-`9gdDxZ!`0m}ywH!;c{oBfL-(BH<&SQ~smbcobU!j49O^f4&IIYh~f+hK*M zZwTp%{ZSAhMFj1qFaOA+3)p^gnXH^=)`NTYgTu!CLpEV2NF=~-`(}7p^Eof=@VUbd z_9U|8qF7Rueg&$qpSSkN%%%DpbV?8E8ivu@ensI0toJ7Eas^jyFReQ1JeY9plb^{m z&eQO)qPLZQ6O;FTr*aJq=$cMN)QlQO@G&%z?BKUs1&I^`lq>=QLODwa`(mFGC`0H< zOlc*|N?B5&!U6BuJvkL?s1&nsi$*5cCv7^j_*l&$-sBmRS85UIrE--7eD8Gr3^+o? zqG-Yl4S&E;>H>k^a0GdUI(|n1`ws@)1%sq2XBdK`mqrNq_b4N{#VpouCXLzNvjoFv zo9wMQ6l0+FT+?%N(ka*;%m~(?338bu32v26!{r)|w8J`EL|t$}TA4q_FJRX5 zCPa{hc_I(7TGE#@rO-(!$1H3N-C0{R$J=yPCXCtGk{4>=*B56JdXU9cQVwB`6~cQZ zf^qK21x_d>X%dT!!)CJQ3mlHA@ z{Prkgfs6=Tz%63$6Zr8CO0Ak3A)Cv#@BVKr&aiKG7RYxY$Yx>Bj#3gJk*~Ps-jc1l z;4nltQwwT4@Z)}Pb!3xM?+EW0qEKA)sqzw~!C6wd^{03-9aGf3Jmt=}w-*!yXupLf z;)>-7uvWN4Unn8b4kfIza-X=x*e4n5pU`HtgpFFd))s$C@#d>aUl3helLom+RYb&g zI7A9GXLRZPl}iQS*d$Azxg-VgcUr*lpLnbPKUV{QI|bsG{8bLG<%CF( zMoS4pRDtLVYOWG^@ox^h8xL~afW_9DcE#^1eEC1SVSb1BfDi^@g?#f6e%v~Aw>@w- zIY0k+2lGWNV|aA*e#`U3=+oBDmGeInfcL)>*!w|*;mWiKNG6wP6AW4-4imN!W)!hE zA02~S1*@Q`fD*+qX@f3!2yJX&6FsEfPditB%TWo3=HA;T3o2IrjS@9SSxv%{{7&4_ zdS#r4OU41~GYMiib#z#O;zohNbhJknrPPZS6sN$%HB=jUnlCO_w5Gw5EeE@KV>soy z2EZ?Y|4RQDDjt5y!WBlZ(8M)|HP<0YyG|D%RqD+K#e7-##o3IZxS^wQ5{Kbzb6h(i z#(wZ|^ei>8`%ta*!2tJzwMv+IFHLF`zTU8E^Mu!R*45_=ccqI};Zbyxw@U%a#2}%f zF>q?SrUa_a4H9l+uW8JHh2Oob>NyUwG=QH~-^ZebU*R@67DcXdz2{HVB4#@edz?B< z5!rQH3O0>A&ylROO%G^fimV*LX7>!%re{_Sm6N>S{+GW1LCnGImHRoF@csnFzn@P0 zM=jld0z%oz;j=>c7mMwzq$B^2mae7NiG}%>(wtmsDXkWk{?BeMpTrIt3Mizq?vRsf zi_WjNp+61uV(%gEU-Vf0;>~vcDhe(dzWdaf#4mH3o^v{0EWhj?E?$5v02sV@xL0l4 zX0_IMFtQ44PfWBbPYN#}qxa%=J%dlR{O!KyZvk^g5s?sTNycWYPJ^FK(nl3k?z-5t z39#hKrdO7V(@!TU)LAPY&ngnZ1MzLEeEiZznn7e-jLCy8LO zu^7_#z*%I-BjS#Pg-;zKWWqX-+Ly$T!4`vTe5ZOV0j?TJVA*2?*=82^GVlZIuH%9s zXiV&(T(QGHHah=s&7e|6y?g+XxZGmK55`wGV>@1U)Th&=JTgJq>4mI&Av2C z)w+kRoj_dA!;SfTfkgMPO>7Dw6&1*Hi1q?54Yng`JO&q->^CX21^PrU^JU#CJ_qhV zSG>afB%>2fx<~g8p=P8Yzxqc}s@>>{g7}F!;lCXvF#RV)^fyYb_)iKVCz1xEq=fJ| z0a7DMCK*FuP=NM*5h;*D`R4y$6cpW-E&-i{v`x=Jbk_xSn@2T3q!3HoAOB`@5Vg6) z{PW|@9o!e;v1jZ2{=Uw6S6o{g82x6g=k!)cFSC*oemHaVjg?VpEmtUuD2_J^A~$4* z3O7HsbA6wxw{TP5Kk)(Vm?gKo+_}11vbo{Tp_5x79P~#F)ahQXT)tSH5;;14?s)On zel1J>1x>+7;g1Iz2FRpnYz;sD0wG9Q!vuzE9yKi3@4a9Nh1!GGN?hA)!mZEnnHh&i zf?#ZEN2sFbf~kV;>K3UNj1&vFhc^sxgj8FCL4v>EOYL?2uuT`0eDH}R zmtUJMxVrV5H{L53hu3#qaWLUa#5zY?f5ozIn|PkMWNP%n zWB5!B0LZB0kLw$k39=!akkE9Q>F4j+q434jB4VmslQ;$ zKiO#FZ`p|dKS716jpcvR{QJkSNfDVhr2%~eHrW;fU45>>snr*S8Vik-5eN5k*c2Mp zyxvX&_cFbB6lODXznHHT|rsURe2!swomtrqc~w5 zymTM8!w`1{04CBprR!_F{5LB+2_SOuZN{b*!J~1ZiPpP-M;);!ce!rOPDLtgR@Ie1 zPreuqm4!H)hYePcW1WZ0Fyaqe%l}F~Orr)~+;mkS&pOhP5Ebb`cnUt!X_QhP4_4p( z8YKQCDKGIy>?WIFm3-}Br2-N`T&FOi?t)$hjphB9wOhBXU#Hb+zm&We_-O)s(wc`2 z8?VsvU;J>Ju7n}uUb3s1yPx_F*|FlAi=Ge=-kN?1;`~6szP%$3B0|8Sqp%ebM)F8v zADFrbeT0cgE>M0DMV@_Ze*GHM>q}wWMzt|GYC%}r{OXRG3Ij&<+nx9;4jE${Fj_r* z`{z1AW_6Myd)i6e0E-h&m{{CvzH=Xg!&(bLYgRMO_YVd8JU7W+7MuGWNE=4@OvP9+ zxi^vqS@5%+#gf*Z@RVyU9N1sO-(rY$24LGsg1>w>s6ST^@)|D9>cT50maXLUD{Fzf zt~tp{OSTEKg3ZSQyQQ5r51){%=?xlZ54*t1;Ow)zLe3i?8tD8YyY^k%M)e`V*r+vL zPqUf&m)U+zxps+NprxMHF{QSxv}>lE{JZETNk1&F+R~bp{_T$dbXL2UGnB|hgh*p4h$clt#6;NO~>zuyY@C-MD@)JCc5XrYOt`wW7! z_ti2hhZBMJNbn0O-uTxl_b6Hm313^fG@e;RrhIUK9@# z+DHGv_Ow$%S8D%RB}`doJjJy*aOa5mGHVHz0e0>>O_%+^56?IkA5eN+L1BVCp4~m=1eeL zb;#G!#^5G%6Mw}r1KnaKsLvJB%HZL)!3OxT{k$Yo-XrJ?|7{s4!H+S2o?N|^Z z)+?IE9H7h~Vxn5hTis^3wHYuOU84+bWd)cUKuHapq=&}WV#OxHpLab`NpwHm8LmOo zjri+!k;7j_?FP##CpM+pOVx*0wExEex z@`#)K<-ZrGyArK;a%Km`^+We|eT+#MygHOT6lXBmz`8|lyZOwL1+b+?Z$0OhMEp3R z&J=iRERpv~TC=p2-BYLC*?4 zxvPs9V@g=JT0>zky5Poj=fW_M!c)Xxz1<=&_ZcL=LMZJqlnO1P^xwGGW*Z+yTBvbV z-IFe6;(k1@$1;tS>{%pXZ_7w+i?N4A2=TXnGf=YhePg8bH8M|Lk-->+w8Y+FjZ;L=wSGwxfA`gqSn)f(XNuSm>6Y z@|#e-)I(PQ^G@N`%|_DZSb4_pkaEF0!-nqY+t#pyA>{9^*I-zw4SYA1_z2Bs$XGUZbGA;VeMo%CezHK0lO={L%G)dI-+8w?r9iexdoB{?l zbJ}C?huIhWXBVs7oo{!$lOTlvCLZ_KN1N+XJGuG$rh<^eUQIqcI7^pmqhBSaOKNRq zrx~w^?9C?*&rNwP_SPYmo;J-#!G|{`$JZK7DxsM3N^8iR4vvn>E4MU&Oe1DKJvLc~ zCT>KLZ1;t@My zRj_2hI^61T&LIz)S!+AQIV23n1>ng+LUvzv;xu!4;wpqb#EZz;F)BLUzT;8UA1x*6vJ zicB!3Mj03s*kGV{g`fpC?V^s(=JG-k1EMHbkdP4P*1^8p_TqO|;!Zr%GuP$8KLxuf z=pv*H;kzd;P|2`JmBt~h6|GxdU~@weK5O=X&5~w$HpfO}@l-T7@vTCxVOwCkoPQv8 z@aV_)I5HQtfs7^X=C03zYmH4m0S!V@JINm6#(JmZRHBD?T!m^DdiZJrhKpBcur2u1 zf9e4%k$$vcFopK5!CC`;ww(CKL~}mlxK_Pv!cOsFgVkNIghA2Au@)t6;Y3*2gK=5d z?|@1a)-(sQ%uFOmJ7v2iG&l&m^u&^6DJM#XzCrF%r>{2XKyxLD2rgWBD;i(!e4InDQBDg==^z;AzT2z~OmV0!?Z z0S9pX$+E;w3WN;v&NYT=+G8hf=6w0E1$0AOr61}eOvE8W1jX%>&Mjo7&!ulawgzLH zbcb+IF(s^3aj12WSi#pzIpijJJzkP?JzRawnxmNDSUR#7!29vHULCE<3Aa#be}ie~d|!V+ z%l~s9Odo$G&fH!t!+`rUT0T9DulF!Yq&BfQWFZV1L9D($r4H(}Gnf6k3^wa7g5|Ws zj7%d`!3(0bb55yhC6@Q{?H|2os{_F%o=;-h{@Yyyn*V7?{s%Grvpe!H^kl6tF4Zf5 z{Jv1~yZ*iIWL_9C*8pBMQArfJJ0d9Df6Kl#wa}7Xa#Ef_5B7=X}DzbQXVPfCwTO@9+@;A^Ti6il_C>g?A-GFwA0#U;t4;wOm-4oS})h z5&on>NAu67O?YCQr%7XIzY%LS4bha9*e*4bU4{lGCUmO2UQ2U)QOqClLo61Kx~3dI zmV3*(P6F_Tr-oP%x!0kTnnT?Ep5j;_IQ^pTRp=e8dmJtI4YgWd0}+b2=ATkOhgpXe z;jmw+FBLE}UIs4!&HflFr4)vMFOJ19W4f2^W(=2)F%TAL)+=F>IE$=e=@j-*bFLSg z)wf|uFQu+!=N-UzSef62u0-C8Zc7 zo6@F)c+nZA{H|+~7i$DCU0pL{0Ye|fKLuV^w!0Y^tT$isu%i1Iw&N|tX3kwFKJN(M zXS`k9js66o$r)x?TWL}Kxl`wUDUpwFx(w4Yk%49;$sgVvT~n8AgfG~HUcDt1TRo^s zdla@6heJB@JV z!vK;BUMznhzGK6PVtj0)GB=zTv6)Q9Yt@l#fv7>wKovLobMV-+(8)NJmyF8R zcB|_K7=FJGGn^X@JdFaat0uhKjp3>k#^&xE_}6NYNG?kgTp>2Iu?ElUjt4~E-?`Du z?mDCS9wbuS%fU?5BU@Ijx>1HG*N?gIP+<~xE4u=>H`8o((cS5M6@_OK%jSjFHirQK zN9@~NXFx*jS{<|bgSpC|SAnA@I)+GB=2W|JJChLI_mx+-J(mSJ!b)uUom6nH0#2^(L@JBlV#t zLl?j54s`Y3vE^c_3^Hl0TGu*tw_n?@HyO@ZrENxA+^!)OvUX28gDSF*xFtQzM$A+O zCG=n#6~r|3zt=8%GuG} z<#VCZ%2?3Q(Ad#Y7GMJ~{U3>E{5e@z6+rgZLX{Cxk^p-7dip^d29;2N1_mm4QkASo z-L`GWWPCq$uCo;X_BmGIpJFBlhl<8~EG{vOD1o|X$aB9KPhWO_cKiU*$HWEgtf=fn zsO%9bp~D2c@?*K9jVN@_vhR03>M_8h!_~%aN!Cnr?s-!;U3SVfmhRwk11A^8Ns`@KeE}+ zN$H}a1U6E;*j5&~Og!xHdfK5M<~xka)x-0N)K_&e7AjMz`toDzasH+^1bZlC!n()crk9kg@$(Y{wdKvbuUd04N^8}t1iOgsKF zGa%%XWx@WoVaNC1!|&{5ZbkopFre-Lu(LCE5HWZBoE#W@er9W<>R=^oYxBvypN#x3 zq#LC8&q)GFP=5^-bpHj?LW=)-g+3_)Ylps!3^YQ{9~O9&K)xgy zMkCWaApU-MI~e^cV{Je75Qr7eF%&_H)BvfyKL=gIA>;OSq(y z052BFz3E(Prg~09>|_Z@!qj}@;8yxnw+#Ej0?Rk<y}4ghbD569B{9hSFr*^ygZ zr6j7P#gtZh6tMk6?4V$*Jgz+#&ug;yOr>=qdI#9U&^am2qoh4Jy}H2%a|#Fs{E(5r z%!ijh;VuGA6)W)cJZx+;9Bp1LMUzN~x_8lQ#D3+sL{be-Jyeo@@dv7XguJ&S5vrH` z>QxOMWn7N-T!D@1(@4>ZlL^y5>m#0!HKovs12GRav4z!>p(1~xok8+_{| z#Ae4{9#NLh#Vj2&JuIn5$d6t@__`o}umFo(n0QxUtd2GKCyE+erwXY?`cm*h&^9*8 zJ+8x6fRZI-e$CRygofIQN^dWysCxgkyr{(_oBwwSRxZora1(%(aC!5BTtj^+YuevI zx?)H#(xlALUp6QJ!=l9N__$cxBZ5p&7;qD3PsXRFVd<({Kh+mShFWJNpy`N@ab7?9 zv5=klvCJ4bx|-pvOO2-+G)6O?$&)ncA#Urze2rlBfp#htudhx-NeRnJ@u%^_bfw4o z4|{b8SkPV3b>Wera1W(+N@p9H>dc6{cnkh-sgr?e%(YkWvK+0YXVwk0=d`)}*47*B z5JGkEdVix!w7-<%r0JF~`ZMMPe;f0EQHuYHxya`puazyph*ZSb1mJAt^k4549BfS; zK7~T&lRb=W{s&t`DJ$B}s-eH1&&-wEOH1KWsKn0a(ZI+G!v&W4A*cl>qAvUv6pbUR z#(f#EKV8~hk&8oayBz4vaswc(?qw1vn`yC zZQDl2PCB-&Uu@g9ZQHhO+v(W0bNig{-k0;;`+wM@#@J)8r?qOYs#&vUna8ILxN7S{ zp1s41KnR8miQJtJtOr|+qk}wrLt+N*z#5o`TmD1)E&QD(Vh&pjZJ_J*0!8dy_ z>^=@v=J)C`x&gjqAYu`}t^S=DFCtc0MkBU2zf|69?xW`Ck~(6zLD)gSE{7n~6w8j_ zoH&~$ED2k5-yRa0!r8fMRy z;QjBYUaUnpd}mf%iVFPR%Dg9!d>g`01m~>2s))`W|5!kc+_&Y>wD@@C9%>-lE`WB0 zOIf%FVD^cj#2hCkFgi-fgzIfOi+ya)MZK@IZhHT5FVEaSbv-oDDs0W)pA0&^nM0TW zmgJmd7b1R7b0a`UwWJYZXp4AJPteYLH>@M|xZFKwm!t3D3&q~av?i)WvAKHE{RqpD{{%OhYkK?47}+}` zrR2(Iv9bhVa;cDzJ%6ntcSbx7v7J@Y4x&+eWSKZ*eR7_=CVIUSB$^lfYe@g+p|LD{ zPSpQmxx@b$%d!05|H}WzBT4_cq?@~dvy<7s&QWtieJ9)hd4)$SZz}#H2UTi$CkFWW|I)v_-NjuH!VypONC=1`A=rm_jfzQ8Fu~1r8i{q-+S_j$ z#u^t&Xnfi5tZtl@^!fUJhx@~Cg0*vXMK}D{>|$#T*+mj(J_@c{jXBF|rm4-8%Z2o! z2z0o(4%8KljCm^>6HDK!{jI7p+RAPcty_~GZ~R_+=+UzZ0qzOwD=;YeZt*?3%UGdr z`c|BPE;yUbnyARUl&XWSNJ<+uRt%!xPF&K;(l$^JcA_CMH6)FZt{>6ah$|(9$2fc~ z=CD00uHM{qv;{Zk9FR0~u|3|Eiqv9?z2#^GqylT5>6JNZwKqKBzzQpKU2_pmtD;CT zi%Ktau!Y2Tldfu&b0UgmF(SSBID)15*r08eoUe#bT_K-G4VecJL2Pa=6D1K6({zj6 za(2Z{r!FY5W^y{qZ}08+h9f>EKd&PN90f}Sc0ejf%kB4+f#T8Q1=Pj=~#pi$U zp#5rMR%W25>k?<$;$x72pkLibu1N|jX4cWjD3q^Pk3js!uK6h7!dlvw24crL|MZs_ zb%Y%?Fyp0bY0HkG^XyS76Ts*|Giw{31LR~+WU5NejqfPr73Rp!xQ1mLgq@mdWncLy z%8}|nzS4P&`^;zAR-&nm5f;D-%yNQPwq4N7&yULM8bkttkD)hVU>h>t47`{8?n2&4 zjEfL}UEagLUYwdx0sB2QXGeRmL?sZ%J!XM`$@ODc2!y|2#7hys=b$LrGbvvjx`Iqi z&RDDm3YBrlKhl`O@%%&rhLWZ*ABFz2nHu7k~3@e4)kO3%$=?GEFUcCF=6-1n!x^vmu+Ai*amgXH+Rknl6U>#9w;A} zn2xanZSDu`4%%x}+~FG{Wbi1jo@wqBc5(5Xl~d0KW(^Iu(U3>WB@-(&vn_PJt9{1`e9Iic@+{VPc`vP776L*viP{wYB2Iff8hB%E3|o zGMOu)tJX!`qJ}ZPzq7>=`*9TmETN7xwU;^AmFZ-ckZjV5B2T09pYliaqGFY|X#E-8 z20b>y?(r-Fn5*WZ-GsK}4WM>@TTqsxvSYWL6>18q8Q`~JO1{vLND2wg@58OaU!EvT z1|o+f1mVXz2EKAbL!Q=QWQKDZpV|jznuJ}@-)1&cdo z^&~b4Mx{*1gurlH;Vhk5g_cM&6LOHS2 zRkLfO#HabR1JD4Vc2t828dCUG#DL}f5QDSBg?o)IYYi@_xVwR2w_ntlpAW0NWk$F1 z$If?*lP&Ka1oWfl!)1c3fl`g*lMW3JOn#)R1+tfwrs`aiFUgz3;XIJ>{QFxLCkK30 zNS-)#DON3yb!7LBHQJ$)4y%TN82DC2-9tOIqzhZ27@WY^<6}vXCWcR5iN{LN8{0u9 zNXayqD=G|e?O^*ms*4P?G%o@J1tN9_76e}E#66mr89%W_&w4n66~R;X_vWD(oArwj z4CpY`)_mH2FvDuxgT+akffhX0b_slJJ*?Jn3O3~moqu2Fs1oL*>7m=oVek2bnprnW zixkaIFU%+3XhNA@@9hyhFwqsH2bM|`P?G>i<-gy>NflhrN{$9?LZ1ynSE_Mj0rADF zhOz4FnK}wpLmQuV zgO4_Oz9GBu_NN>cPLA=`SP^$gxAnj;WjJnBi%Q1zg`*^cG;Q)#3Gv@c^j6L{arv>- zAW%8WrSAVY1sj$=umcAf#ZgC8UGZGoamK}hR7j6}i8#np8ruUlvgQ$j+AQglFsQQq zOjyHf22pxh9+h#n$21&$h?2uq0>C9P?P=Juw0|;oE~c$H{#RGfa>| zj)Iv&uOnaf@foiBJ}_;zyPHcZt1U~nOcNB{)og8Btv+;f@PIT*xz$x!G?u0Di$lo7 zOugtQ$Wx|C($fyJTZE1JvR~i7LP{ zbdIwqYghQAJi9p}V&$=*2Azev$6K@pyblphgpv8^9bN!?V}{BkC!o#bl&AP!3DAjM zmWFsvn2fKWCfjcAQmE+=c3Y7j@#7|{;;0f~PIodmq*;W9Fiak|gil6$w3%b_Pr6K_ zJEG@&!J%DgBZJDCMn^7mk`JV0&l07Bt`1ymM|;a)MOWz*bh2#d{i?SDe9IcHs7 zjCrnyQ*Y5GzIt}>`bD91o#~5H?4_nckAgotN{2%!?wsSl|LVmJht$uhGa+HiH>;av z8c?mcMYM7;mvWr6noUR{)gE!=i7cZUY7e;HXa221KkRoc2UB>s$Y(k%NzTSEr>W(u z<(4mcc)4rB_&bPzX*1?*ra%VF}P1nwiP5cykJ&W{!OTlz&Td0pOkVp+wc z@k=-Hg=()hNg=Q!Ub%`BONH{ z_=ZFgetj@)NvppAK2>8r!KAgi>#%*7;O-o9MOOfQjV-n@BX6;Xw;I`%HBkk20v`qoVd0)}L6_49y1IhR z_OS}+eto}OPVRn*?UHC{eGyFU7JkPz!+gX4P>?h3QOwGS63fv4D1*no^6PveUeE5% zlehjv_3_^j^C({a2&RSoVlOn71D8WwMu9@Nb@=E_>1R*ve3`#TF(NA0?d9IR_tm=P zOP-x;gS*vtyE1Cm zG0L?2nRUFj#aLr-R1fX*$sXhad)~xdA*=hF3zPZhha<2O$Ps+F07w*3#MTe?)T8|A!P!v+a|ot{|^$q(TX`35O{WI0RbU zCj?hgOv=Z)xV?F`@HKI11IKtT^ocP78cqHU!YS@cHI@{fPD?YXL)?sD~9thOAv4JM|K8OlQhPXgnevF=F7GKD2#sZW*d za}ma31wLm81IZxX(W#A9mBvLZr|PoLnP>S4BhpK8{YV_}C|p<)4#yO{#ISbco92^3 zv&kCE(q9Wi;9%7>>PQ!zSkM%qqqLZW7O`VXvcj;WcJ`2~v?ZTYB@$Q&^CTfvy?1r^ z;Cdi+PTtmQwHX_7Kz?r#1>D zS5lWU(Mw_$B&`ZPmqxpIvK<~fbXq?x20k1~9az-Q!uR78mCgRj*eQ>zh3c$W}>^+w^dIr-u{@s30J=)1zF8?Wn|H`GS<=>Om|DjzC{}Jt?{!fSJe*@$H zg>wFnlT)k#T?LslW zu$^7Uy~$SQ21cE?3Ijl+bLfuH^U5P^$@~*UY#|_`uvAIe(+wD2eF}z_y!pvomuVO; zS^9fbdv)pcm-B@CW|Upm<7s|0+$@@<&*>$a{aW+oJ%f+VMO<#wa)7n|JL5egEgoBv zl$BY(NQjE0#*nv=!kMnp&{2Le#30b)Ql2e!VkPLK*+{jv77H7)xG7&=aPHL7LK9ER z5lfHxBI5O{-3S?GU4X6$yVk>lFn;ApnwZybdC-GAvaznGW-lScIls-P?Km2mF>%B2 zkcrXTk+__hj-3f48U%|jX9*|Ps41U_cd>2QW81Lz9}%`mTDIhE)jYI$q$ma7Y-`>% z8=u+Oftgcj%~TU}3nP8&h7k+}$D-CCgS~wtWvM|UU77r^pUw3YCV80Ou*+bH0!mf0 zxzUq4ed6y>oYFz7+l18PGGzhB^pqSt)si=9M>~0(Bx9*5r~W7sa#w+_1TSj3Jn9mW zMuG9BxN=}4645Cpa#SVKjFst;9UUY@O<|wpnZk$kE+to^4!?0@?Cwr3(>!NjYbu?x z1!U-?0_O?k!NdM^-rIQ8p)%?M+2xkhltt*|l=%z2WFJhme7*2xD~@zk#`dQR$6Lmd zb3LOD4fdt$Cq>?1<%&Y^wTWX=eHQ49Xl_lFUA(YQYHGHhd}@!VpYHHm=(1-O=yfK#kKe|2Xc*9}?BDFN zD7FJM-AjVi)T~OG)hpSWqH>vlb41V#^G2B_EvYlWhDB{Z;Q9-0)ja(O+By`31=biA zG&Fs#5!%_mHi|E4Nm$;vVQ!*>=_F;ZC=1DTPB#CICS5fL2T3XmzyHu?bI;m7D4@#; ztr~;dGYwb?m^VebuULtS4lkC_7>KCS)F@)0OdxZIFZp@FM_pHnJes8YOvwB|++#G( z&dm*OP^cz95Wi15vh`Q+yB>R{8zqEhz5of>Po$9LNE{xS<)lg2*roP*sQ}3r3t<}; zPbDl{lk{pox~2(XY5=qg0z!W-x^PJ`VVtz$git7?)!h>`91&&hESZy1KCJ2nS^yMH z!=Q$eTyRi68rKxdDsdt+%J_&lapa{ds^HV9Ngp^YDvtq&-Xp}60B_w@Ma>_1TTC;^ zpbe!#gH}#fFLkNo#|`jcn?5LeUYto%==XBk6Ik0kc4$6Z+L3x^4=M6OI1=z5u#M%0 z0E`kevJEpJjvvN>+g`?gtnbo$@p4VumliZV3Z%CfXXB&wPS^5C+7of2tyVkMwNWBiTE2 z8CdPu3i{*vR-I(NY5syRR}I1TJOV@DJy-Xmvxn^IInF>Tx2e)eE9jVSz69$6T`M9-&om!T+I znia!ZWJRB28o_srWlAxtz4VVft8)cYloIoVF=pL zugnk@vFLXQ_^7;%hn9x;Vq?lzg7%CQR^c#S)Oc-8d=q_!2ZVH764V z!wDKSgP}BrVV6SfCLZnYe-7f;igDs9t+K*rbMAKsp9L$Kh<6Z;e7;xxced zn=FGY<}CUz31a2G}$Q(`_r~75PzM4l_({Hg&b@d8&jC}B?2<+ed`f#qMEWi z`gm!STV9E4sLaQX+sp5Nu9*;9g12naf5?=P9p@H@f}dxYprH+3ju)uDFt^V{G0APn zS;16Dk{*fm6&BCg#2vo?7cbkkI4R`S9SSEJ=#KBk3rl69SxnCnS#{*$!^T9UUmO#&XXKjHKBqLdt^3yVvu8yn|{ zZ#%1CP)8t-PAz(+_g?xyq;C2<9<5Yy<~C74Iw(y>uUL$+$mp(DRcCWbCKiGCZw@?_ zdomfp+C5xt;j5L@VfhF*xvZdXwA5pcdsG>G<8II-|1dhAgzS&KArcb0BD4ZZ#WfiEY{hkCq5%z9@f|!EwTm;UEjKJsUo696V>h zy##eXYX}GUu%t{Gql8vVZKkNhQeQ4C%n|RmxL4ee5$cgwlU+?V7a?(jI#&3wid+Kz5+x^G!bb#$q>QpR#BZ}Xo5UW^ zD&I`;?(a}Oys7-`I^|AkN?{XLZNa{@27Dv^s4pGowuyhHuXc zuctKG2x0{WCvg_sGN^n9myJ}&FXyGmUQnW7fR$=bj$AHR88-q$D!*8MNB{YvTTEyS zn22f@WMdvg5~o_2wkjItJN@?mDZ9UUlat2zCh(zVE=dGi$rjXF7&}*sxac^%HFD`Y zTM5D3u5x**{bW!68DL1A!s&$2XG@ytB~dX-?BF9U@XZABO`a|LM1X3HWCllgl0+uL z04S*PX$%|^WAq%jkzp~%9HyYIF{Ym?k)j3nMwPZ=hlCg9!G+t>tf0o|J2%t1 ztC+`((dUplgm3`+0JN~}&FRRJ3?l*>Y&TfjS>!ShS`*MwO{WIbAZR#<%M|4c4^dY8 z{Rh;-!qhY=dz5JthbWoovLY~jNaw>%tS4gHVlt5epV8ekXm#==Po$)}mh^u*cE>q7*kvX&gq)(AHoItMYH6^s6f(deNw%}1=7O~bTHSj1rm2|Cq+3M z93djjdomWCTCYu!3Slx2bZVy#CWDozNedIHbqa|otsUl+ut?>a;}OqPfQA05Yim_2 zs@^BjPoFHOYNc6VbNaR5QZfSMh2S*`BGwcHMM(1@w{-4jVqE8Eu0Bi%d!E*^Rj?cR z7qgxkINXZR)K^=fh{pc0DCKtrydVbVILI>@Y0!Jm>x-xM!gu%dehm?cC6ok_msDVA*J#{75%4IZt}X|tIVPReZS#aCvuHkZxc zHVMtUhT(wp09+w9j9eRqz~LtuSNi2rQx_QgQ(}jBt7NqyT&ma61ldD(s9x%@q~PQl zp6N*?=N$BtvjQ_xIT{+vhb1>{pM0Arde0!X-y))A4znDrVx8yrP3B1(7bKPE5jR@5 zwpzwT4cu~_qUG#zYMZ_!2Tkl9zP>M%cy>9Y(@&VoB84#%>amTAH{(hL4cDYt!^{8L z645F>BWO6QaFJ-{C-i|-d%j7#&7)$X7pv#%9J6da#9FB5KyDhkA+~)G0^87!^}AP>XaCSScr;kL;Z%RSPD2CgoJ;gpYT5&6NUK$86$T?jRH=w8nI9Z534O?5fk{kd z`(-t$8W|#$3>xoMfXvV^-A(Q~$8SKDE^!T;J+rQXP71XZ(kCCbP%bAQ1|%$%Ov9_a zyC`QP3uPvFoBqr_+$HenHklqyIr>PU_Fk5$2C+0eYy^~7U&(!B&&P2%7#mBUhM!z> z_B$Ko?{Pf6?)gpYs~N*y%-3!1>o-4;@1Zz9VQHh)j5U1aL-Hyu@1d?X;jtDBNk*vMXPn@ z+u@wxHN*{uHR!*g*4Xo&w;5A+=Pf9w#PeZ^x@UD?iQ&${K2c}UQgLRik-rKM#Y5rdDphdcNTF~cCX&9ViRP}`>L)QA4zNXeG)KXFzSDa6 zd^St;inY6J_i=5mcGTx4_^Ys`M3l%Q==f>{8S1LEHn{y(kbxn5g1ezt4CELqy)~TV6{;VW>O9?5^ ztcoxHRa0jQY7>wwHWcxA-BCwzsP>63Kt&3fy*n#Cha687CQurXaRQnf5wc9o8v7Rw zNwGr2fac;Wr-Ldehn7tF^(-gPJwPt@VR1f;AmKgxN&YPL;j=0^xKM{!wuU|^mh3NE zy35quf}MeL!PU;|{OW_x$TBothLylT-J>_x6p}B_jW1L>k)ps6n%7Rh z96mPkJIM0QFNYUM2H}YF5bs%@Chs6#pEnloQhEl?J-)es!(SoJpEPoMTdgA14-#mC zghayD-DJWtUu`TD8?4mR)w5E`^EHbsz2EjH5aQLYRcF{l7_Q5?CEEvzDo(zjh|BKg z3aJl_n#j&eFHsUw4~lxqnr!6NL*se)6H=A+T1e3xUJGQrd}oSPwSy5+$tt{2t5J5@(lFxl43amsARG74iyNC}uuS zd2$=(r6RdamdGx^eatX@F2D8?U23tDpR+Os?0Gq2&^dF+$9wiWf?=mDWfjo4LfRwL zI#SRV9iSz>XCSgEj!cW&9H-njJopYiYuq|2w<5R2!nZ27DyvU4UDrHpoNQZiGPkp@ z1$h4H46Zn~eqdj$pWrv;*t!rTYTfZ1_bdkZmVVIRC21YeU$iS-*XMNK`#p8Z_DJx| zk3Jssf^XP7v0X?MWFO{rACltn$^~q(M9rMYoVxG$15N;nP)A98k^m3CJx8>6}NrUd@wp-E#$Q0uUDQT5GoiK_R{ z<{`g;8s>UFLpbga#DAf%qbfi`WN1J@6IA~R!YBT}qp%V-j!ybkR{uY0X|x)gmzE0J z&)=eHPjBxJvrZSOmt|)hC+kIMI;qgOnuL3mbNR0g^<%|>9x7>{}>a2qYSZAGPt4it?8 zNcLc!Gy0>$jaU?}ZWxK78hbhzE+etM`67*-*x4DN>1_&{@5t7_c*n(qz>&K{Y?10s zXsw2&nQev#SUSd|D8w7ZD2>E<%g^; zV{yE_O}gq?Q|zL|jdqB^zcx7vo(^})QW?QKacx$yR zhG|XH|8$vDZNIfuxr-sYFR{^csEI*IM#_gd;9*C+SysUFejP0{{z7@P?1+&_o6=7V|EJLQun^XEMS)w(=@eMi5&bbH*a0f;iC~2J74V2DZIlLUHD&>mlug5+v z6xBN~8-ovZylyH&gG#ptYsNlT?-tzOh%V#Y33zlsJ{AIju`CjIgf$@gr8}JugRq^c zAVQ3;&uGaVlVw}SUSWnTkH_6DISN&k2QLMBe9YU=sA+WiX@z)FoSYX`^k@B!j;ZeC zf&**P?HQG6Rk98hZ*ozn6iS-dG}V>jQhb3?4NJB*2F?6N7Nd;EOOo;xR7acylLaLy z9)^lykX39d@8@I~iEVar4jmjjLWhR0d=EB@%I;FZM$rykBNN~jf>#WbH4U{MqhhF6 zU??@fSO~4EbU4MaeQ_UXQcFyO*Rae|VAPLYMJEU`Q_Q_%s2*>$#S^)&7er+&`9L=1 z4q4ao07Z2Vsa%(nP!kJ590YmvrWg+YrgXYs_lv&B5EcoD`%uL79WyYA$0>>qi6ov7 z%`ia~J^_l{p39EY zv>>b}Qs8vxsu&WcXEt8B#FD%L%ZpcVtY!rqVTHe;$p9rbb5O{^rFMB>auLn-^;s+-&P1#h~mf~YLg$8M9 zZ4#87;e-Y6x6QO<{McUzhy(%*6| z)`D~A(TJ$>+0H+mct(jfgL4x%^oC^T#u(bL)`E2tBI#V1kSikAWmOOYrO~#-cc_8! zCe|@1&mN2{*ceeiBldHCdrURk4>V}79_*TVP3aCyV*5n@jiNbOm+~EQ_}1#->_tI@ zqXv+jj2#8xJtW508rzFrYcJxoek@iW6SR@1%a%Bux&;>25%`j3UI`0DaUr7l79`B1 zqqUARhW1^h6=)6?;@v>xrZNM;t}{yY3P@|L}ey@gG( z9r{}WoYN(9TW&dE2dEJIXkyHA4&pU6ki=rx&l2{DLGbVmg4%3Dlfvn!GB>EVaY_%3+Df{fBiqJV>~Xf8A0aqUjgpa} zoF8YXO&^_x*Ej}nw-$-F@(ddB>%RWoPUj?p8U{t0=n>gAI83y<9Ce@Q#3&(soJ{64 z37@Vij1}5fmzAuIUnXX`EYe;!H-yTVTmhAy;y8VZeB#vD{vw9~P#DiFiKQ|kWwGFZ z=jK;JX*A;Jr{#x?n8XUOLS;C%f|zj-7vXtlf_DtP7bpurBeX%Hjwr z4lI-2TdFpzkjgiv!8Vfv`=SP+s=^i3+N~1ELNWUbH|ytVu>EyPN_3(4TM^QE1swRo zoV7Y_g)a>28+hZG0e7g%@2^s>pzR4^fzR-El}ARTmtu!zjZLuX%>#OoU3}|rFjJg} zQ2TmaygxJ#sbHVyiA5KE+yH0LREWr%^C*yR|@gM$nK2P zo}M}PV0v))uJh&33N>#aU376@ZH79u(Yw`EQ2hM3SJs9f99+cO6_pNW$j$L-CtAfe zYfM)ccwD!P%LiBk!eCD?fHCGvgMQ%Q2oT_gmf?OY=A>&PaZQOq4eT=lwbaf}33LCH zFD|)lu{K7$8n9gX#w4~URjZxWm@wlH%oL#G|I~Fb-v^0L0TWu+`B+ZG!yII)w05DU z>GO?n(TN+B=>HdxVDSlIH76pta$_LhbBg;eZ`M7OGcqt||qi zogS72W1IN%=)5JCyOHWoFP7pOFK0L*OAh=i%&VW&4^LF@R;+K)t^S!96?}^+5QBIs zjJNTCh)?)4k^H^g1&jc>gysM`y^8Rm3qsvkr$9AeWwYpa$b22=yAd1t<*{ zaowSEFP+{y?Ob}8&cwfqoy4Pb9IA~VnM3u!trIK$&&0Op#Ql4j>(EW?UNUv#*iH1$ z^j>+W{afcd`{e&`-A{g}{JnIzYib)!T56IT@YEs{4|`sMpW3c8@UCoIJv`XsAw!XC z34|Il$LpW}CIHFC5e*)}00I5{%OL*WZRGzC0?_}-9{#ue?-ug^ zLE|uv-~6xnSs_2_&CN9{9vyc!Xgtn36_g^wI0C4s0s^;8+p?|mm;Odt3`2ZjwtK;l zfd6j)*Fr#53>C6Y8(N5?$H0ma;BCF3HCjUs7rpb2Kf*x3Xcj#O8mvs#&33i+McX zQpBxD8!O{5Y8D&0*QjD=Yhl9%M0)&_vk}bmN_Ud^BPN;H=U^bn&(csl-pkA+GyY0Z zKV7sU_4n;}uR78ouo8O%g*V;79KY?3d>k6%gpcmQsKk&@Vkw9yna_3asGt`0Hmj59 z%0yiF*`jXhByBI9QsD=+>big5{)BGe&+U2gAARGe3ID)xrid~QN_{I>k}@tzL!Md_ z&=7>TWciblF@EMC3t4-WX{?!m!G6$M$1S?NzF*2KHMP3Go4=#ZHkeIv{eEd;s-yD# z_jU^Ba06TZqvV|Yd;Z_sN%$X=!T+&?#p+OQIHS%!LO`Hx0q_Y0MyGYFNoM{W;&@0@ zLM^!X4KhdtsET5G<0+|q0oqVXMW~-7LW9Bg}=E$YtNh1#1D^6Mz(V9?2g~I1( zoz9Cz=8Hw98zVLwC2AQvp@pBeKyidn6Xu0-1SY1((^Hu*-!HxFUPs)yJ+i`^BC>PC zjwd0mygOVK#d2pRC9LxqGc6;Ui>f{YW9Bvb>33bp^NcnZoH~w9(lM5@JiIlfa-6|k ziy31UoMN%fvQfhi8^T+=yrP{QEyb-jK~>$A4SZT-N56NYEbpvO&yUme&pWKs3^94D zH{oXnUTb3T@H+RgzML*lejx`WAyw*?K7B-I(VJx($2!NXYm%3`=F~TbLv3H<{>D?A zJo-FDYdSA-(Y%;4KUP2SpHKAIcv9-ld(UEJE7=TKp|Gryn;72?0LHqAN^fk6%8PCW z{g_-t)G5uCIf0I`*F0ZNl)Z>))MaLMpXgqWgj-y;R+@A+AzDjsTqw2Mo9ULKA3c70 z!7SOkMtZb+MStH>9MnvNV0G;pwSW9HgP+`tg}e{ij0H6Zt5zJ7iw`hEnvye!XbA@!~#%vIkzowCOvq5I5@$3wtc*w2R$7!$*?}vg4;eDyJ_1=ixJuEp3pUS27W?qq(P^8$_lU!mRChT}ctvZz4p!X^ zOSp|JOAi~f?UkwH#9k{0smZ7-#=lK6X3OFEMl7%)WIcHb=#ZN$L=aD`#DZKOG4p4r zwlQ~XDZ`R-RbF&hZZhu3(67kggsM-F4Y_tI^PH8PMJRcs7NS9ogF+?bZB*fcpJ z=LTM4W=N9yepVvTj&Hu~0?*vR1HgtEvf8w%Q;U0^`2@e8{SwgX5d(cQ|1(!|i$km! zvY03MK}j`sff;*-%mN~ST>xU$6Bu?*Hm%l@0dk;j@%>}jsgDcQ)Hn*UfuThz9(ww_ zasV`rSrp_^bp-0sx>i35FzJwA!d6cZ5#5#nr@GcPEjNnFHIrtUYm1^Z$;{d&{hQV9 z6EfFHaIS}46p^5I-D_EcwwzUUuO}mqRh&T7r9sfw`)G^Q%oHxEs~+XoM?8e*{-&!7 z7$m$lg9t9KP9282eke608^Q2E%H-xm|oJ8=*SyEo} z@&;TQ3K)jgspgKHyGiKVMCz>xmC=H5Fy3!=TP)-R3|&1S-B)!6q50wfLHKM@7Bq6E z44CY%G;GY>tC`~yh!qv~YdXw! zSkquvYNs6k1r7>Eza?Vkkxo6XRS$W7EzL&A`o>=$HXgBp{L(i^$}t`NcnAxzbH8Ht z2!;`bhKIh`f1hIFcI5bHI=ueKdzmB9)!z$s-BT4ItyY|NaA_+o=jO%MU5as9 zc2)aLP>N%u>wlaXTK!p)r?+~)L+0eCGb5{8WIk7K52$nufnQ+m8YF+GQc&{^(zh-$ z#wyWV*Zh@d!b(WwXqvfhQX)^aoHTBkc;4ossV3&Ut*k>AI|m+{#kh4B!`3*<)EJVj zwrxK>99v^k4&Y&`Awm>|exo}NvewV%E+@vOc>5>%H#BK9uaE2$vje zWYM5fKuOTtn96B_2~~!xJPIcXF>E_;yO8AwpJ4)V`Hht#wbO3Ung~@c%%=FX4)q+9 z99#>VC2!4l`~0WHs9FI$Nz+abUq# zz`Of97})Su=^rGp2S$)7N3rQCj#0%2YO<R&p>$<#lgXcUj=4H_{oAYiT3 z44*xDn-$wEzRw7#@6aD)EGO$0{!C5Z^7#yl1o;k0PhN=aVUQu~eTQ^Xy{z8Ow6tk83 z4{5xe%(hx)%nD&|e*6sTWH`4W&U!Jae#U4TnICheJmsw{l|CH?UA{a6?2GNgpZLyzU2UlFu1ZVwlALmh_DOs03J^Cjh1im`E3?9&zvNmg(MuMw&0^Lu$(#CJ*q6DjlKsY-RMJ^8yIY|{SQZ*9~CH|u9L z`R78^r=EbbR*_>5?-)I+$6i}G)%mN(`!X72KaV(MNUP7Nv3MS9S|Pe!%N2AeOt5zG zVJ;jI4HZ$W->Ai_4X+`9c(~m=@ek*m`ZQbv3ryI-AD#AH=`x$~WeW~M{Js57(K7(v ze5`};LG|%C_tmd>bkufMWmAo&B+DT9ZV~h(4jg0>^aeAqL`PEUzJJtI8W1M!bQWpv zvN(d}E1@nlYa!L!!A*RN!(Q3F%J?5PvQ0udu?q-T)j3JKV~NL>KRb~w-lWc685uS6 z=S#aR&B8Sc8>cGJ!!--?kwsJTUUm`Jk?7`H z7PrO~xgBrSW2_tTlCq1LH8*!o?pj?qxy8}(=r_;G18POrFh#;buWR0qU24+XUaVZ0 z?(sXcr@-YqvkCmHr{U2oPogHL{r#3r49TeR<{SJX1pcUqyWPrkYz^X8#QW~?F)R5i z>p^!i<;qM8Nf{-fd6!_&V*e_9qP6q(s<--&1Ttj01j0w>bXY7y1W*%Auu&p|XSOH=)V7Bd4fUKh&T1)@cvqhuD-d=?w}O zjI%i(f|thk0Go*!d7D%0^ztBfE*V=(ZIN84f5HU}T9?ulmEYzT5usi=DeuI*d|;M~ zp_=Cx^!4k#=m_qSPBr5EK~E?3J{dWWPH&oCcNepYVqL?nh4D5ynfWip$m*YlZ8r^Z zuFEUL-nW!3qjRCLIWPT0x)FDL7>Yt7@8dA?R2kF@WE>ysMY+)lTsgNM#3VbXVGL}F z1O(>q>2a+_`6r5Xv$NZAnp=Kgnr3)cL(^=8ypEeOf3q8(HGe@7Tt59;yFl||w|mnO zHDxg2G3z8=(6wjj9kbcEY@Z0iOd7Gq5GiPS5% z*sF1J<#daxDV2Z8H>wxOF<;yKzMeTaSOp_|XkS9Sfn6Mpe9UBi1cSTieGG5$O;ZLIIJ60Y>SN4vC?=yE_CWlo(EEE$e4j?z&^FM%kNmRtlbEL^dPPgvs9sbK5fGw*r@ z+!EU@u$T8!nZh?Fdf_qk$VuHk^yVw`h`_#KoS*N%epIIOfQUy_&V}VWDGp3tplMbf z5Se1sJUC$7N0F1-9jdV2mmGK{-}fu|Nv;12jDy0<-kf^AmkDnu6j~TPWOgy1MT68|D z=4=50jVbUKdKaQgD`eWGr3I&^<6uhkjz$YwItY8%Yp9{z4-{6g{73<_b*@XJ4Nm3-3z z?BW3{aY_ccRjb@W1)i5nLg|7BnWS!B`_Uo9CWaE`Ij327QH?i)9A}4Ug4wmxVVa^b z-4+m%-wwOl7cKH7+=x&nrCrbEC)Q$fpg&V83#uEH;C=GNMz`ps@^RxK%T*8%OPnC` z{WO~J%nxYJ`x|N%?&i7?;{_8t^jM&=50HlaOQj8fS}_`moH$c;vI<|cruPFnpT8yU zS%rPOCUSd5Zdb(zwk`hqwTQn)*&n)uYsP*F_(~xEWq}C= zv30kFmZFwJZ@ELVX3?$dXQh|icO7UrL*_5G=I^xXjImz`ZPp>?g#tf(ej~KaIU0algsG!IS09;>?MvqGg#c{i+}qY|{P8W~O%#>|gFd z<1dr$-oxyRGN17yZo1OwLnzwYs0|;IS_nymNB0IlSzPQ%-r`?T=;_XQ^~&#}b|AB} zkNbN5uB?-sUB-T5QLlg%Uk3)uHB;>VIzGe9_J9 zaeISkQm!v(9d(0ML^b9fR^sfHFlH?7Mvddt37OuR{|O0{uv)(&-6<87W4 zyO>s!=cPgP3O&7xxU5DlIPw_o3O>6o6Qb?JWs3qw#p3sBc3g$?Dx zi(6D+DYgV;GrUis-CL%Qe{nvZnwaVXmbhH(|GFh|Q)k=1uvA$I@1DXI7bKlQ@8D6P zS?(*?><>)G49q0wr;NajpxP4W2G)kHl6^=Z>hrNEI4Mwd_$O6$1dXF;Q#hE(-eeW6 zz03GJF%Wl?HO=_ztv5*zRlcU~{+{k%#N59mgm~eK>P!QZ6E?#Cu^2)+K8m@ySvZ*5 z|HDT}BkF@3!l(0%75G=1u2hETXEj!^1Z$!)!lyGXlWD!_vqGE$Z)#cUVBqlORW>0^ zDjyVTxwKHKG|0}j-`;!R-p>}qQfBl(?($7pP<+Y8QE#M8SCDq~k<+>Q^Zf@cT_WdX3~BSe z+|KK|7OL5Hm5(NFP~j>Ct3*$wi0n0!xl=(C61`q&cec@mFlH(sy%+RH<=s)8aAPN`SfJdkAQjdv82G5iRdv8 zh{9wHUZaniSEpslXl^_ODh}mypC?b*9FzLjb~H@3DFSe;D(A-K3t3eOTB(m~I6C;(-lKAvit(70k`%@+O*Ztdz;}|_TS~B?Tpmi=QKC^m_ z2YpEaT3iiz*;T~ap1yiA)a`dKMwu`^UhIUeltNQ1Yjo=q@bI@&3zH?rVUg=IxLy-ni zyxDu%-Fr{H6owTjZU2O5>nDb=q&Jz_TjeSq%!2m40x&U6w~GQ({quPL73IsJS;f`$ zsuhioqCBj(gJ>2hoo)Gou7(WP*pX)f=Y=!=k!&1K?EYY%jJ~X&DnK{^saPQK<1BJ z_A`_{%ZozcB(3w$z^To^6d|XuT@=X~wtW!+{4ID@N{AB~J6AL5vuY>JwvWCNFKsKh zd}@>q@_WV#QZ&UJ0#?X(pXR!oyXOEG3rqzHbCzGLONDb042i$})fM@XF)uSP(DHUc z^&{|$*xe{cs?Gp8=B%RY3L7#$ve$?TWh>MZdxF1zH1v}1z+$Ov#G7?%D)bBCyDe*% zSeKSpETC2V1){II>@UwJi>4uBN+iAx+82E~gb|Cr&8E^i&)A!uv-g?jzH99wU}8+# z$nh>yvb;TwZmS@7LrvuCu_d0-WxFNI&C7%sWuTL%YU!l|I1{|->=dlOeHOCtUO#zkS3ESO8LHV4hTdQL5EdV zuWD33fFPH}HPrW^s$Qn1Xgp&AT6<-He{{4%eIu3rN=iK|9mURdKXfB&Q?qGok%!cs ze53UP{Z!TO-Y@q2;;k2avA3`lm4OoN4@S*k=UA)7H;qZ`d8`XaYFCv?Ba+uGW@r5v z&&{nf(24WSBOhc7!qF^@0cz;XcUynNaj6w2349;s!K{KVqs5yS{ z7VubS`2OzT^5#1~6Tt^RTvt9-J|D2F>y~>2;jeF>g`hx5l%B3H=aLExQihuYngzlnBTYOTHJQMzl>kwqN5JYs)Ej zblA@ntkUS~xi+}y6|(81helS}Q~&VB37qyV|S3Y=><^1wh%msQM?fz z<58MX(=|PSUKCF#)dbhR%D&xgCD?$aR0qen+wpp6 zst}vX18!Be96TD??j1HsHTUx(a&@F?=gT`Q$oJFFyrh^;zgz!(NlAHGn0cJy@us=w zNhC#l5G;H}+>49Nsh12=ZPO2r*2OBQe5kpb&1?*PIBFitK8}FUfb~S-#hKfF0o#&d z#3aPkB$9scYku&kA6{0xHnBV#&Wei5J>5T-XX-gUXEPo+9b7WL=*XESc(3BshL`aj zXp}QIp*40}oWJt*l043e8_5;H5PI5c)U&IEw5dF(4zjX0y_lk9 zAp@!mK>WUqHo)-jop=DoK>&no>kAD=^qIE7qis&_*4~ z6q^EF$D@R~3_xseCG>Ikb6Gfofb$g|75PPyyZN&tiRxqovo_k zO|HA|sgy#B<32gyU9x^&)H$1jvw@qp+1b(eGAb)O%O!&pyX@^nQd^9BQ4{(F8<}|A zhF&)xusQhtoXOOhic=8#Xtt5&slLia3c*a?dIeczyTbC#>FTfiLST57nc3@Y#v_Eg#VUv zT8cKH#f3=1PNj!Oroz_MAR*pow%Y0*6YCYmUy^7`^r|j23Q~^*TW#cU7CHf0eAD_0 zEWEVddxFgQ7=!nEBQ|ibaScslvhuUk^*%b#QUNrEB{3PG@uTxNwW}Bs4$nS9wc(~O zG7Iq>aMsYkcr!9#A;HNsJrwTDYkK8ikdj{M;N$sN6BqJ<8~z>T20{J8Z2rRUuH7~3 z=tgS`AgxbBOMg87UT4Lwge`*Y=01Dvk>)^{Iu+n6fuVX4%}>?3czOGR$0 zpp*wp>bsFFSV`V;r_m+TZns$ZprIi`OUMhe^cLE$2O+pP3nP!YB$ry}2THx2QJs3< za1;>d-AggCarrQ>&Z!d@;mW+!q6eXhb&`GbzUDSxpl8AJ#Cm#tuc)_xh(2NV=5XMs zrf_ozRYO$NkC=pKFX5OH8v1>0i9Z$ec`~Mf+_jQ68spn(CJwclDhEEkH2Qw;${J$clv__nUjn5jA0wCLEnu1j;v!0vB>Ri6m9`;R{JMS%^)4FC zU0Z44+u$I$w=Bj|iu4DT5h~sS`C*zbmX?@-crY}E+hy>}2~C0Nn(EKk@5^qO4@l@! z6O0lr%tzGC`D^)8xU3FnMZVm0kX1sBWhaQyzVoXFWwr%Ny?=2M{5s#5i7fTu3gEkG zc{(Pr$v=;`Y#&`y*J}#M9ux>0?xu!`$9cUKm#Bdd_&S#LPTS?ZPV6zN6>W6JTS~-LfjL{mB=b(KMk3 z2HjBSlJeyUVqDd=Mt!=hpYsvby2GL&3~zm;0{^nZJq+4vb?5HH4wufvr}IX42sHeK zm@x?HN$8TsTavXs)tLDFJtY9b)y~Tl@7z4^I8oUQq4JckH@~CVQ;FoK(+e0XAM>1O z(ei}h?)JQp>)d=6ng-BZF1Z5hsAKW@mXq+hU?r8I(*%`tnIIOXw7V6ZK(T9RFJJe@ zZS!aC+p)Gf2Ujc=a6hx4!A1Th%YH!Lb^xpI!Eu` zmJO{9rw){B1Ql18d%F%da+Tbu1()?o(zT7StYqK6_w`e+fjXq5L^y(0 z09QA6H4oFj59c2wR~{~>jUoDzDdKz}5#onYPJRwa`SUO)Pd4)?(ENBaFVLJr6Kvz= zhTtXqbx09C1z~~iZt;g^9_2nCZ{};-b4dQJbv8HsWHXPVg^@(*!@xycp#R?a|L!+` zY5w))JWV`Gls(=}shH0#r*;~>_+-P5Qc978+QUd>J%`fyn{*TsiG-dWMiJXNgwBaT zJ=wgYFt+1ACW)XwtNx)Q9tA2LPoB&DkL16P)ERWQlY4%Y`-5aM9mZ{eKPUgI!~J3Z zkMd5A_p&v?V-o-6TUa8BndiX?ooviev(DKw=*bBVOW|=zps9=Yl|-R5@yJe*BPzN}a0mUsLn{4LfjB_oxpv(mwq# zSY*%E{iB)sNvWfzg-B!R!|+x(Q|b@>{-~cFvdDHA{F2sFGA5QGiIWy#3?P2JIpPKg6ncI^)dvqe`_|N=8 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000000..db3a6ac207 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt index a582d14634..5ce7c4e770 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt @@ -14,7 +14,7 @@ import kotlinx.serialization.json.longOrNull * hand-editable and hostile (project.json is git-shared), so a wrong-typed field degrades to * "absent" — it never throws and never takes the whole listing down with it. */ -internal object J { +object J { fun parse(text: String): JsonElement? = try { if (text.isBlank()) null else Json.parseToJsonElement(text) } catch (_: Exception) { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt index 8b24d1975e..3d9bafce5e 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt @@ -31,9 +31,35 @@ data class PairingPayload( val sshAvailable: Boolean ) { companion object { - /** Null for anything that is not a nodeterm pairing payload — never throws. */ + /** `@shared/pair-qr` PAIR_URL_PREFIX: the URL envelope a system camera can open. */ + const val PAIR_URL_PREFIX = "nodeterm://pair?code=" + + /** + * The three shapes the desktop can hand over (`@shared/pair-qr`, and what the iOS app's + * `PairingService.decode` accepts): the raw payload JSON, the `nodeterm://pair?code=` + * URL wrapping base64url(JSON), or the bare base64url code. + */ + fun unwrap(text: String): String? { + val t = text.trim() + if (t.isEmpty()) return null + if (t.startsWith("{")) return t + val code = if (t.contains("://")) { + val uri = try { + URI(t) + } catch (_: Exception) { + return null + } + if (uri.scheme != "nodeterm" || uri.host != "pair") return null + if (!uri.path.isNullOrEmpty() && uri.path != "/") return null + uri.rawQuery?.split('&')?.firstOrNull { it.startsWith("code=") }?.removePrefix("code=") ?: return null + } else t + return B64.decodeUrl(code)?.toString(Charsets.UTF_8) + } + + /** Null for anything that is not a nodeterm PHONE pairing payload — never throws. (A desktop-peer + * relay offer shares the URL envelope but not the payload, and is refused here.) */ fun parse(text: String): PairingPayload? { - val o = J.obj(J.parse(text.trim())) ?: return null + val o = J.obj(J.parse(unwrap(text) ?: return null)) ?: return null if (o.l("v") != 1L || o.b("nodeterm") != true) return null val host = o.s("host")?.takeIf { it.isNotBlank() } ?: return null val user = o.s("user")?.takeIf { it.isNotBlank() } ?: return null diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index 6cc97cb58c..eab1a1a068 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -65,13 +65,35 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos @Volatile private var onClosed: ((String?) -> Unit)? = null @Volatile private var userData: String? = null - /** Run a script through `/bin/sh -c` and return stdout (bounded wait). */ + @Volatile private var closedFired = false + + private fun fireClosed(reason: String?) { + if (closedFired) return + closedFired = true + onClosed?.invoke(reason) + } + + /** True while the SSH transport is up. */ + val isConnected: Boolean get() = client.isConnected && client.isAuthenticated + + /** + * Run a script through `/bin/sh -c` and return stdout (bounded wait). A dead transport is + * reported through `onClosed` (so the owner reconnects) and surfaces as a [HostException] — + * never as a raw sshj exception a caller would have to know about. + */ internal fun run(script: String, timeoutSec: Long = 20): Pair { - client.startSession().use { session -> - val cmd = session.exec("/bin/sh -c " + SshScripts.q(script)) - val out = cmd.inputStream.readBytes() - cmd.join(timeoutSec, TimeUnit.SECONDS) - return cmd.exitStatus to String(out, Charsets.UTF_8) + try { + client.startSession().use { session -> + val cmd = session.exec("/bin/sh -c " + SshScripts.q(script)) + val out = cmd.inputStream.readBytes() + cmd.join(timeoutSec, TimeUnit.SECONDS) + return cmd.exitStatus to String(out, Charsets.UTF_8) + } + } catch (e: HostException) { + throw e + } catch (e: Exception) { + if (!isConnected) fireClosed(e.message) + throw HostException("The SSH connection failed: ${e.message ?: e.javaClass.simpleName}") } } @@ -277,6 +299,7 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } override fun close() { + closedFired = true // an intentional close is not a drop runCatching { client.disconnect() } } @@ -333,7 +356,13 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos mismatch?.let { throw it } throw HostException("Couldn't connect over SSH to $user@$host:$port (${e.message ?: e.javaClass.simpleName}).") } - return SshHostConnection(client) + val conn = SshHostConnection(client) + // The transport dying (network change, sleep, the computer going away) is the one event + // nothing else would report: without this the owner keeps a dead connection forever. + client.transport.disconnectListener = net.schmizz.sshj.transport.DisconnectListener { _, message -> + conn.fireClosed(message) + } + return conn } } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt index 95be5bbbc5..e92bb00c4d 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt @@ -64,7 +64,21 @@ class ModelTest { assertEquals(false, p.sshAvailable) assertNull(p.relay, "ws:// to a non-loopback host is refused (pairing.ts R5)") assertNull(PairingPayload.parse("""{"v":2,"host":"x"}""")) - assertNull(PairingPayload.parse("nodeterm://pair?code=abc"), "a desktop-peer offer is not a phone pairing") + assertNull(PairingPayload.parse("nodeterm://pair?code=abc"), "not a payload") + } + + @Test + fun `the URL and bare-code envelopes of pair-qr ts decode to the same payload`() { + val json = """{"v":1,"host":"10.0.0.2","user":"u","token":"t","pairPort":1,"nodeterm":true,"name":"Box é"}""" + val code = dev.nodeterm.protocol.crypto.B64.encodeUrl(json.toByteArray()) + val viaUrl = assertNotNull(PairingPayload.parse("nodeterm://pair?code=$code")) + assertEquals("Box é", viaUrl.name) + assertEquals(viaUrl, PairingPayload.parse(code)) + assertEquals(viaUrl, PairingPayload.parse(json)) + // A desktop-peer relay offer uses the same envelope with a different payload: refused. + val offer = dev.nodeterm.protocol.crypto.B64.encodeUrl("""{"relayEndpoint":"wss://r","pairingToken":"x","hostPublicKeyB64":"y"}""".toByteArray()) + assertNull(PairingPayload.parse("nodeterm://pair?code=$offer")) + assertNull(PairingPayload.parse("https://pair?code=$code")) } @Test diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000000..4c13679278 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,22 @@ +// nodeterm for Android. Two builds: +// - `protocol/` — the pure-Kotlin wire layer (JVM; testable without the Android SDK), included here; +// - `app/` — the Android app (Compose UI) on top of it. +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "nodeterm-android" +includeBuild("protocol") +include(":app") diff --git a/docs/android.md b/docs/android.md new file mode 100644 index 0000000000..35cf336333 --- /dev/null +++ b/docs/android.md @@ -0,0 +1,102 @@ +# Android companion — design notes + +`android/` is the Android counterpart of the iOS companion (nodeterm-ios, a separate private repo). +It speaks the protocol the desktop already serves to phones; the only desktop-side additions were +two relay verbs a relay-only phone was missing (below). This doc records what the app relies on, +where each fact comes from, and what is not done. + +## The two transports + +A phone reaches a paired computer one of two ways, and the app tries them in the iOS order: + +1. **Direct SSH** (the LAN leg). Pairing installs the phone's Ed25519 public key in the computer's + `~/.ssh/authorized_keys` (`src/main/pairing-service.ts`). Everything after that is POSIX `sh` + + tmux on the computer (`protocol/.../ssh/SshScripts.kt`): the desktop's `-L node-terminal` socket, + its generated `/tmux.conf`, `new-session -A` **without `-D`** (the desktop's own client + must stay attached). PATH is *appended* with the Homebrew dirs, exactly like + `remoteTmuxPathPrologue`, and the macOS app's bundled `Contents/Resources/bin/tmux` is the last + resort, as it is for the desktop's `findTmux`. Not available on Windows hosts (the QR says + `"ssh":false`). +2. **The relay** (from anywhere). E2EE through `wss://relay.nodeterm.dev`, to the standing phone + host (`src/main/remote/standing-host.ts`). The phone trades its device token for a single-use + relay token (`POST /v1/relay/join`), runs the handshake, and — the first time only — waits while + the desktop shows the SAS approval dialog (pin-once). + +`Auto` tries SSH with a 4 s budget and falls back to the relay; a changed SSH host key is a hard +stop, never a fallback. Per-computer overrides live in Settings. + +## Protocol mapping + +The standing phone host still speaks the **legacy relay dialect** (`host-service.ts` +`createHostHandlers` + `framing.ts` opcodes), not the rpc.ts tunnel `docs/ios-protocol-migration.md` +describes as the future. The Android client implements what the host actually serves: + +| Phone action | Relay (host-service.ts) | Direct SSH | +|---|---|---| +| List projects/sessions/status | `projects.list` → the `--NT-PROJECTS-SPLIT--` blob | same blob, from `workspace.json` + `tmux ls` + `agent-status.json`; the v3 index is resolved like `WorkspaceStore` (folder refs → `.nodeterm/project.json`, SSH refs → `cache`, data refs → `inline-projects/.json`) | +| Open a terminal | `pty.attach` → `{streamId, fresh}`, Snapshot frames, Output frames | `has-session` (fresh) then a pty exec of `tmux new-session -A` | +| Type / resize | `OP.Input` / `OP.Resize` frames | channel stdin / window-change | +| Scroll | `pty.scroll` (host writes SGR wheel events) | the phone writes the same SGR wheel events | +| Detach / end | `pty.kill` / `pty.destroy` | close channel / `kill-session` | +| Wake, refresh, rename | `node.wake|refresh|rename` | — (needs the desktop app) | +| Board | `projects.ensureBoard|setCardColumn|editCardLabels` | read-only | +| New session | `pty.attach` of a fresh `term-…` id, launch line, then `projects.registerNode` | — | +| Answer a held approval | **`approvals.answer`** (new) | write `~/.nodeterm/pending/.answer` | +| Read-ack | **`inbox.ack`** (new) | write `~/.nodeterm/acks/.seen` | + +`resizedFrames` is deliberately not sent on attach, matching iOS: the phone is a size *ceiling* on +the shared pty. An `OP.Resized` still shows a "sized to another screen · fit this screen" hint. + +### Why the two new relay verbs + +Both already existed for a phone on SSH — as files it writes on the host. A relay-only phone (every +Windows host, and any phone off the LAN) had neither: `fs.*` is jailed to project roots, correctly. +Typing `1` into the pane is **not** a substitute for answering a held hook-reply approval: while the +hook holds the request the prompt is not on screen, so the keystroke lands in the agent's composer. +`approvals.answer` routes to the same `answerPermission` the canvas Approve/Deny button uses, and +`inbox.ack` runs the same `ackDone` + unread-clear the ack-file sweep runs +(`src/main/remote/host-inbox-verbs.test.ts`). An older desktop answers "not served", which the +phone treats as "open the session" — never a guessed keystroke. The iOS app can adopt both verbs +unchanged. + +## What is verified, and how + +`android/protocol` has no Android dependency and is tested on a JVM (`./gradlew -p protocol test`): + +- **Crypto** — byte-for-byte against vectors generated by the desktop's own `tweetnacl` and + `node:crypto` HKDF (`scripts/gen-crypto-vectors.cjs`): key derivation, `box.before`, secretbox + across block boundaries, tamper rejection, session key, SAS, relay host id. +- **Relay** — the Kotlin client against the desktop's real `connectHostSession`/`connectRelay` + through a local broker: handshake, SAS agreement, approval wait, `projects.list`, attach with + snapshot paint (including a >256 KB snapshot whose chunk boundary splits a code point), input, + resize/`OP.Resized`, exit codes, scroll, detach/destroy, node actions, board verbs (`null` = + Ungrouped), `approvals.answer`, `inbox.ack`, registration. +- **Relay security** — scripted-host tests for: no re-key after ready, reflected boxes (own role) + dropped, replayed/reordered sequence numbers dropped, boxes under a foreign key dropped. +- **Pairing** — against the desktop's real `createPairingService` with HOME in a temp dir: the + E2EE-sealed exchange, the key landing in `authorized_keys` under `nodeterm-ios-`, the + relay leg and its `/v1/relay/device` body, a refused wrong token. +- **SSH** — against Apache MINA sshd running every command through `/bin/sh`, with real tmux on a + private `TMUX_TMPDIR`: v3 index resolution, attach with keystrokes both ways, cold-start + detection, literal `send-keys` (a leading `-` is text), answer files, read-acks, host-key pinning. + One tmux fact measured along the way: an exact-match **pane** target is `=name:`, not `=name` + (tmux 3.4 answers "can't find pane"). + +The **app** module is built by CI (`.github/workflows/android.yml`) against the runner's Android +SDK; it has no instrumented tests yet. + +## Known gaps + +- **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's + `/v1/push/*` fan-out is APNs-only. +- **`/v1/relay/join`.** The request/response shape is not in this repo (the backend is separate). + The client sends `{deviceToken, hostId}` and accepts `pairingToken`, `token` or `joinToken` — + unverified against the live backend. +- **Direct SSH is POSIX-only by design** (like iOS): board writes, node actions and new sessions + need the relay. iOS writes `project.json` over SSH for some of these; Android deliberately does + not (the host verbs exist because that write breaks past `MAX_ARG_STRLEN` and cannot reach an SSH + project's file at all). +- **Codex/Gemini/… launch flags.** A phone-started non-Claude agent launches bare (its own default + approval mode): the per-agent approval table needs host facts (codex's vocabulary moved between + releases, #785) the mirror only partly publishes. +- **Instrumented UI tests** and a store listing do not exist yet. diff --git a/src/renderer/components/PhonePairPopover.tsx b/src/renderer/components/PhonePairPopover.tsx index d5613f2fc6..bc5ba682c7 100644 --- a/src/renderer/components/PhonePairPopover.tsx +++ b/src/renderer/components/PhonePairPopover.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { Switch } from '@renderer/ui/Switch' import { useSettings } from '@renderer/state/settings' import { usePhonePairing } from './settings/usePhonePairing' -import { IOS_APP_STORE_URL } from '@renderer/lib/links' +import { ANDROID_APP_URL, IOS_APP_STORE_URL } from '@renderer/lib/links' import { hostOsFromNavigator, sshServerCopy } from '@shared/ssh-server' import { pairingEndedMessage, @@ -117,7 +117,7 @@ export function PhonePairPopover({ ) : ( <> Pairing QR code -
Scan with the nodeterm iOS app · waiting (10 min)
+
Scan from the nodeterm phone app · waiting (10 min)
{!sshKey ? (
{relayOnlyExplanation(windowsKeyFile)}
) : relayPlan === 'dev' ? ( @@ -194,6 +194,12 @@ export function PhonePairPopover({ > Get the nodeterm iOS app ↗ + diff --git a/src/renderer/components/settings/sections/PhoneSection.tsx b/src/renderer/components/settings/sections/PhoneSection.tsx index 9f9d4480fb..ca3e78e305 100644 --- a/src/renderer/components/settings/sections/PhoneSection.tsx +++ b/src/renderer/components/settings/sections/PhoneSection.tsx @@ -7,7 +7,7 @@ import { Button } from '@renderer/ui/Button' import { Switch } from '@renderer/ui/Switch' import { useSettings } from '@renderer/state/settings' import { usePhonePairing } from '../usePhonePairing' -import { IOS_APP_STORE_URL } from '@renderer/lib/links' +import { ANDROID_APP_URL, IOS_APP_STORE_URL } from '@renderer/lib/links' import { hostOsFromNavigator, sshServerCopy } from '@shared/ssh-server' import { pairingEndedMessage, @@ -169,7 +169,7 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele @@ -198,8 +198,9 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele

Pair phone

- Pair the nodeterm iOS app: scan this QR with your phone. Your phone generates its own - key on-device — nothing secret leaves this machine except a single-use pairing token. + Pair the nodeterm phone app (iPhone or Android): scan this QR from inside the app. Your + phone generates its own key on-device — nothing secret leaves this machine except a + single-use pairing token.

Don't have the app yet?{' '} @@ -208,6 +209,13 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele onClick={() => window.nodeTerminal.shell.openExternal(IOS_APP_STORE_URL)} > Get nodeterm for iOS on the App Store + {' '} + ·{' '} +

@@ -277,12 +285,12 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele > {qrForm === 'url' ? 'Show the in-app code instead' - : 'Scan with the iPhone Camera app instead'} + : "Scan with the phone's Camera app instead"} {qrForm === 'url' ? (

- Point the iPhone's own Camera at this and tap the nodeterm banner. - Needs a recent version of the iOS app — if your phone doesn't + Point the phone's own Camera at this and tap the nodeterm link. + Needs a recent version of the nodeterm app — if your phone doesn't recognise it, switch back and scan from inside nodeterm. Same code either way; switching doesn't restart pairing.

diff --git a/src/renderer/lib/links.ts b/src/renderer/lib/links.ts index 363cd76fd2..63dcb4f4fc 100644 --- a/src/renderer/lib/links.ts +++ b/src/renderer/lib/links.ts @@ -1,3 +1,7 @@ /** nodeterm mobile on the App Store (live since 1.0). One home for the link so the welcome * flow, Settings → Phone and the quick-pair popover can never drift apart. */ export const IOS_APP_STORE_URL = 'https://apps.apple.com/app/nodeterm/id6790581233' + +/** nodeterm for Android. Not in a store yet: the app lives in the repo's `android/` folder (build + * instructions in its README), so the link goes there rather than to a listing that does not exist. */ +export const ANDROID_APP_URL = 'https://github.com/eneskirca/nodeterm/tree/main/android' From 2dd539fca8d56491d9308db6b43244f908a1f86b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:09:59 +0000 Subject: [PATCH 003/284] docs(android): handover, audit findings, offline type-check The app builds in CI but has never been run on a phone. An audit of the code found release blockers. - docs/android-handover.md: status, what is and isn't verified, sandbox notes, the plan as ordered work packages, the open decisions, a device checklist and a follow-up prompt for the next session. - docs/android-audit-2026-09.md: all 77 findings from the adversarially verified audit (A01-A77), with evidence, proposed fixes and verifier corrections. The worst: direct-SSH terminal I/O runs on the main thread, so typing drops the connection (A01). The SSH prelude looks for the desktop's data under nodeterm, but the desktop uses node-terminal (A02). - android/tools/typecheck: the offline Kotlin type-check used while Google Maven is unreachable (android-all + Compose Multiplatform desktop + stubs). It does not replace the AGP build in CI. - README and design notes now say the app is not ready for users and point at the handover. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KCfnRQxqi75Q2abUqLMDwE --- android/.gitignore | 1 + android/README.md | 7 + android/tools/typecheck/README.md | 27 + android/tools/typecheck/build.gradle.kts | 29 + android/tools/typecheck/settings.gradle.kts | 4 + .../androidx/activity/ComponentActivity.java | 9 + .../result/ActivityResultCallback.java | 2 + .../result/ActivityResultLauncher.java | 2 + .../contract/ActivityResultContract.java | 2 + .../contract/ActivityResultContracts.java | 4 + .../androidx/core/app/NotificationCompat.java | 20 + .../core/app/NotificationManagerCompat.java | 7 + .../androidx/core/content/ContextCompat.java | 3 + .../stubs/java/androidx/work/Constraints.java | 4 + .../work/ExistingPeriodicWorkPolicy.java | 2 + .../java/androidx/work/ListenableWorker.java | 7 + .../stubs/java/androidx/work/NetworkType.java | 2 + .../androidx/work/PeriodicWorkRequest.java | 9 + .../stubs/java/androidx/work/WorkManager.java | 7 + .../java/androidx/work/WorkerParameters.java | 2 + .../barcodescanner/ScanContract.java | 3 + .../barcodescanner/ScanIntentResult.java | 2 + .../barcodescanner/ScanOptions.java | 8 + .../stubs/java/dev/nodeterm/android/R.java | 5 + .../kotlin/androidx/activity/compose/Stubs.kt | 10 + .../compose/ui/platform/AndroidStubs.kt | 4 + .../compose/ui/viewinterop/AndroidView.kt | 6 + .../stubs/kotlin/androidx/work/Stubs.kt | 8 + docs/android-audit-2026-09.md | 2330 +++++++++++++++++ docs/android-handover.md | 272 ++ docs/android.md | 10 +- 31 files changed, 2807 insertions(+), 1 deletion(-) create mode 100644 android/tools/typecheck/README.md create mode 100644 android/tools/typecheck/build.gradle.kts create mode 100644 android/tools/typecheck/settings.gradle.kts create mode 100644 android/tools/typecheck/stubs/java/androidx/activity/ComponentActivity.java create mode 100644 android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultCallback.java create mode 100644 android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultLauncher.java create mode 100644 android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContract.java create mode 100644 android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContracts.java create mode 100644 android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java create mode 100644 android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java create mode 100644 android/tools/typecheck/stubs/java/androidx/core/content/ContextCompat.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/Constraints.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/ExistingPeriodicWorkPolicy.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/NetworkType.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/PeriodicWorkRequest.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/WorkManager.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/WorkerParameters.java create mode 100644 android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanContract.java create mode 100644 android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanIntentResult.java create mode 100644 android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanOptions.java create mode 100644 android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java create mode 100644 android/tools/typecheck/stubs/kotlin/androidx/activity/compose/Stubs.kt create mode 100644 android/tools/typecheck/stubs/kotlin/androidx/compose/ui/platform/AndroidStubs.kt create mode 100644 android/tools/typecheck/stubs/kotlin/androidx/compose/ui/viewinterop/AndroidView.kt create mode 100644 android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt create mode 100644 docs/android-audit-2026-09.md create mode 100644 docs/android-handover.md diff --git a/android/.gitignore b/android/.gitignore index 7c8fb6c297..249fad017b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -10,3 +10,4 @@ captures/ .cxx/ *.keystore !debug.keystore +.kotlin/ diff --git a/android/README.md b/android/README.md index c8a77ee5d3..dd927e1de4 100644 --- a/android/README.md +++ b/android/README.md @@ -6,6 +6,13 @@ from anywhere through the end-to-end encrypted relay. It is the Android counterp and speaks the same protocol to the same desktop; nothing on the computer needs to know which phone it is talking to. +> **Status (2026-09-26): not ready for users.** CI builds the debug APK, but the app has not yet +> been run on a phone, and an audit found release blockers, including that typing in a +> direct-SSH terminal drops the connection. The plan and the full list are in +> [`docs/android-handover.md`](../docs/android-handover.md) and +> [`docs/android-audit-2026-09.md`](../docs/android-audit-2026-09.md). The table below describes what +> the code is written to do, not what has been verified on a device. + ## What it does | | Android | Notes | diff --git a/android/tools/typecheck/README.md b/android/tools/typecheck/README.md new file mode 100644 index 0000000000..25c0cea240 --- /dev/null +++ b/android/tools/typecheck/README.md @@ -0,0 +1,27 @@ +# Offline type-check for the Android app + +This is a stopgap for sandboxes that cannot reach Google Maven (`dl.google.com` / `maven.google.com`), +where the Android Gradle Plugin (AGP) cannot resolve. It compiles `android/app/src/main/kotlin` as +plain Kotlin/JVM against: + +- `org.robolectric:android-all` (the Android framework classes, from Maven Central), +- Compose Multiplatform **desktop** artifacts (the same Compose APIs, from Maven Central), and +- hand-written stubs in `stubs/` for the androidx / zxing classes that exist only on Google Maven + (activity-compose, work, core, the zxing scanner contract, and `R`). + +```bash +cd android/tools/typecheck +gradle compileKotlin # needs Gradle 8.x and a JDK 17+ on PATH; no wrapper here on purpose +``` + +What it catches: Kotlin compile errors, wrong imports, API misuse visible in signatures. + +What it cannot catch: anything aapt2, the manifest merger, R8/D8 or a device would catch — +missing resources, manifest errors, Android-only runtime rules (e.g. `NetworkOnMainThreadException`), +API-level availability, and any difference between Compose Multiplatform desktop and Jetpack Compose +for Android. The real build is `./gradlew :app:assembleDebug` in `android/`, which CI runs +(`.github/workflows/android.yml`). + +When the app starts using a new androidx/Google-Maven-only class, add a stub with the same +package, name and the signatures the app calls. `stubs/java/dev/nodeterm/android/R.java` must list +every `R.*` id the Kotlin references. diff --git a/android/tools/typecheck/build.gradle.kts b/android/tools/typecheck/build.gradle.kts new file mode 100644 index 0000000000..39c4aa927d --- /dev/null +++ b/android/tools/typecheck/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + kotlin("jvm") version "2.2.0" + id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" + java +} +java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } +kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } +sourceSets { + main { + kotlin.srcDirs("../../app/src/main/kotlin", "stubs/kotlin") + java.srcDirs("stubs/java") + } +} +dependencies { + implementation("dev.nodeterm:protocol:0.1.0") + compileOnly("org.robolectric:android-all:15-robolectric-13954326") + implementation("org.jetbrains.compose.runtime:runtime-desktop:1.8.2") + implementation("org.jetbrains.compose.ui:ui-desktop:1.8.2") + implementation("org.jetbrains.compose.foundation:foundation-desktop:1.8.2") + implementation("org.jetbrains.compose.material3:material3-desktop:1.8.2") + implementation("org.jetbrains.compose.material:material-icons-core-desktop:1.7.3") + implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") +} +configurations.all { + exclude(group = "androidx.lifecycle") + exclude(group = "androidx.annotation") + exclude(group = "androidx.collection") + exclude(group = "androidx.arch.core") +} diff --git a/android/tools/typecheck/settings.gradle.kts b/android/tools/typecheck/settings.gradle.kts new file mode 100644 index 0000000000..5f1ab260aa --- /dev/null +++ b/android/tools/typecheck/settings.gradle.kts @@ -0,0 +1,4 @@ +pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { repositories { mavenCentral() } } +rootProject.name = "appcheck" +includeBuild("../../protocol") diff --git a/android/tools/typecheck/stubs/java/androidx/activity/ComponentActivity.java b/android/tools/typecheck/stubs/java/androidx/activity/ComponentActivity.java new file mode 100644 index 0000000000..908a734b20 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/ComponentActivity.java @@ -0,0 +1,9 @@ +package androidx.activity; +import android.content.Intent; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContract; +public class ComponentActivity extends android.app.Activity { + public final ActivityResultLauncher registerForActivityResult(ActivityResultContract contract, ActivityResultCallback callback) { return null; } + @Override protected void onNewIntent(Intent intent) {} +} diff --git a/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultCallback.java b/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultCallback.java new file mode 100644 index 0000000000..847b6b323d --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultCallback.java @@ -0,0 +1,2 @@ +package androidx.activity.result; +public interface ActivityResultCallback { void onActivityResult(O result); } diff --git a/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultLauncher.java b/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultLauncher.java new file mode 100644 index 0000000000..dcdd5a6fa7 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultLauncher.java @@ -0,0 +1,2 @@ +package androidx.activity.result; +public abstract class ActivityResultLauncher { public void launch(I input) {} } diff --git a/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContract.java b/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContract.java new file mode 100644 index 0000000000..7fd0e96385 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContract.java @@ -0,0 +1,2 @@ +package androidx.activity.result.contract; +public abstract class ActivityResultContract {} diff --git a/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContracts.java b/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContracts.java new file mode 100644 index 0000000000..9ccb26aec3 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContracts.java @@ -0,0 +1,4 @@ +package androidx.activity.result.contract; +public final class ActivityResultContracts { + public static final class RequestPermission extends ActivityResultContract {} +} diff --git a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java new file mode 100644 index 0000000000..3ddb3ab798 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java @@ -0,0 +1,20 @@ +package androidx.core.app; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +public class NotificationCompat { + public abstract static class Style {} + public static class BigTextStyle extends Style { public BigTextStyle bigText(CharSequence cs) { return this; } } + public static class Builder { + public Builder(Context context, String channelId) {} + public Builder setSmallIcon(int icon) { return this; } + public Builder setContentTitle(CharSequence title) { return this; } + public Builder setContentText(CharSequence text) { return this; } + public Builder setStyle(Style style) { return this; } + public Builder setSubText(CharSequence text) { return this; } + public Builder setWhen(long when) { return this; } + public Builder setAutoCancel(boolean autoCancel) { return this; } + public Builder setContentIntent(PendingIntent intent) { return this; } + public Notification build() { return null; } + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java new file mode 100644 index 0000000000..e75f0c71f6 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java @@ -0,0 +1,7 @@ +package androidx.core.app; +import android.app.Notification; +import android.content.Context; +public final class NotificationManagerCompat { + public static NotificationManagerCompat from(Context context) { return null; } + public void notify(int id, Notification notification) {} +} diff --git a/android/tools/typecheck/stubs/java/androidx/core/content/ContextCompat.java b/android/tools/typecheck/stubs/java/androidx/core/content/ContextCompat.java new file mode 100644 index 0000000000..4feebd0ab5 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/core/content/ContextCompat.java @@ -0,0 +1,3 @@ +package androidx.core.content; +import android.content.Context; +public class ContextCompat { public static int checkSelfPermission(Context context, String permission) { return 0; } } diff --git a/android/tools/typecheck/stubs/java/androidx/work/Constraints.java b/android/tools/typecheck/stubs/java/androidx/work/Constraints.java new file mode 100644 index 0000000000..2ced3d2be0 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/Constraints.java @@ -0,0 +1,4 @@ +package androidx.work; +public final class Constraints { + public static final class Builder { public Builder setRequiredNetworkType(NetworkType t) { return this; } public Constraints build() { return null; } } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/ExistingPeriodicWorkPolicy.java b/android/tools/typecheck/stubs/java/androidx/work/ExistingPeriodicWorkPolicy.java new file mode 100644 index 0000000000..8a7a61bc46 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/ExistingPeriodicWorkPolicy.java @@ -0,0 +1,2 @@ +package androidx.work; +public enum ExistingPeriodicWorkPolicy { KEEP, UPDATE, CANCEL_AND_REENQUEUE } diff --git a/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java b/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java new file mode 100644 index 0000000000..6aae4f39b3 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java @@ -0,0 +1,7 @@ +package androidx.work; +import android.content.Context; +public abstract class ListenableWorker { + public ListenableWorker(Context appContext, WorkerParameters workerParams) {} + public final Context getApplicationContext() { return null; } + public abstract static class Result { public static Result success() { return null; } public static Result retry() { return null; } } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/NetworkType.java b/android/tools/typecheck/stubs/java/androidx/work/NetworkType.java new file mode 100644 index 0000000000..8703497741 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/NetworkType.java @@ -0,0 +1,2 @@ +package androidx.work; +public enum NetworkType { NOT_REQUIRED, CONNECTED, UNMETERED } diff --git a/android/tools/typecheck/stubs/java/androidx/work/PeriodicWorkRequest.java b/android/tools/typecheck/stubs/java/androidx/work/PeriodicWorkRequest.java new file mode 100644 index 0000000000..23eebe3a9e --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/PeriodicWorkRequest.java @@ -0,0 +1,9 @@ +package androidx.work; +import java.util.concurrent.TimeUnit; +public final class PeriodicWorkRequest { + public static final class Builder { + public Builder(Class workerClass, long repeatInterval, TimeUnit repeatIntervalTimeUnit) {} + public Builder setConstraints(Constraints c) { return this; } + public PeriodicWorkRequest build() { return null; } + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java b/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java new file mode 100644 index 0000000000..5265f8b33f --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java @@ -0,0 +1,7 @@ +package androidx.work; +import android.content.Context; +public abstract class WorkManager { + public static WorkManager getInstance(Context context) { return null; } + public abstract Object cancelUniqueWork(String uniqueWorkName); + public abstract Object enqueueUniquePeriodicWork(String uniqueWorkName, ExistingPeriodicWorkPolicy policy, PeriodicWorkRequest request); +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/WorkerParameters.java b/android/tools/typecheck/stubs/java/androidx/work/WorkerParameters.java new file mode 100644 index 0000000000..6d3d5dbe2b --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/WorkerParameters.java @@ -0,0 +1,2 @@ +package androidx.work; +public final class WorkerParameters {} diff --git a/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanContract.java b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanContract.java new file mode 100644 index 0000000000..3132f4eeec --- /dev/null +++ b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanContract.java @@ -0,0 +1,3 @@ +package com.journeyapps.barcodescanner; +import androidx.activity.result.contract.ActivityResultContract; +public class ScanContract extends ActivityResultContract {} diff --git a/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanIntentResult.java b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanIntentResult.java new file mode 100644 index 0000000000..1819791d78 --- /dev/null +++ b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanIntentResult.java @@ -0,0 +1,2 @@ +package com.journeyapps.barcodescanner; +public final class ScanIntentResult { public String getContents() { return null; } } diff --git a/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanOptions.java b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanOptions.java new file mode 100644 index 0000000000..391184e044 --- /dev/null +++ b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanOptions.java @@ -0,0 +1,8 @@ +package com.journeyapps.barcodescanner; +public class ScanOptions { + public static final String QR_CODE = "QR_CODE"; + public final ScanOptions setDesiredBarcodeFormats(String... formats) { return this; } + public final ScanOptions setPrompt(String prompt) { return this; } + public ScanOptions setBeepEnabled(boolean enabled) { return this; } + public ScanOptions setOrientationLocked(boolean locked) { return this; } +} diff --git a/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java b/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java new file mode 100644 index 0000000000..363df0f8db --- /dev/null +++ b/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java @@ -0,0 +1,5 @@ +package dev.nodeterm.android; +public final class R { + public static final class string { public static final int app_name = 1, channel_attention = 2, channel_attention_desc = 3, channel_done = 4, channel_done_desc = 5; } + public static final class drawable { public static final int ic_launcher_foreground = 6; } +} diff --git a/android/tools/typecheck/stubs/kotlin/androidx/activity/compose/Stubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/activity/compose/Stubs.kt new file mode 100644 index 0000000000..b74af14c86 --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/activity/compose/Stubs.kt @@ -0,0 +1,10 @@ +package androidx.activity.compose +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +fun ComponentActivity.setContent(parent: CompositionContext? = null, content: @Composable () -> Unit) {} +@Composable fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {} +class ManagedActivityResultLauncher : ActivityResultLauncher() +@Composable fun rememberLauncherForActivityResult(contract: ActivityResultContract, onResult: (O) -> Unit): ManagedActivityResultLauncher = ManagedActivityResultLauncher() diff --git a/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/platform/AndroidStubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/platform/AndroidStubs.kt new file mode 100644 index 0000000000..4885d75df7 --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/platform/AndroidStubs.kt @@ -0,0 +1,4 @@ +package androidx.compose.ui.platform +import android.content.Context +import androidx.compose.runtime.staticCompositionLocalOf +val LocalContext = staticCompositionLocalOf { error("no context") } diff --git a/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/viewinterop/AndroidView.kt b/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/viewinterop/AndroidView.kt new file mode 100644 index 0000000000..e55b19e61d --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/viewinterop/AndroidView.kt @@ -0,0 +1,6 @@ +package androidx.compose.ui.viewinterop +import android.content.Context +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +@Composable fun AndroidView(factory: (Context) -> T, modifier: Modifier = Modifier, update: (T) -> Unit = {}) {} diff --git a/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt new file mode 100644 index 0000000000..4a34de3299 --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt @@ -0,0 +1,8 @@ +package androidx.work +import android.content.Context +import java.util.concurrent.TimeUnit +abstract class CoroutineWorker(appContext: Context, params: WorkerParameters) : ListenableWorker(appContext, params) { + abstract suspend fun doWork(): Result +} +inline fun PeriodicWorkRequestBuilder(repeatInterval: Long, repeatIntervalTimeUnit: TimeUnit): PeriodicWorkRequest.Builder = + PeriodicWorkRequest.Builder(W::class.java, repeatInterval, repeatIntervalTimeUnit) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md new file mode 100644 index 0000000000..624dc3eed9 --- /dev/null +++ b/docs/android-audit-2026-09.md @@ -0,0 +1,2330 @@ +# Android companion: audit findings (2026-09-25) + +Generated from the adversarially verified audit run `wf_cfa3d2db-264` (170 agents). Six auditors covered build, protocol, runtime, security, iOS parity and CI/docs; a completeness critic ran afterwards. Refuters checked every finding against the source (two refuters for high/critical findings), then an impact judge re-rated its severity. **All 77 findings survived and none were refuted.** Treat that with some caution: a fix session should re-read the cited code before changing it. Line numbers refer to commit `2f58918`. + +Severity is the impact judge's rating, not the auditor's claim. **BLOCK** means the judge said it blocks a first sideloaded release. Effort is the judge's estimate. + +Prioritised plan and handover: [`android-handover.md`](android-handover.md). + +## Index + +| ID | Sev | Block | Effort | Area | Title | +|---|---|---|---|---|---| +| [A01](#a01) | high | BLOCK | small | build/bug | Direct-SSH terminal writes and resizes run on the Android main thread, which breaks the SSH connection as soon as the keyboard opens | +| [A02](#a02) | high | BLOCK | small | protocol/bug | SSH browse looks for the desktop's userData under 'nodeterm', but the desktop writes it under 'node-terminal', so every direct-SSH listing comes back empty | +| [A03](#a03) | high | BLOCK | small | protocol/bug | The direct-SSH tmux client starts without a UTF-8 locale, so tmux replaces every non-ASCII character with '_' | +| [A04](#a04) | high | BLOCK | small | runtime/bug | SSH terminal writes and resizes run on the main thread, and the NetworkOnMainThreadException that runCatching swallows leaves sshj's cipher state out of sync, which drops the whole SSH connection | +| [A05](#a05) | high | BLOCK | small | runtime/risk | The background InboxWorker can open a never-approved relay handshake and raise the desktop's SAS approval dialog while the phone shows no code | +| [A06](#a06) | high | BLOCK | small | security/bug | approvals.answer reports success for a hold that already timed out, and clears NEEDS YOU on every surface | +| [A07](#a07) | high | BLOCK | medium | critic/bug | Remote access fails the first time you are away from the computer: Auto never does the first relay handshake while the phone is at the desk, and pairing does not pin the phone's relay key | +| [A08](#a08) | high | BLOCK | small | critic/bug | A cold attach over direct SSH creates the desktop's tmux session with no hook environment, so an agent resumed there never reports status, and the desktop never repairs it | +| [A09](#a09) | medium | BLOCK | small | protocol/bug | Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine | +| [A10](#a10) | medium | BLOCK | small | ci-docs/risk | Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | +| [A11](#a11) | medium | | small | build/bug | Tapping an Inbox notification while the app is in the background does not open that computer | +| [A12](#a12) | medium | | medium | protocol/bug | Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | +| [A13](#a13) | medium | | medium | protocol/bug | Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | +| [A14](#a14) | medium | | small | protocol/bug | New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned | +| [A15](#a15) | medium | | small | protocol/bug | The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found' | +| [A16](#a16) | medium | | small | protocol/gap | The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | +| [A17](#a17) | medium | | small | protocol/risk | The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against | +| [A18](#a18) | medium | | small | runtime/bug | No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely | +| [A19](#a19) | medium | | small | runtime/bug | Tapping an inbox notification while the activity is alive ignores the target computer | +| [A20](#a20) | medium | | small | runtime/bug | Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure | +| [A21](#a21) | medium | | small | runtime/bug | A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On | +| [A22](#a22) | medium | | small | runtime/bug | The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied | +| [A23](#a23) | medium | | small | security/risk | Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows | +| [A24](#a24) | medium | | small | security/bug | SecureStore treats ANY decrypt error as 'absent', so getOrCreate32 permanently overwrites the phone's identity | +| [A25](#a25) | medium | | medium | parity/gap | No real push notifications: 15-minute background polling, no notification actions, no Live-Activity equivalent, and the desktop's phone-push switches are ignored | +| [A26](#a26) | medium | | medium | parity/gap | New session and board edits are unavailable on the LAN (direct-SSH) connection that Auto picks first; iOS does both over SSH | +| [A27](#a27) | medium | | large | parity/gap | Cannot connect straight to a Linux dev host or a headless Server Edition (iOS's "phone SSHes into the host" setup) | +| [A28](#a28) | medium | | small | parity/gap | SSH-project sessions over direct SSH are attached, approved and resumed on the wrong machine | +| [A29](#a29) | medium | | medium | parity/gap | No source-control screen, although the protocol layer already implements the git verbs iOS uses | +| [A30](#a30) | medium | | small | critic/bug | Pressing Deny on the desktop is not respected: the phone re-dials about 8 s later and the SAS approval dialog reappears | +| [A31](#a31) | medium | | small | critic/bug | A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown | +| [A32](#a32) | medium | | medium | critic/gap | No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm | +| [A33](#a33) | medium | | medium | critic/bug | On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it | +| [A34](#a34) | medium | | small | critic/bug | The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter | +| [A35](#a35) | medium | | small | critic/bug | approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.' | +| [A36](#a36) | medium | | small | critic/gap | After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself | +| [A37](#a37) | low | | small | build/risk | proguard-rules.pro would not survive turning on minification (R8 missing-class errors) | +| [A38](#a38) | low | | small | protocol/bug | Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question | +| [A39](#a39) | low | | small | protocol/bug | The account chip reads `account.label`, which the mirror never writes, so it falls back to the raw account UUID | +| [A40](#a40) | low | | small | runtime/bug | The pending-launch path and the attach hand-off never re-check `disposed`: a stream can stay attached forever, or a phone-started node gets registered with no agent launched | +| [A41](#a41) | low | | small | runtime/bug | The composed prompt is cleared even when no stream is attached, so the text is silently lost | +| [A42](#a42) | low | | small | runtime/bug | NewSessionDialog crashes if the selected project disappears while the dialog is open | +| [A43](#a43) | low | | small | runtime/bug | The HostScreen tab and scroll position reset after returning from a terminal | +| [A44](#a44) | low | | small | runtime/bug | System back discards Settings edits (device name, relay API base) | +| [A45](#a45) | low | | small | runtime/risk | No onRenderProcessGone handler on the terminal WebView | +| [A46](#a46) | low | | small | runtime/bug | The ⌨ key-row chip only focuses the DOM textarea, which cannot raise the soft keyboard | +| [A47](#a47) | low | | small | runtime/bug | The Keystore decrypt runs on the main thread in the host list's composition, once per row per recomposition | +| [A48](#a48) | low | | small | runtime/bug | The seen-events set is trimmed in hash order and updated without synchronization, which can produce duplicate notifications | +| [A49](#a49) | low | | small | security/risk | SSH host-key TOFU pin is saved during key exchange (before auth) and is not tied to the pairing | +| [A50](#a50) | low | | medium | security/risk | The only distributable build is a debuggable APK, so Keystore-protected secrets can be pulled over adb/JDWP | +| [A51](#a51) | low | | small | security/gap | allowBackup=false does not stop device-to-device migration at targetSdk 35: hosts, pins and deviceId are cloned | +| [A52](#a52) | low | | small | security/gap | Approval and finish notifications put command text and the agent's last message on the lock screen | +| [A53](#a53) | low | | small | security/bug | OSC 52 handler has no size cap (the desktop caps at 1,000,000) and setPrimaryClip is unguarded | +| [A54](#a54) | low | | small | security/bug | PairingClient trusts an unbounded Content-Length / EOF body from the pairing endpoint | +| [A55](#a55) | low | | medium | parity/gap | Inbox and Usage are per computer; iOS merges them across all paired computers | +| [A56](#a56) | low | | small | parity/gap | Approval cards lack iOS's "Always allow" answer | +| [A57](#a57) | low | | small | parity/gap | Multi-select AskUserQuestion cards fall back to "Open session" | +| [A58](#a58) | low | | small | parity/gap | Usage and feed cards omit iOS's pace line and the context indicator on event cards | +| [A59](#a59) | low | | small | parity/gap | No built-in dictation (iOS has on-device Whisper plus a Cloud engine) | +| [A60](#a60) | low | | small | ci-docs/bug | Interop fixture needs the Electron binary, which is downloaded at test time inside the 20 s ready window, despite the workflow saying 'not Electron' | +| [A61](#a61) | low | | small | ci-docs/bug | Following CONTRIBUTING / android/README (`npm ci --ignore-scripts`) wipes a desktop developer's patched node_modules | +| [A62](#a62) | low | | small | ci-docs/bug | SSH transport tests fail, not skip, on macOS: the gate checks only that /usr/bin/script exists, then runs util-linux-only flags | +| [A63](#a63) | low | | small | ci-docs/gap | Android workflow path filters miss files that change the tested wire behavior, contrary to CLAUDE.md | +| [A64](#a64) | low | | small | ci-docs/gap | Docs say the protocol tests check the mirror, the projects.list blob and the ~/.nodeterm files against desktop code, but those shapes are hand-copied in the tests | +| [A65](#a65) | low | | small | ci-docs/gap | User-facing docs and desktop UI present the Android app as working, but it has never been built by AGP or run on a device, and no device checklist exists | +| [A66](#a66) | low | | small | ci-docs/gap | ANDROID_APP_URL points at a folder that exists on neither upstream nor fork main yet, and it points at source code rather than an installable | +| [A67](#a67) | low | | small | ci-docs/gap | The interop fixture is excluded from every tsconfig, so `npm run typecheck` never checks it against the desktop interfaces it implements | +| [A68](#a68) | low | | small | ci-docs/risk | Workflow triggers break repo conventions: every branch push plus pull_request doubles runs, and there is no merge_group | +| [A69](#a69) | low | | small | ci-docs/gap | The Gradle/Kotlin code has no dependency-update, CodeQL or wrapper-validation coverage | +| [A70](#a70) | low | | small | ci-docs/bug | On Windows the interop tests fail with CreateProcess instead of skipping | +| [A71](#a71) | low | | small | ci-docs/gap | android/README says 'JDK 17+', but the pinned Gradle 8.14.3 cannot run on JDK 25 | +| [A72](#a72) | low | | medium | critic/gap | Phone-started sessions are created without the agent-specific env, so for their whole life they get no hook-reply approvals, no canvas control and no pane ownership | +| [A73](#a73) | low | | small | critic/bug | Notifications are documented as 'live every 8 seconds while a computer is open', but the in-app poll never posts a notification | +| [A74](#a74) | low | | small | critic/bug | The LAN leg dials a DHCP IPv4 frozen at pairing time; when another SSH host answers at that address, the host-key 'hard stop' also blocks the relay fallback | +| [A75](#a75) | low | | small | critic/gap | The New session account picker lists managed Claude accounts by raw UUID | +| [A76](#a76) | low | | small | critic/gap | Over direct SSH, opening a Sleeping (Eco-hibernated) session lands on a bare shell with no wake or resume offer | +| [A77](#a77) | low | | small | critic/bug | IME insets are not handled for Android 15's enforced edge-to-edge (targetSdk 35): the terminal gets double bottom padding when the keyboard opens, and other screens have no IME padding at all | + +## A01 + +**Direct-SSH terminal writes and resizes run on the Android main thread, which breaks the SSH connection as soon as the keyboard opens** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: build; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt:221` +- Note: Same bug as A04 (reported by two auditors). Fix once, in SshStream. + +**Evidence** + +SshStream.write/resize are plain (non-suspend) calls that write to the socket synchronously: `override fun write(text: String) { synchronized(this) { runCatching { stdin.write(...); stdin.flush() } } }` (l.221-228) and `override fun resize(...) { runCatching { (session as Session.Shell).changeWindowDimensions(...) } }` (l.230-233). TerminalController calls them on the MAIN thread: `main.post { ... if (s != null) s.resize(c, r) ... }` (TerminalController.kt l.124-132, fired by xterm's fit whenever the WebView resizes, and TerminalScreen.kt l.77 `.imePadding()` / adjustResize resize it when the soft keyboard opens), `main.post { ... s.resize(cols, rows) }` (l.216), `fitHere()` (l.279), `acceptResume()` -> `stream?.write` (l.270), and `raw()` -> `stream?.write` (l.290) from the ^C/^D/^R/^L key chips (TerminalScreen.kt l.163-166). On Android, socket writes on the main thread throw NetworkOnMainThreadException, a RuntimeException. `javap` of sshj 0.39.0 `TransportImpl.write` shows `Encoder.encode` (offset 108) runs before `OutputStream.write/flush` (131/141). The only handler around the socket write is `java/io/IOException` (range 112-144), and `Encoder.encode` does `putfield seq`, so the sequence number and cipher state have already advanced. The exception escapes sshj and SshStream's runCatching swallows it silently. The next packet (the user's next keystroke, from the JavaBridge thread) is encrypted with a state the server does not expect, so sshd drops the connection. The terminal ends with 'Disconnected.', and the shared SSHClient used for listing and the Inbox dies with it. The relay path is unaffected: RelayHostConnection.Stream.write/resize only enqueue on OkHttp's `ws.send`. The same NetworkOnMainThreadException hits `SshHostConnection.close()` -> `client.disconnect()` when it runs from click handlers (HostsScreen.kt l.145 Forget, SettingsScreen.kt l.109 route change). There it escapes sshj's `sendDisconnect` before `finishOff()`, which leaks the socket and the sshj reader thread. JVM tests cannot see any of this because the JVM has no BlockGuard, and nothing in app/ or protocol/ relaxes StrictMode. + +**Proposed fix** + +Keep all sshj I/O off the main thread while preserving write order. Give SshStream a single-thread writer, e.g. `Executors.newSingleThreadExecutor()`, and have write/resize enqueue onto it. Alternatively, have TerminalController route every stream.write/resize/raw/acceptResume/fitHere call through one serial off-main dispatcher, such as `Dispatchers.IO.limitedParallelism(1)`. Run `HostSession.disconnect()` / `SshHostConnection.close()` on `Dispatchers.IO` as well. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line numbers and root cause are accurate. Three refinements: +> (a) One more main-thread caller of `close()`: `PairScreen.kt:152` (`graph.connections.forget`), alongside `HostsScreen.kt:145` and `SettingsScreen.kt:109`. `InboxNotifier.kt:127` runs in the worker and is fine. +> (b) Moving sshj writes off the main thread matters even apart from StrictMode. `TransportImpl.write` takes `writeLock` and can block in `kexer.waitForDone()` during a rekey. `ChannelOutputStream.flush` can block in `win.awaitExpansion`. On the main thread either one is an ANR risk. +> (c) The fix must preserve ORDER across two producers: main-thread resize/raw/acceptResume and JavaBridge-thread onInput. So route both `SshStream.write` and `resize` through ONE serial executor inside `SshStream` (e.g. a single-thread executor created per stream and shut down on detach/exit). Fixing each call site separately would leave the ordering unguaranteed. Also make `HostSession.disconnect()` run `c?.close()` on `Dispatchers.IO` (e.g. `graph.scope.launch(Dispatchers.IO)`). For defense, catch `RuntimeException` around `client.disconnect()` and fall back to closing the socket, so a failed `SSH_MSG_DISCONNECT` cannot leak the socket and reader thread. + +> The finding is accurate. Four refinements: +> +> 1. The finding lists the keyboard, rotation, A−/A+ and 'Fit this screen' as triggers. The A−/A+ path is the only one that fires even with a hardware keyboard and no rotation, because TerminalController.setFontSize -> terminal.js `setFontSize` -> `doFit(true)` forces onResize. +> +> 2. What gets corrupted is broader than the sequence number. Encoder.encode advances both `seq` (putfield at offset 274) and the cipher stream (Cipher.update at 334/381), so every negotiable cipher desyncs. +> +> 3. For close(): the DisconnectListener fires before the failing sendDisconnect, but it is a no-op because close() already set closedFired=true. The keepalive is interrupted first, so the leaked socket is idle rather than kept alive. +> +> 4. Put the fix in the protocol layer, not in TerminalController. The TerminalStream contract is non-suspend and callers cannot know that SSH blocks. Give SshStream a single-thread executor and route write, resize and scroll through it; this also serializes main-thread raw()/resize with JavaBridge-thread onInput, keeping them in order. Make SshHostConnection.close() hand client.disconnect() to that executor, or to a background thread. For defense in depth, catch RuntimeException in SshStream by closing the channel/transport instead of silently swallowing it, since the transport is unusable after any exception thrown mid-write. + +## A02 + +**SSH browse looks for the desktop's userData under 'nodeterm', but the desktop writes it under 'node-terminal', so every direct-SSH listing comes back empty** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:38` +- Note: Related: A27 (the Server Edition data dir is not probed either). + +**Evidence** + +Android PRELUDE: `for d in "$HOME/Library/Application Support/nodeterm" "${XDG_CONFIG_HOME:-$HOME/.config}/nodeterm"; do if [ -f "$d/workspace.json" ]; then NT_UD="$d"; ...`. On the desktop, userData is `app.getPath('userData')`, which is appData/. package.json has `name: "node-terminal"` and NO top-level productName (only `build.productName: "nodeterm"`). electron-builder's `modifyMainPackageJson` (node_modules/app-builder-lib/out/fileTransformer.js:88-106) merges only `config.extraMetadata` into the packaged package.json, and this repo sets none, so at runtime app.name is `node-terminal`. The desktop's own runtime shell agrees: src/core/agents/hook-endpoint-failover-sh.ts:79-80 walks `"$HOME/.config/node-terminal/hook-endpoint.env"` and `"$HOME/Library/Application Support/node-terminal/hook-endpoint.env"`, and node-token-sh.ts:93-94 uses the same `node-terminal` dirs. Repo tests use the same path as a realistic example (ssh-agent.test.ts:59, codex-relay-daemon.test.ts:925). The mirror is written to `path.join(platform().userDataDir, 'agent-status.json')` (agent-status-mirror.ts:1436). Only scripts/uninstall.sh and docs/uninstall.md say `nodeterm`, and the Android prelude copied that. I ran the prelude under /bin/sh against a fake HOME that had `~/.config/node-terminal/workspace.json`: it printed `ud=` (empty). SshTransportTest.kt:154 passes only because its fixture creates `.config/nodeterm`. + +**Proposed fix** + +Probe `$HOME/Library/Application Support/node-terminal` and `${XDG_CONFIG_HOME:-$HOME/.config}/node-terminal` first, keeping `nodeterm` as a fallback. Change the SshTransportTest fixture to the real directory name so the test would have caught this. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The core claim and root cause are correct. Two refinements: +> +> 1. The fix should probe `node-terminal` first on both platforms: `$HOME/Library/Application Support/node-terminal` and `${XDG_CONFIG_HOME:-$HOME/.config}/node-terminal`. Keep the existing `nodeterm` dirs as fallbacks. Also consider `$HOME/.nodeterm-server`, the Server Edition's default data dir (src/server/config.ts:108), which the prelude does not probe either. Update the header comment at SshScripts.kt:17-19 and the SshTransportTest fixture (line 154) to `node-terminal`. +> +> 2. docs/uninstall.md:24 and scripts/uninstall.sh:64 carry the same wrong `nodeterm` path. That is a separate desktop-side doc/script bug, but it is where the Android prelude copied the path from. + +> Mostly correct. A few refinements: +> +> (a) The KDoc at SshScripts.kt:18-19 states the wrong path too, and should be fixed along with line 38. +> +> (b) The `nodeterm` spelling appears in more places than scripts/uninstall.sh and docs/uninstall.md. It is also in the src/main/remote-ssh/ssh-agent.ts:76-77 comment, which wrongly says the installed app is "nodeterm". Illustrative paths use it in docs/codex-shared-identity.md:72 and in claude-accounts-core.test.ts / claude-skill-share-core.test.ts. The desktop repo is internally inconsistent, and the Android author followed the wrong half. +> +> (c) The proposed fix is right. Probe `node-terminal` first in both the macOS and XDG locations, keep `nodeterm` as a fallback, and change the SshTransportTest.kt:154 fixture to `.config/node-terminal` so the test catches this. +> +> (d) Not strictly needed, but: because the listing is empty rather than failing, ConnectionManager never falls back to the relay even when one is paired. A browse that returns `ud=` with no workspace should be treated as a failed or degraded SSH route rather than a legitimately empty host. + +## A03 + +**The direct-SSH tmux client starts without a UTF-8 locale, so tmux replaces every non-ASCII character with '_'** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:92` + +**Evidence** + +The attach script sets only `TERM=xterm-256color; export TERM` and then `exec "$NT_TMUX" -L node-terminal "$@" new-session -A -s ...`. It never sets LANG/LC_ALL and never passes `-u`, and nothing in the Android code calls sshj `setEnvVar` (grep for LANG/LC_ALL/setEnvVar/` -u ` in android/ finds nothing). tmux marks a client UTF-8-capable only when LC_ALL/LC_CTYPE/LANG contains UTF-8 (or with -u). The desktop solves this for its own clients: pty-manager.ts:512 `resolveLocaleLang()` forces `en_US.UTF-8` into the spawn env ('rounded borders come out as `_`/`|`'), and passes `-e LANG=` per session (pty-manager.ts:3249-3251). An sshd exec channel on a stock macOS host carries no LANG: sshj sends no env, and the `zsh -c` wrapper sources no locale. Measured in the sandbox with tmux 3.4: a client started without LANG received `_ box` instead of `╭ box`; the same client with LANG=en_US.UTF-8 received the real glyphs. + +**Proposed fix** + +In the attach script, export a UTF-8 locale when the inherited one is not UTF-8 (the same rule as resolveLocaleLang), or add `-u` to the tmux client invocation. Add an SshTransportTest case that runs with LANG unset. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line and root cause are correct (SshScripts.kt:92-93, with the exec on line 93). Two refinements: +> +> 1. Not every character becomes `_`. Box-drawing characters that have a DEC ACS mapping (─ │ ┌ ┐ └ ┘ and the like) are converted by tmux to `ESC(0` + an ACS letter, and xterm.js renders those correctly. Characters with no ACS mapping become width-many underscores: rounded corners ╭╮╰╯, accented Latin letters, CJK (two underscores each), and symbols and emoji such as ✻ ⏺ ⎿. +> +> 2. The better fix is to export a UTF-8 LANG in the attach script, using the desktop's `resolveLocaleLang` rule (keep an inherited LC_ALL/LC_CTYPE/LANG that already says UTF-8, otherwise `LANG=en_US.UTF-8`), rather than only adding `-u`. `-u` fixes this client's rendering but not the pane environment when the phone's attach starts the tmux server; exporting LANG covers both. Adding `-u` on top is harmless. +> +> The regression test should attach under an env with no LANG, LC_ALL or LC_CTYPE and assert that a non-ASCII glyph (e.g. `é`) arrives as UTF-8 bytes, not `_`. + +> The line is right (92-93) and so is the root cause. Two additions and a sharper fix: +> +> (a) The impact is mainly stock macOS hosts, plus Linux hosts without a pam_env locale. Debian/Ubuntu normally get LANG through PAM. +> +> (b) SshTransportTest.kt:57 hides the bug by passing the JVM's own environment, LANG included, to the fake sshd. The new test must clear LANG, LC_ALL and LC_CTYPE in `childEnv()` and assert that a non-ASCII glyph such as `╭` comes through the attach. +> +> (c) Fix: add `-u` to the tmux client invocation in `attach()`. That makes the client UTF-8 whatever locale the host has installed. Also export LANG only when LC_ALL, LC_CTYPE and LANG are all non-UTF-8, so that panes of a server the phone starts don't inherit a C locale. Use `en_US.UTF-8` on Darwin, where it is always present and matches the desktop's resolveLocaleLang, and `C.UTF-8` elsewhere. Forcing en_US.UTF-8 on a Linux host that lacks it makes shells print setlocale warnings. `-u` alone fixes rendering but not the pane locale of a phone-created server. + +## A04 + +**SSH terminal writes and resizes run on the main thread, and the NetworkOnMainThreadException that runCatching swallows leaves sshj's cipher state out of sync, which drops the whole SSH connection** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:131` +- Note: Same bug as A01. + +**Evidence** + +Main-thread callers of TerminalStream.write/resize: +- TerminalController.kt:125-131: `main.post { ... val s = stream; if (s != null) s.resize(c, r) ... }`. This runs on every WebView resize: the soft keyboard (TerminalScreen.kt:77 `Column(...imePadding())` shrinks the WebView), rotation, and A−/A+ (`nt.setFontSize` → `doFit(true)` → `bridge.onResize`). +- :216 `main.post { ... s.resize(cols, rows) }`; :279 fitHere `s.resize(cols, rows)`; :270 acceptResume `stream?.write(offer.second + "\r")`; :290 `fun raw(data: String) { stream?.write(data) }`, which the ^C/^D/^R/^L chips call (TerminalScreen.kt:163-166). + +SSH side, SshHostConnection.kt:221-233: `synchronized(this) { runCatching { stdin.write(...); stdin.flush() } }` and `runCatching { (session as Session.Shell).changeWindowDimensions(...) }`. Both write to the socket on the calling thread. + +In the sshj 0.39.0 jar, TransportImpl.write calls `Encoder.encode(packet)` at bytecode offset 108; that increments the sequence number and runs cipher.update/MAC. Only then does it call `connInfo.out.write` at offset 131 (a raw java.net.Socket stream, from SocketClient.onConnect). The exception table catches only IOException. + +On Android the main thread has StrictMode death-on-network (targetSdk ≥ 11), and SocketOutputStream.socketWrite calls `BlockGuard.getThreadPolicy().onNetwork()`. So the write throws NetworkOnMainThreadException, a RuntimeException that runCatching silently swallows. + +**Proposed fix** + +Never call TerminalStream.write/resize on the main thread. Either give SshStream a per-stream single-thread executor that does write+flush and changeWindowDimensions (this keeps keystrokes in order), or route raw/acceptResume/fitHere/onResize/the attach-time resize through a serial background dispatcher (e.g. Dispatchers.IO.limitedParallelism(1)). Also stop runCatching-ing a RuntimeException out of sshj: at that point the transport state is corrupt, so close the connection. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The substance is correct. Three refinements: +> +> (a) Ordinary typed input is NOT affected. `Bridge.onInput` (`TerminalController.kt:136-144`) runs on the WebView's JavaBridge background thread, and so do `submit()`/`key()`, which go through `nt.submit`/`nt.key` → `bridge.onInput`. Only these main-thread paths break: the resize at `:131` and `:216`, `fitHere` at `:279`, `acceptResume` at `:270`, and the KeyRow chips at `:290`. Opening the keyboard alone is enough, because `adjustResize` plus `imePadding` shrinks the WebView, xterm's rows change, and `onResize` fires. +> +> (b) The fix has to serialize with the JavaBridge-thread `onInput` writes as well. Otherwise ^C from a chip could overtake or interleave with typed bytes. So put the executor or `limitedParallelism(1)` dispatcher inside `SshStream` itself, covering write+flush and `changeWindowDimensions`, rather than in the controller. +> +> (c) `runCatching` in `write`/`resize` also hides an `IOException`/`TransportException` from a dead socket. Any exception out of `Transport.write` should close the stream or connection, not be ignored. + +> The finding is accurate. Three refinements: +> +> (a) Line 235 (afterAttach) is NOT on the main thread: it runs on graph.scope, which is Dispatchers.Default. Typed keystrokes via Bridge.onInput (:143) run on the WebView JavascriptInterface thread and are also unaffected. The main-thread offenders are exactly :131, :216, :270, :279 and :290. +> +> (b) The disconnect does not need further user activity. After one swallowed main-thread write, the next packet on the transport breaks the connection. That can be the HostSession poll's exec (every 8 s) or sshj's keep-alive (set to 20 s at SshHostConnection.kt:353). So the terminal drops within about 8 to 20 s of the first keyboard-open, rotation, A−/A+, chip tap, Resume or Fit. +> +> (c) Fix details: +> - Move SshStream.write and resize onto one per-stream single-thread executor (or a `Dispatchers.IO.limitedParallelism(1)` dispatcher) so writes and resizes keep their order. +> - On any non-IOException thrown from sshj's write path, close the connection: `client.disconnect()` plus `fireClosed`. Do not swallow it. +> - Narrowing runCatching to IOException alone is not enough: the cipher has already advanced before the throw. +> - Optionally, move the controller's main.post resize and raw calls off the main thread as well, which protects any future TerminalStream implementation. + +## A05 + +**The background InboxWorker can open a never-approved relay handshake and raise the desktop's SAS approval dialog while the phone shows no code** + +- Severity: **high** (BLOCKS first release); claimed by auditor: medium; effort: small; area: runtime; kind: risk +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:124` +- Note: Same issue as A17 and A23 (three auditors). Related: A07, A30. + +**Evidence** + +- NodetermApp.kt:45 schedules InboxWorker on every app start. +- InboxNotifier.kt:121-127 runs `withTimeoutOrNull(45_000) { session.refreshNow() }` for every paired host. +- refreshNow → connectLocked (ConnectionManager.kt:99-125) falls through to the relay when SSH fails (phone off the LAN). It then runs RelayConnector.connect, which holds the handshake open and polls projects.list while the desktop waits for approval (RelayConnector.kt:67-86). +- Pairing never pins the phone's box key: the PairingClient body is `{token, publicKey(ssh), deviceName, deviceId, priorDeviceToken}`. standing-host.ts:12-13 says "the first connect from a given phone (its box public key) prompts the host human via the shared SAS dialog". +- A phone paired on the LAN with route AUTO connects over SSH in the foreground, so its first relay connect is usually the worker's, in the background. +- The SAS is rendered only in HostScreen's ConnectionBanner. + +**Proposed fix** + +Store a per-host "relay approved" flag after the first successful foreground relay connect, and have the worker skip the relay leg until it is set. Alternatively, abort on RelayConnectStatus.AwaitingApproval in background mode and post a notification asking the user to open the app. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause is right. The second proposed fix is not: aborting on `RelayConnectStatus.AwaitingApproval` in background mode would not prevent the desktop prompt. The desktop raises its SAS dialog in `onPeerReady`, as soon as the E2EE handshake completes (`standing-host.ts:256-261`). The phone only learns it is awaiting approval later, when its first `projects.list` fails with "Awaiting host approval." (`RelayConnector.kt:72-80`). Even if the phone closed immediately, the desktop would keep the approvable dialog for 120 s (`phone-approval.ts`). +> +> The fix that actually prevents the prompt is the first proposal: never enter the relay leg from the background worker until a foreground relay connect for that host has completed. Record a per-host "relay approved" flag when `RelayConnector.connect` returns successfully in the foreground, and have `InboxWorker` pass a background/`allowRelayHandshake=false` mode to `connectLocked`. +> +> Optionally, when the worker skips a host whose relay leg is unapproved, it can post a local notification asking the user to open the app and approve this phone. One more precision: the prompt does not repeat forever. It recurs every 15 minutes only while the phone is off the LAN and the key is still unpinned. + +## A06 + +**approvals.answer reports success for a hold that already timed out, and clears NEEDS YOU on every surface** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: security; kind: bug +- Location: `src/main/remote/host-service.ts:690` +- Note: Related: A35 (answered:false is ambiguous). + +**Evidence** + +The new verb's doc (host-service.ts:690) promises "`answered:false` is an ANSWER (the hook already timed out, the host could not write)". It cannot return that for a timeout. The relay leg reaches `answerPermission` (src/main/index.ts:2774), which calls `writePendingAnswerLocal` (src/core/agents/pending-approvals.ts:42-58: `mkdir` + `writeFileAtomic(file, decision)`, then `return true`). The SSH-project leg is `ssh-project.ts:1823-1845` (`remoteAtomicWrite`, then `code === 0`). Neither checks that `.json` still exists. The hook deletes that file and stops polling when its hold ends: managed-script.ts:423-425, `# Timed out: clean up the request + payload files and print nothing → Claude shows its normal prompt`. On `ok`, answerPermission then emits `syntheticAnsweredEvent`, so the mirror marks the card resolved and flips the node to working (agent-status-mirror.ts:1492-1496). The phone's own SSH leg does check: SshScripts.kt:136 `if [ ! -f "$d/$pendingId.json" ]; then echo gone; exit 0; fi`. On timeout the hook POSTs nothing, so the inbox card keeps its dead `pendingId` (only a `working` event with that id resolves it). QuickActions.stillWaiting still sees BLOCKED and takes the relay path. The hold is `PERM_WAIT_SECS_DEFAULT = 45` (hook-server.ts:65). Android's only background alert path is the 15-minute WorkManager poll (InboxNotifier.kt:64). So an Approve or Deny tapped from an Android notification almost always arrives after the hold has ended. + +**Proposed fix** + +In answerPermission, check that the request still exists before writing: locally, `fs.access(/.json)`; over SSH, a `test -f` in the same remote command. Return false otherwise, and skip the synthetic event on false. On the phone, map a 'gone/false' result for a node that is still BLOCKED to OPEN_SESSION (the prompt is now on screen) rather than 'Already handled.' + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The core claim is right. Some details need adjusting: +> +> 1. **Where the tap happens.** The Android notification has no Approve/Deny actions; it only sets a content intent that opens the app (InboxNotifier.kt:97-113). Approve/Deny live in the Inbox tab (InboxTab.kt:111-114). So the late answer comes from a user who opens the app, from that notification or just by browsing, and taps a card that is still unresolved. The 15-minute poll (InboxNotifier.kt:64) is why that usually happens after the 45 s hold, but any in-app tap more than 45 s after the request hits the bug. +> +> 2. **The gap is not new, but its reach is.** The writer was extracted unchanged from the pre-existing canvas IPC handler in commit a0c07e6. The desktop canvas Approve/Deny has always had the same problem: the renderer keeps `pendingId` on blocked (agentStatus.ts:558), so the buttons stay after a timeout. On the desktop, though, the prompt is on screen in front of the user. What commit a0c07e6 adds is exposure to a remote phone, plus a doc/test contract (host-service.ts:690, host-inbox-verbs.test.ts:6-7) that the code cannot meet. +> +> 3. **Severity.** High is defensible, because a phone answer usually lands after the hold. Medium would also be defensible, since it only bites when the phone answers after the 45 s hold. +> +> 4. **The fix.** The proposed fix is right in substance, with these points: +> - Check `/.json` exists before writing, locally and in the same remote command over SSH. +> - Return false and skip the synthetic event when it is missing. +> - A small race between the check and the write remains, as it does in SshScripts.kt:136. It is harmless apart from an orphaned `.answer` file that the sweep removes. +> - On the phone, prefer OPEN_SESSION over "Already handled." when `answered:false` comes back and the node is still BLOCKED, because the interactive prompt is then on screen. + +> 1. **Approve/Deny are not notification actions.** The notification built at `InboxNotifier.kt:95-117` only has a content intent that opens the app. The buttons are in the Inbox tab (`InboxTab.kt:109-117`), reached after opening the app or a notification. The timing argument still holds. +> +> 2. **The root cause is older than this branch.** Commit a0c07e6 only extracted the canvas IPC body into `answerPermission`. The canvas Approve/Deny button (`IPC.agentAnswerPermission`) has had the same no-existence-check behaviour all along. The new relay verb inherits it and documents a `false` result it can never produce. What this branch adds is exposure: the phone's minutes-late tap is now the common case. +> +> 3. **Severity is arguably medium-high rather than high.** It is limited to relay-only Android phones answering Claude hook-reply approvals. Within that path, it is the dominant outcome. +> +> 4. **The fix belongs in the writers:** +> - `writePendingAnswerLocal`: `fs.promises.access(path.join(dir, pendingId + '.json'))` before writing; return false if it is missing. +> - `SshProjectManager.writePendingAnswer`: put `test -f /.json || exit 3` in the same remote command. +> - `answerPermission` already skips the synthetic event when the result is false. +> - A small race remains: the hook can time out between the check and the write. Closing it fully would need the hook to POST a "timed-out" event, which the mirror would use to strip the `pendingId` from the card. That would also let the desktop badge stop offering dead Approve/Deny buttons. +> +> 5. **Map the Android dead-ticket results to OPEN_SESSION.** Both the relay `answered:false` and the SSH `gone` currently map to ALREADY_HANDLED. When the fresh status still says BLOCKED, the prompt is now on screen, so the result should be OPEN_SESSION. A claude-only legacy `1`/Esc send-keys would also work there, but only after re-confirming the pane. + +## A07 + +**Remote access fails the first time you are away from the computer: Auto never does the first relay handshake while the phone is at the desk, and pairing does not pin the phone's relay key** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: medium; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt:76` +- Note: Related: A05, A30. Partly a desktop design gap (the relay key is never pinned at pairing), so iOS likely shares it. + +**Evidence** + +connectLocked tries direct SSH first whenever `route != RELAY_ONLY && host.sshAvailable` (lines 76-87) and returns on success. The relay is only tried when SSH fails (lines 99-125). After pairing, PairScreen goes straight to Route.Host, which connects over SSH on the LAN. adoptRelayIfAdvertised returns early when a relay leg and token already exist (ConnectionManager.kt:167). So a phone paired on the LAN never does a relay handshake while its owner is at the computer. Nothing else can pre-approve it. The /pair body the phone sends is `{token, publicKey(ssh), deviceName, deviceId, priorDeviceToken}` (PairingClient.kt:45-50), with no box key. The desktop /pair handler never calls pinDevice (grep: pairing-service.ts has no approved/pin reference). The standing host approves a relay peer only if `isPinned(store, pub)` (standing-host.ts:250); otherwise it raises the SAS dialog (standing-host.ts:258). The first relay connect therefore happens off-LAN, usually with nobody at the desktop. RelayConnector waits 5 minutes and then fails with "Nobody approved this phone on your computer in time." (RelayConnector.kt:82). Meanwhile PairScreen told the user "✓ Reachable from anywhere (remote access is on)" (PairScreen.kt:123). + +**Proposed fix** + +Complete the pin-once approval while the user is at the computer. Either (a) run one relay handshake plus approval right after pairing (and after late adoption), while the phone is still on the LAN, and show the SAS on PairScreen; or (b) send the phone's persistent box public key inside the E2EE-sealed /pair body and have the desktop pin it when pairing succeeds. Until then, PairScreen should say that remote access needs one approval at the computer. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The behaviour is as described, but four things need adjusting. +> +> (1) Root cause and scope. This is not only an Android client bug. The desktop design itself has no way to pin a phone's relay key at pairing time. The desktop's own documented flow in the standing-host.ts:424-434 comment (put the phone on cellular, "FIRST connect: the desktop shows the SAS approval dialog") treats the first relay connect as the approval moment. So iOS very likely has the same gap; that part is inferred, since the iOS repo is not visible. android/README.md:15 even documents it ("Pin-once: approve the phone on the computer the first time"). +> +> (2) Severity. Medium fits better than high. It is one-time setup friction, not a broken steady state. Workarounds exist: switch the route to Relay-only in Settings (SettingsScreen.kt:104) or connect over cellular while at the desk; approving within the 5-minute wait or the 120 s retention after the phone gives up also pins the key. Windows hosts are not affected, because they have no SSH and their first connect after pairing goes through the relay while the user is still at the desk. +> +> (3) A related side effect. While the owner is away, the background inbox poll (InboxNotifier.kt:122-124) runs refreshNow under a 45 s timeout. An unapproved phone therefore raises the SAS dialog on the unattended desktop, then gives up on that attempt after 45 s — repeated noise, never an approval. +> +> (4) Choice of fix. Fix (a) needs only an Android change, and it is enough: after pairing, and after late adoption, run one relay handshake plus approval while still on the LAN, and show the SAS on PairScreen. Fix (b) is cryptographically sound, because the /pair body is sealed to the QR's host key and gated by the one-time QR token. But it needs a desktop change in pairing-service.ts, and it changes the phone trust model that docs/ios-protocol-migration.md §7 leaves as an open question to settle with iOS. At minimum, PairScreen.kt:123 should say that remote access needs one approval at the computer. + +> Facts and line numbers are correct. +> +> Severity should be medium, not high. This is the desktop's documented pin-once design (standing-host.ts:12-14 and the smoke test at 426-434; docs/android.md:23). The iOS app very likely shares it, so it is a real, inherited product gap rather than an Android-only bug that breaks a feature. It is also a one-time cost: approving once from the desk with the phone off the LAN, or with the route set to Relay-only, fixes it permanently. The problem is that nothing tells the user this. +> +> Root cause: the only way a phone key gets pinned is the standing host's interactive SAS approval (standing-host.ts:180/250), and the Android route order means that approval never happens while the user is at the computer. +> +> Better fix order: +> (a) Android-only, no desktop or iOS change needed. After a successful pair that returned a relay leg (and after late adoption in adoptRelayIfAdvertised), run one RelayConnector.connect while the user is still at the desk and show the SAS on PairScreen. Use the same flow as HostScreen's AwaitingApproval banner, then close that connection. The user stays at the desk until they approve or skip. +> (b) The desktop-side option is sound but must land together with iOS. It would add the phone's persistent box public key to the E2EE-sealed /pair body. That body is authenticated by the on-screen one-time token and sealed to the QR's hostKey. pairing-service would then pin that key through updateApprovedDevices/pinDevice. +> +> Interim: PairScreen:123 should say remote access needs one approval at the computer, rather than "✓ Reachable from anywhere". +> +> Secondary: InboxWorker should not start a relay approval in the background. When state would be AwaitingApproval and nobody is watching, it should skip, so it does not raise unattended SAS dialogs on the desktop every 15 minutes. + +## A08 + +**A cold attach over direct SSH creates the desktop's tmux session with no hook environment, so an agent resumed there never reports status, and the desktop never repairs it** + +- Severity: **high** (BLOCKS first release); claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:93` +- Note: Related: A72 (phone-started sessions lack agent env). + +**Evidence** + +SshScripts.attach runs `exec tmux -L node-terminal [-f conf] new-session -A -s nt- [-c cwd]` (line 93) with no `-e NODETERM_NODE_ID=… -e NODETERM_HOOK_ENDPOINT=…`. When the session is gone (the reboot case the app detects via `hasSession`, SshHostConnection.kt:172), this CREATES it bare. The managed hook script is gated on the variable (`if [ -z "$NODETERM_NODE_ID" ]; then` exit — managed-script.ts:153). An agent started in that pane, including via the phone's own cold-start 'Resume' offer (TerminalController.kt:247-254), therefore reports nothing. When the desktop later mounts the node, the session already exists, so `fresh:false`: no cold restore, and tmux ignores `-e` for an existing session. The relay path does not have this problem, because the desktop's attachDetached injects the hook env from persistKey (pty-manager.ts:2892-2896). + +**Proposed fix** + +Do not cold-create over SSH. When `hasSession` says no, offer to 'open on the computer' via the relay, or ask the user to open the node on the desktop first. At minimum, pass the hook env the desktop would pass (NODETERM_NODE_ID, NODETERM_HOOK_ENDPOINT pointing at `/hook-endpoint.env`, NODETERM_HOOK_VERSION) with `-e` when creating. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause and the file:line are correct (`SshScripts.kt:93` has no `-e` hook env when `new-session -A` creates the session). The impact statement is incomplete. The session is only fully "dark" when the phone's attach is what starts the tmux server. When the desktop's tmux client started the server, the bare session inherits the tmux GLOBAL env, which the desktop seeded with another node's `NODETERM_NODE_ID`, `NODETERM_HOOK_ENDPOINT` and possibly `NODETERM_AGENT_ID` / `NODETERM_CANVAS_CONTROL` (`pty-manager.ts:2897` merges `hookEnv` into the client process env, and `NODETERM_*` is not in `update-environment`). That was verified with tmux 3.4. The resumed agent's status, verified token and session id are then attributed to the wrong node, and `Canvas.tsx:13242` overwrites that node's persisted `sessionId`. +> +> This is also an iOS parity gap. iOS passes `NODETERM_HOOK_ENDPOINT` and `NODETERM_NODE_ID` (`phone-spawned-identity.test.ts:4`, `docs/node-identity.md:185-187`). Severity is at least medium, arguably high, given the cross-node corruption. +> +> Better fix: when creating, ALWAYS pass `-e NODETERM_NODE_ID=` and `-e NODETERM_HOOK_ENDPOINT=/hook-endpoint.env`, as iOS does. This also overrides any leaked global value. For agent nodes, also pass `-e NODETERM_AGENT_ID=`. For a plain terminal, unset the inherited agent/canvas-control vars; `-e` cannot unset, so wrap the command with `env -u NODETERM_AGENT_ID -u NODETERM_CANVAS_CONTROL` or similar. The node id alone is enough to heal the endpoint through the hook's failover (`managed-script.ts:146-152`). `-e` is safe to send on every call because it is ignored when the session already exists. + +## A09 + +**Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine** + +- Severity: **medium** (BLOCKS first release); claimed by auditor: medium; effort: small; area: protocol; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt:141` +- Note: Same issue as A28. + +**Evidence** + +SessionsTab lists every project's sessions, including ones labelled "on user@host" (sshTarget != null), and `onClick` pushes Route.Terminal for any node. Over SSH, SshScripts.attach runs `tmux -L node-terminal new-session -A -s nt-` on the DESKTOP. Over the relay, host-service calls `attachDetached(nodeId, sinks, { cols, rows })` with no sshRemote/requireRemote; pty-manager.ts:2254-2259 says the relay host 'passes only {cols, rows}', so the spawn is local. Neither path finds the session, so `fresh=true`, and TerminalController:253 offers `claude --resume ` in a local shell. CLAUDE.md states 'A remote node is NEVER spawned locally'. The desktop expects phones to reach those sessions on their own host: the mirror pushes per-project slices to `~/.nodeterm/agent-status-.json` on the SSH host (docs/mobile-usage-inbox.md), and index.ts's ack sweep notes 'a Mac→SSH node's acks land on the REMOTE fs'. For the same nodes, SshScripts.answerApproval checks for `$HOME/.nodeterm/pending/.json` on the desktop, but the managed hook writes it on the SSH host, so the phone prints `gone` and reports ALREADY_HANDLED for a live approval. + +**Proposed fix** + +Until the phone can reach the SSH host itself, disable open/resume for nodes whose project has an sshTarget (say why in the row). On the SSH transport, route approvals for those nodes to the relay verb (the desktop writes over the ControlMaster) or show "open on computer". + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three small corrections, plus a better fix: +> +> - **"Neither path finds the session, so fresh=true" is not always true.** On the relay, `sessionExists` first checks `liveSessionForPersistKey` (pty-manager.ts:2583). If the SSH node is mounted on the desktop at that moment, the answer is "exists", so `fresh=false`. The phone then gets a blank local shell with no resume offer and no indication that anything is wrong. +> - **The orphan session sticks.** After the first open, the orphan `nt-` stays on the local `node-terminal` socket. Later opens attach to it as a warm session. `listNodetermSessions` also lists it, so the phone starts reporting the SSH node as running. +> - **More entry points than line 141.** The same unguarded open exists at SessionsTab.kt:148, BoardTab.kt:172 and InboxTab.kt:81. "End session" over the relay (SessionsTab.kt:219-220) also attaches first, which creates the orphan before destroying it. Over SSH, `sendKeys` quick answers for these nodes hit a local session that does not exist, so tmux exits 1 and the app shows an error. +> +> **Better fix — two layers:** +> 1. **Phone:** block open, resume and key-based quick actions for nodes whose project has an `sshTarget`, and say why ("runs on user@host — open it on the computer"). On the SSH transport, send approvals for those nodes to the relay verb when a relay connection exists, otherwise show "open on computer". +> 2. **Desktop:** in `handleAttach`, refuse `pty.attach` for a node where `workspaceStore.sshProjectIdForNode(nodeId)` is set, instead of letting `attachDetached` create a local session. This applies the existing "a remote node is never spawned locally" invariant to the relay attach path as well. It also protects the iOS relay path, which this repo cannot see. + +## A10 + +**Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings** + +- Severity: **medium** (BLOCKS first release); claimed by auditor: medium; effort: small; area: ci-docs; kind: risk +- Location: `README.md:228` + +**Evidence** + +README.md:227-228 says 'Android — build it from android/ ...; CI also attaches a debug APK to every run of the Android workflow.' android/README.md:37-38 says the same. The app job uploads `android/app/build/outputs/apk/debug/*.apk` (android.yml:77-80). app/build.gradle.kts has no `signingConfigs`, and no debug keystore is committed. android/.gitignore has a `!debug.keystore` exception, but no such file exists and nothing would use it. So each APK is signed with whatever ~/.android/debug.keystore the ephemeral runner has; AGP generates a fresh one when none exists, and runner images are rebuilt regularly. Installing a later run's APK over an earlier one then fails with a signature mismatch (INSTALL_FAILED_UPDATE_INCOMPATIBLE). The only way forward is uninstall. The manifest sets `android:allowBackup="false"` (AndroidManifest.xml:13), so uninstalling irrecoverably drops every paired computer and the Keystore-held relay/SSH identities. Workflow artifacts also require a GitHub login and expire after the default retention period. + +**Proposed fix** + +Commit a debug keystore and wire `signingConfigs.getByName("debug").storeFile` to it (the .gitignore exception suggests this was intended), or publish consistently signed APKs as release assets. Until then, drop the 'CI attaches a debug APK' install advice or warn that updates need an uninstall and re-pair. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause and citations are right. One refinement: the per-run key change comes mainly from each GitHub-hosted job running on a fresh VM with an empty `~/.android` (nothing in android.yml caches or injects a keystore), not from image rebuilds. +> +> The first proposed fix is unsafe as written. Committing `debug.keystore` to this public repo, which the `!debug.keystore` .gitignore exception invites, makes the signing key public. Anyone could then build an APK that installs as an in-place update over users' installs. An update keeps the app UID, so that APK inherits the app's data dir and its AndroidKeyStore key. It could then decrypt the SecureStore secrets, including the SSH private key that paired computers trust in authorized_keys and the relay identity. +> +> Safer options: +> - Sign CI builds with a key held in GitHub Actions secrets (a `signingConfig` fed from env vars, used only in CI), and publish those APKs as release assets. +> - Or, until then, reword README.md:227-228 and android/README.md:37-38. Say CI artifacts are for testing only, that each one needs an uninstall and fresh pairing, and that a locally built APK is the route to keep for updates. A local build is stable because it uses the developer's own `~/.android/debug.keystore`. +> +> Also drop the `!debug.keystore` exception, so nobody commits a public signing key by accident. + +## A11 + +**Tapping an Inbox notification while the app is in the background does not open that computer** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: build; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt:67` +- Note: Same issue as A19. + +**Evidence** + +The notification's PendingIntent carries `putExtra(MainActivity.EXTRA_HOST_ID, host.id)` with `FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP` (InboxNotifier.kt l.97-104). MainActivity is `android:launchMode="singleTask"` (AndroidManifest.xml l.24). So when the activity already exists (the common case: app backgrounded with Home), Android delivers the intent to `onNewIntent`, not `onCreate`. `onNewIntent` only does `super.onNewIntent(intent); takePairLink(intent)` (l.67-70), and takePairLink ignores anything that is not a `nodeterm://pair` VIEW intent. EXTRA_HOST_ID is only read in onCreate (`val openHost = intent?.getStringExtra(EXTRA_HOST_ID)`, l.83), and it is consumed inside `remember { Navigator(...) }`, which never re-runs. + +**Proposed fix** + +In onNewIntent, read EXTRA_HOST_ID into a Compose state, as incomingPairCode already is, and push `Route.Host(hostId, tab = 2)` from a LaunchedEffect keyed on it. Call setIntent(intent) as well. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The diagnosis and line numbers are correct, but the proposed fix is incomplete. +> +> HostScreen.kt:62 creates the tab as `var tab by rememberSaveable { mutableIntStateOf(initialTab) }`, keyed on neither `hostId` nor `initialTab`. AppContent (MainActivity.kt:112-118) renders every `Route.Host` from the same `when` branch. So if the user is already on a Host screen when the notification arrives, pushing `Route.Host(hostId, tab = 2)` recomposes the same HostScreen slot and keeps the old tab value. The Inbox tab would not open. +> +> The fix therefore needs a second part. Either: +> - key the state, e.g. `rememberSaveable(hostId, initialTab) { mutableIntStateOf(initialTab) }`, or +> - wrap each destination in `key(...)`, or +> - rebuild the stack with `nav.replaceAll(Route.Hosts); nav.push(Route.Host(id, 2))`. +> +> The rest of the proposed fix is right: store the extra in a Compose state from `onNewIntent`, call `setIntent(intent)`, and check `graph.hosts.get(id) != null` the same way `onCreate` does. +> +> Minor: `FLAG_ACTIVITY_CLEAR_TOP` has no effect on a singleTask root activity. It is harmless but does not help here. + +## A12 + +**Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt:266` + +**Evidence** + +Android: `val stream = attach(nodeId, 80, 24, sink); try { stream.write(keys) } finally { stream.detach() }`. The Input frame goes out as soon as the attach RESPONSE arrives, and `pty.kill` follows immediately. Desktop host-service.ts: the response goes first (`socket.respond(req.id, true, { streamId, fresh: !existed })`, line 423). Only after an async `captureSnapshot` does it set `stream.sessionId = pty.attachDetached(...)` (line 432), which spawns a brand-new tmux client (relay ptys are never co-attached: pty-manager.ts:3446-3450, 'Detached (relay-served) ptys are deliberately NOT indexed'). An Input that arrives first hits `pty.write(getClientId(), '', data)`. PtyManager.write (pty-manager.ts:3909-3911) finds no session for '' and falls through to `backgroundWriteBySessionId('')`, which matches nothing, so the input is dropped. If the client was spawned in time, the `pty.kill` processed right after it calls releaseClient → `releasePty` → `proc.destroy()` on a tmux client that has only just been spawned, before it is known to have read and forwarded the byte. The interop fixture's fake `write` accepts any sessionId, so this is not covered by tests. The throwaway 80x24 client also becomes tmux's latest client (the generated conf sets no window-size), so the desktop pane briefly resizes as the key lands. + +**Proposed fix** + +Don't write until the stream is live: wait for the first OP.Output after SnapshotEnd, which proves attachDetached ran and the tmux client painted. Then write, and delay the detach briefly (or until the pane echoes). Attach at the phone's normal size, not 80x24. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is right on substance. Corrections and additions: +> +> (a) The kill-after-write loss is worse than "before it is known to have read and forwarded the byte". In node-pty 1.1.0, `write` is an async threadpool `fs.write`, while `destroy()` closes the master straight away (unixTerminal.js:215-225, 296-341). So the byte may never reach the pty at all. Closing the master also hangs up the slave, which likely discards input tmux has not read yet. +> +> (b) The Esc Deny fallback (QuickActions.kt:37) is lost almost every time, not just sometimes. tmux holds a lone ESC for `escape-time 10` ms (pty-manager.ts:299), and the client is killed well inside that window. +> +> (c) In the pre-attach race, `attachDetached` never runs, because `handleKill` drops the stream first and the capture continuation returns at host-service.ts:428. It is a clean drop, not a late write. +> +> Better fix: add a desktop relay verb such as `node.sendKeys {nodeId, keys}`, backed by PtyManager's existing background write / tmux send-keys path. That is the same deterministic route the SSH transport uses, and it needs no throwaway tmux client. Gate it by capability ("not served" means open the session). The PR already adds desktop verbs (`approvals.answer`, `inbox.ack`), so this fits. +> +> If you stay client-only instead: +> - attach at the phone's real size; +> - wait for the first OP.Output after SnapshotEnd (proof that attachDetached ran and the tmux client is painting) before writing; +> - keep the stream open for well over escape-time, or until the pane echoes, before sending `pty.kill`. +> +> Either way, `sendKeys` should report failure instead of Unit so that QuickActions does not return SENT unconditionally. + +## A13 + +**Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: protocol; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:238` + +**Evidence** + +afterAttach types the launch line and then calls `conn.registerNode(...)` while its relay stream is still attached. Desktop side: `appendRemoteNodeNow` broadcasts `IPC.workspaceExternalChange` (workspace-store.ts:1629). The active project reloads silently, the new TerminalNode mounts and calls create(). create() cannot co-attach to the relay's session, because relay ptys are not in `byPersistKey` (pty-manager.ts:3446-3450). So it spawns a painter with `tmuxAttachFlags(false)` = `['-A', '-D']` (pty-manager.ts:751). `-D` detaches every other client, including the relay-served one. That client exits 0, host `onExit` sends `OP.Error {exitCode:0}`, and Stream.accept calls `sink.onExit(0)`. TerminalController turns that into `TermState.Ended("The session ended (exit 0).")`. + +**Proposed fix** + +Treat an exit that comes back while the tmux session still exists (re-check isLive / has-session) as a detach, and re-attach automatically instead of showing Ended. Separately, ask the desktop side to co-attach relay viewers instead of `-D`-kicking them. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The mechanism is right, but the impact is overstated in three ways: +> +> 1. It is recoverable with one tap. The Ended screen has a "Reattach" button (TerminalScreen.kt:89-95) that calls `controller.attach()`. Once the desktop client is attached, that re-attach co-attaches without `-D` and works. The real harm is a false "The session ended (exit 0)." message and a lost view, not a lost session. Medium is borderline; medium-low fits better. +> +> 2. The immediate kick needs the target project to be the ACTIVE tab on the desktop, not merely open. A background project only gets `replaceProject()`, and its node mounts later on a project switch. That switch then kicks the phone if it is still attached. +> +> 3. Line refs: +> - the no-index code is pty-manager.ts:3418 (the `indexKey` line) plus the comment and `set` at 3446-3449; +> - the renderer spawn with undefined sinks is in `spawnNew` around line 2290. +> +> Better fix: fix the root cause on the desktop, which also covers iOS. When PtyManager spawns a renderer client for a persistKey that has a live detached (relay) session, it should attach with `-A` and no `-D`. Either track detached sessions by persistKey, or have the renderer spawn check for a live relay pty, as the comment at pty-manager.ts:740-748 already intends. This does not cover the SSH-transport phone's client, which is not in this process's session table, so the Android-side guard below is still needed for that case. +> +> On Android, as a fallback, treat an exit code of 0 as a possible detach rather than an end. If the node is still in the next snapshot or listing, or a has-session check succeeds, re-attach once automatically. Otherwise show a "Detached — another viewer took over" state instead of "ended". + +## A14 + +**New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: protocol; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt:289` + +**Evidence** + +NewSessionDialog offers `snapshot.openProjects().filter { it.sshTarget == null }`, which includes cwd-less canvases. The desktop registrar only handles folder refs: `const e = this.index?.entries.find((x) => x.id === projectId && x.cwd); if (!e?.cwd) return false` (workspace-store.ts:1607-1608), so the answer is `{registered:false}`. TerminalController.afterAttach wraps the call in `runCatching { conn.registerNode(...) }` and never reads the Boolean. host-service documents a refusal as an answer: 'the phone opened its session either way and just stays unregistered'. + +**Proposed fix** + +Only offer projects that have a cwd, and check `registered`. On false, tell the user and offer to end the session (or keep it reachable from a phone-local list). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two corrections to the impact, and one to the fix. +> +> - **Not wholly invisible on the desktop.** The canvas never shows the session, but the desktop's session-memory panel (RAM pill) lists every `nt-*` session. It labels unowned ones as orphans and offers a kill button. So the tmux session can still be found and ended from the desktop; it is not a completely invisible leak. +> - **Two lines to cite.** The offer is at `SessionsTab.kt:289` (and the FAB gate at `HostScreen.kt:71-72`). The ignored refusal is at `TerminalController.kt:236-239`. +> - **The fix has two parts.** +> 1. Filter the dialog and the FAB gate on `it.cwd != null` as well as `it.sshTarget == null`. +> 2. Read the result of `registerNode` instead of swallowing it. This is still needed after the filter, because a folder project can also be refused: its `project.json` may be unreadable or corrupt, or the account binding may be refused. On `false` or an exception, tell the user the session was not added to the project, and offer to end it or keep it reachable (for example, a phone-local list of unregistered live sessions). +> +> Registering before typing the launch line is not an option: the code comment says registering first would let the desktop cold-mount the node and launch the agent a second time. + +## A15 + +**The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found'** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: protocol; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:253` + +**Evidence** + +On `s.fresh` the phone offers `Launch.resumeCommand(agent, sid)`, which is bare, e.g. `claude --resume `. It ignores `node.accountId` (parsed in ProjectsParser.parseNode) and settings.claudeAccounts[].dir. The desktop's cold restore runs inside a pty spawned with the node's account env (`CLAUDE_CONFIG_DIR` via tmux `-e`, pty-manager accountTmuxEnvArgs) and the node's cwd, and wraps the command in `withPermissionMode`. A relay cold attach creates the session with only `{cols, rows}`: no account env and cwd = $HOME (pty-manager.ts:2254-2259). A managed account's transcript lives under `/projects/…`, so running under the default `~/.claude` finds nothing. + +**Proposed fix** + +Build the resume line the way launchCommand builds a first launch: `cd '' && CLAUDE_CONFIG_DIR='' claude --resume `, plus the permission-mode flag as the desktop's cold restore would add it. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Mostly accurate. Four refinements: +> +> 1. **Impact on the relay is probably wider than managed accounts.** Relay cold attach also drops the cwd, and Claude finds a transcript at `/projects//.jsonl` (the CLAUDE.md measurement). So `claude --resume ` typed into a relay-recreated pane in $HOME likely fails for system-account Claude nodes too, not only managed ones. That part is inferred from the CLAUDE.md note, not measured here. On direct SSH the cwd is already supplied through `new-session -c`, so there only the account (and permission mode) is missing. +> +> 2. **The fix belongs in `Launch`.** Add a resume builder that shares `launchCommand`'s prefix logic: `cd '' &&` gated on `SAFE_DIR`, then `CLAUDE_CONFIG_DIR=''` only for CLAUDE and only when `node.accountId` matches a `settings.claudeAccounts` entry. Call it from TerminalController.kt:253 with `node?.accountId` and `node?.cwd ?: project cwd`. +> - The cwd must be absolute. Portable `./` cwds are only resolved on the SSH listing path, in `SshHostConnection`. +> - Add the same Claude-only permission-mode flag. +> +> 3. **It remains a partial fix.** It cannot reproduce the desktop's `AUTH_ENV_STRIP` removal of an inherited `ANTHROPIC_API_KEY`. The relay-created pane also still gets no hook env, so status reporting from that pane stays dark. +> +> 4. **Codex is not fixable from the phone.** Managed Codex accounts (`CODEX_HOME`) are dropped the same way, but the mirror does not advertise Codex account homes. + +## A16 + +**The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: protocol; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt:59` + +**Evidence** + +Android: `val mode = settings?.claudePermissionMode?.takeIf { it in PERMISSION_MODES } ?: "manual"`, i.e. the global mirror value only. The desktop resolves `resolvePermissionMode(project, settings)`, 'the project override, else the global setting' (shared/agents/config.ts:754). `defaultPermissionMode` is carried through project.json into the assembled workspace that `projects.list` serves (workspace-files.ts:377/540), and so is the machine-local `defaultAccountId`. ProjectsParser.parseProject reads neither, and NewSessionDialog defaults the account to System. + +**Proposed fix** + +Parse `defaultPermissionMode` and `defaultAccountId` in ProjectInfo, and resolve project → global exactly as resolvePermissionMode does (keeping the claude-only autoSupported degrade). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct. Three points to make it more precise: +> 1. The loosening to `auto` happens only when the mirror's `autoSupported` is true. Otherwise the global `auto` degrades to the bare command, which is manual, so a stricter project is not loosened in that case. It does still happen when the global mode is `acceptEdits` or `bypassPermissions`, because those modes are emitted without any version gate. The reverse direction also exists: a project override that is looser than the global mode is ignored as well. That is a parity gap, not a security issue. +> 2. More file:line locations for the fix: +> - SessionsTab.kt:293 (accountId initialised to null) +> - SessionsTab.kt:330 (launchCommand is not given the project) +> - ProjectsParser.kt:52-61 (neither field is parsed) +> - Models.kt:31-42 (ProjectInfo has neither field) +> 3. Fix: +> - Parse `defaultPermissionMode` and `defaultAccountId` into ProjectInfo. +> - Pass the project, or the resolved mode, into `launchCommand`, and resolve project → global → default using the same `isPermissionMode` re-validation the desktop uses. Keep the claude-only `autoSupported` degrade after resolving. +> - Preselect `project.defaultAccountId` in NewSessionDialog, but only when it matches an entry in the mirror's `claudeAccounts`. That mirrors how the desktop validates an account id before stamping it on a node; a stale id should fall back to System. + +## A17 + +**The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: protocol; kind: risk +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:124` +- Note: Same issue as A05. + +**Evidence** + +InboxWorker runs `withTimeoutOrNull(45_000) { session.refreshNow() }` for every paired host. refreshNow calls ensureConnected, which calls RelayConnector.connect and then polls `projects.list` while the host answers 'Awaiting host approval.'. On the desktop, standing-host.ts onPeerReady sends `remoteHostPeerPending` for any unpinned key, and the consent record outlives the socket by 120 s (#819). A reject pins nothing, so the next run prompts again. + +**Proposed fix** + +Skip relay connects from the worker until this phone's key has been approved once in the foreground (record that locally after the first successful relay listing). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The cited line (InboxNotifier.kt:124) and the mechanism are right. Two refinements: +> +> 1. The trigger is broader than "never approved or rejected" phones. On a macOS/Linux computer, every phone that paired on the LAN and has only ever connected over SSH still has an unpinned box key, because pairing-service does not pin it; only standing-host's SAS approval does. That includes phones that picked up a relay block through the QR or through adoptRelayIfAdvertised (ConnectionManager.kt:165-187). The first time such a phone is off the LAN, the SSH attempt fails (4 s AUTO timeout) and the background worker dials the relay. The desktop then gets an unexpected SAS dialog whose code nobody can see. For this app that is the common first-off-LAN path, not an edge case. +> +> 2. The fix has to be "don't dial the relay at all from the worker until approved". An early abort would not help: the desktop raises the dialog in onPeerReady as soon as the handshake completes, before any request, and keeps it for 120 s after close. Concretely: +> - Persist a per-host `relayApproved` flag in HostStore when RelayConnector.connect returns (a successful projects.list means the key is pinned). +> - Skip the relay leg in background runs until that flag is set, e.g. by passing an `allowRelayApproval=false` / background mode into connectLocked. +> - If the host later answers AWAITING_APPROVAL_MESSAGE (the pin was revoked), clear the flag. +> +> Side note found while verifying: connectLocked's `catch (e: Exception)` at ConnectionManager.kt:123 also catches the worker's timeout CancellationException (it is an Exception subclass) and rewrites it as a HostException. It is harmless here, because withTimeoutOrNull still returns null, but cancellation is not propagated cleanly. + +## A18 + +**No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt:58` + +**Evidence** + +- TerminalScreen.kt:57-63 and HostScreen.kt:65-68 call `session.startWatching()` (and the terminal attaches) inside DisposableEffect. DisposableEffect disposes only when the composable leaves composition, not on ON_STOP. +- Nothing under android/app references Lifecycle, onPause/onResume or WebView.onPause (grep). +- ConnectionManager.kt:211-222: pollJob runs on graph.scope, looping `refreshNow(); delay(POLL_MS)` while watchers > 0. Its own KDoc calls this "the iOS foreground cadence". +- Desktop side: host-service.ts:397-402 calls `remoteViewer?.attached(nodeId)` for the relay stream's whole lifetime. index.ts:3970-3978 says this set makes "Eco's isNodeWatched stop hibernating a session someone is watching from a phone". On the session host a relay sink is bounding (pty-manager.ts:3701 `const bounding = session.sizes.has(null) && !session.sinkAdapts`). +- InboxNotifier.kt:127 never disconnects a session whose isWatched is still true. + +**Proposed fix** + +Tie watching to the Activity lifecycle: use a LifecycleEventObserver on LocalLifecycleOwner, or repeatOnLifecycle(STARTED). On ON_STOP call stopWatching, detach the terminal stream and call webView.onPause(). On ON_START watch again and reattach. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. **"Indefinitely / until the OS kills the process" is overstated.** +> - When the screen is off and no wakelock is held, the CPU sleeps. Doze and app-standby later cut network access. +> - On Android 14+ (and some 12/13 builds), the cached-app freezer stops a process once it drops to the cached state. +> - The accurate claim: the loop keeps running for as long as the process is not frozen or asleep. That covers the whole Home or app-switch window, and screen-lock until the device sleeps. While the process is alive, the phone's keepalives stop the desktop from dropping the stream, so an agent streaming output keeps waking the radio. +> - The desktop effects (Eco shielding, the session-host size ceiling) are real, and they apply specifically to the relay stream: `remoteViewer` exists only in `host-service`. On SSH, a tmux client simply stays attached. +> 2. **The terminal attach is not inside the `DisposableEffect`.** It is triggered by the JS `Bridge.onResize` in `TerminalController.kt`; only `startWatching` and `dispose` are in the effect. The conclusion is unchanged. +> 3. **Better fix, using the dependency already declared.** +> - `lifecycle-runtime-compose` 2.9.1 provides `LifecycleStartEffect`. Use `LifecycleStartEffect(hostId) { session.startWatching(); onStopOrDispose { session.stopWatching() } }` in `HostScreen` and `TerminalScreen`. +> - In `TerminalController`, add a stop/start pair. On ON_STOP: detach the stream and null `stream`, keeping the WebView, then call `webView.onPause()`. On ON_START: call `webView.onResume()` and re-`attach()`. +> - Optionally, `InboxWorker` could also disconnect when `ProcessLifecycleOwner` reports the app is not STARTED. + +## A19 + +**Tapping an inbox notification while the activity is alive ignores the target computer** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt:67` +- Note: Same issue as A11. + +**Evidence** + +InboxNotifier.kt:97-104 builds the PendingIntent as `Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_HOST_ID, host.id).addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP)`. The activity is `android:launchMode="singleTask"` (AndroidManifest.xml:24), so a live instance receives the intent in onNewIntent. MainActivity.kt:67-70 is `override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); takePairLink(intent) }`, and takePairLink (:60-65) only reads a nodeterm://pair data URI. EXTRA_HOST_ID is read once, in onCreate (:83 `val openHost = intent?.getStringExtra(EXTRA_HOST_ID)`), into the initial Navigator (:89). + +**Proposed fix** + +In onNewIntent, call setIntent(intent), read EXTRA_HOST_ID into a mutableState (as incomingPairCode does), and have a LaunchedEffect push Route.Host(hostId, tab = 2). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line citations and root cause are correct. The proposed fix has one gap. HostScreen keeps its tab in `var tab by rememberSaveable { mutableIntStateOf(initialTab) }` (HostScreen.kt:62), and AppContent renders every Route.Host from the same `when` branch (MainActivity.kt:116). So pushing Route.Host(hostId, tab = 2) while the current route is already a Route.Host keeps the existing saved tab and ignores `initialTab`. That covers two cases: the user is on that computer's Sessions or Board tab, or they are on a different computer's host screen, which also reuses the slot and its state. The fix needs three parts: +> 1. In `onNewIntent`, read EXTRA_HOST_ID into a mutableState, as `incomingPairCode` does (and optionally call `setIntent`). +> 2. In a LaunchedEffect, validate the id with `graph.hosts.get(id)` and push or replace to Route.Host(id, 2). +> 3. Make HostScreen honour the new route: wrap the branch in `key(r) { … }` or use `rememberSaveable(hostId, initialTab)`. Otherwise the navigation does nothing when a host screen is already showing. + +## A20 + +**Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt:94` + +**Evidence** + +- ConnectionManager.kt:79-96: `val ssh = withContext(Dispatchers.IO) { SshHostConnection.connect(...) }` ... `} catch (e: Exception) { errors += "On your network: ..." }`. CancellationException is an Exception, so this swallows it. withContext's prompt-cancellation guarantee then discards the SshHostConnection that the blocking connect returned, and nothing closes it (keepalive is enabled at SshHostConnection.kt:353). +- The catch at :123 swallows cancellation on the relay leg too, and :132-134 then set `ConnState.Failed("...was cancelled...")`. +- The cancellation comes from stopWatching (:225-231, `pollJob?.cancel()` when watchers reaches 0). +- watchers reaches 0 on every Host→Terminal push as well: Compose dispatches onForgotten before onRemembered (checked in the runtime-desktop 1.8.2 RememberEventDispatcher.dispatchRememberObservers bytecode). So HostScreen.kt:67 `stopWatching()` runs before TerminalScreen.kt:58 `startWatching()`. + +**Proposed fix** + +Rethrow CancellationException in connectLocked before the generic catches. Make the SSH dial close its result when the caller was cancelled, for example by running it as an async in graph.scope and awaiting it, closing the connection if the awaiting coroutine was cancelled. Also consider keeping one watch token across the Host→Terminal hand-off so the poll job is not cancelled on every navigation. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct on mechanism and cause. Refinements: +> +> (a) The leak site is ConnectionManager.kt:79-85: the discarded result, with `adopt` never reached. :94 is where the cancellation is swallowed. The relay leg swallows it again at :123, and :132-134 set `ConnState.Failed` and throw. +> +> (b) The wrong "offline" state is not always brief. When the user backs out of HostScreen, nothing else is watching, so no new pollJob replaces it. `ConnState.Failed` stays until the host is reopened: HostsScreen.kt:168/175 shows " — offline" in the attention colour, and `_lastError` holds "StandaloneCoroutine was cancelled". It is brief only on the Host→Terminal hand-off, where TerminalScreen's new pollJob reconnects. That reconnect is a second dial, while the first connection stays leaked. +> +> (c) There are more cancellation sources than the poll job. BoardTab.kt:98-99 and InboxTab.kt:83-84 call `session.ensureConnected()` inside a `rememberCoroutineScope()` launch. That scope is cancelled when the tab or screen leaves composition, which triggers the same leak if that coroutine holds the mutex during the dial. +> +> (d) Fix. Rethrow CancellationException before both generic catches in connectLocked. Also close a dial that finished after cancellation. Two ways: +> - Record the result inside the IO block (`.also { dialed = it }`) and, in a `catch (e: CancellationException) { dialed?.close(); throw e }`, close it. withContext does not resume the caller until the blocking block has returned, so `dialed` is set by then. +> - Run the dial as `graph.scope.async(Dispatchers.IO)`. If the await is cancelled, close the result in `invokeOnCompletion`. +> +> Keeping one watch token across the Host→Terminal hand-off, or delaying the cancel in `stopWatching`, would avoid needlessly cancelling the pollJob. It does not fix the back-out case or the rememberCoroutineScope callers, so the close-on-cancel change is the real fix. +> +> (e) Severity medium is reasonable. The leak needs the cancel to land during a dial that then succeeds. Each occurrence leaves one live SSH session to the desktop for the life of the process. + +## A21 + +**A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt:89` + +**Evidence** + +- MainActivity.kt:72-82 requests POST_NOTIFICATIONS once, in onCreate, with an empty callback commented `/* the Settings switch reflects it */`. +- SettingsScreen.kt:51 is `var notify by remember { mutableStateOf(graph.hosts.notificationsEnabled) }`. The switch at :89-93 only writes the pref and calls InboxNotifier.schedule; it never checks or requests the runtime permission. +- InboxNotifier.kt:74-76 returns 0 silently when the permission is not granted. Meanwhile the worker still dials every paired computer every 15 minutes. + +**Proposed fix** + +Show the switch as on only when `notificationsEnabled && permission granted`. When the user turns it on, or opens Settings while permission is denied, launch the permission request. If it is permanently denied, deep-link to Settings.ACTION_APP_NOTIFICATION_SETTINGS. Don't run the worker without the permission. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The permission is not requested "once" or "never re-requested". MainActivity.kt:78-81 runs in every onCreate: every cold start, and every activity recreation such as rotation. It fires whenever the pref is on and the permission is not granted. What actually happens: +> 1. On Android 13+ the system stops showing the dialog after the user denies it twice. From then on `launch()` fails silently, and the app has no path to recover. +> 2. Flipping the Settings switch off and on never requests the permission, and nothing opens the system notification settings. +> 3. The launch-time prompt can repeat on every rotation or cold start until that system cutoff. +> +> The same "switch says On, nothing arrives" state also happens on API 26-32 when the user blocks notifications or a channel in system settings. There `nm.notify` is a silent no-op and the switch still reads On, so the fix should check `NotificationManagerCompat.areNotificationsEnabled()` (and the channel importance), not only the runtime permission. +> +> Side effect: `announce` returns before `markSeen` when the permission is missing. After a later grant, every unresolved event from the last 6 hours is posted at once (up to 5 per host, via `fresh.takeLast(5)`). +> +> Better fix: +> - Derive the displayed state as `pref && areNotificationsEnabled()`, re-checked on resume. +> - Request the permission from the switch, and after pairing the first computer rather than in onCreate. +> - Fall back to `Settings.ACTION_APP_NOTIFICATION_SETTINGS` when `shouldShowRequestPermissionRationale` is false after a denial. +> - Skip or cancel the WorkManager job while notifications are disabled. + +## A22 + +**The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt:87` + +**Evidence** + +- :87 `val nav = remember { Navigator(Route.Hosts)... }` uses plain remember, not rememberSaveable, and Route has no Saver. +- AndroidManifest.xml:26 configChanges covers orientation/screenSize/uiMode but not density, fontScale, locale or layoutDirection, so those changes (and process death) recreate the activity. +- onCreate reads `intent` again each time (:83-84), and onNewIntent never calls setIntent. + +**Proposed fix** + +Save the route stack with rememberSaveable and a listSaver. Only apply the intent's extras/data when savedInstanceState == null, or clear them once consumed. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is accurate. Two refinements: +> +> (a) Re-opening the Pair screen does not re-submit the used token. PairScreen only pre-fills the parsed payload, and the user must tap "Pair" again, which would then fail on the server. So the impact is a stale screen and a confusing error, not a duplicate pairing. +> +> (b) A related, more impactful bug sits on the same lines. With launchMode="singleTask" (Manifest:24), a notification tap while the activity already exists is delivered to onNewIntent. That PendingIntent is built with NEW_TASK|CLEAR_TOP in InboxNotifier.kt:99-103. onNewIntent (MainActivity.kt:67-70) only calls takePairLink, which returns early because the intent has no data, so EXTRA_HOST_ID is ignored. Tapping a "Needs you" or "Completed" notification while the app is alive in the background just brings it forward on whatever screen it was on, without opening that host's inbox. Only a cold start honours the extra (:83, :89). +> +> Fix for both: +> - Save the stack with rememberSaveable and a listSaver, or make Route @Parcelize. +> - In onCreate, consume the intent extras/data only when savedInstanceState == null, or clear them after use (setIntent(Intent(intent).setData(null).removeExtra(...))). +> - In onNewIntent, call setIntent(intent) and route EXTRA_HOST_ID into the same state-holder mechanism as incomingPairCode, so the Compose side pushes Route.Host(hostId, tab = 2). + +## A23 + +**Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: security; kind: risk +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:124` +- Note: Same issue as A05. + +**Evidence** + +InboxWorker.doWork calls `withTimeoutOrNull(45_000) { session.refreshNow() }` for every paired host (line 124). That goes to ensureConnected → connectLocked, which in Auto falls to `RelayConnector.connect(...)` (ConnectionManager.kt:99-122). There is no 'already approved' gate. For an unpinned phone key, the standing host raises the approval prompt on every connect: standing-host.ts:250-261 `if (pub && isPinned(store, pub)) { s.approve() ... }` else `send(IPC.remoteHostPeerPending, { sas: s.sas(), ... })`, and the consent record lives 120 s after the socket closes (#819). On the phone, the AwaitingApproval SAS only lands in `_state` (ConnectionManager.kt:114), which nothing renders while no screen is open. This is the common first relay use: a Mac user who paired and used SSH at home first reaches the relay when the phone leaves the LAN, typically from this worker. + +**Proposed fix** + +Track a per-host 'relay-approved' flag, set after the first successful RelayConnector.connect in the foreground. In background refreshes, refuse to proceed on AWAITING_APPROVAL_MESSAGE: close immediately, and instead post a notification such as 'Open nodeterm to approve this phone on '. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two corrections. +> +> **1. The 120 s window is timed from the prompt, not from the socket close.** `PHONE_APPROVAL_TTL_MS` starts in `approvals.add()` (phone-approval.ts:38-45), which runs when the prompt is raised. It is not 120 s "after the socket closes": the desktop dialog lives about 120 s from when it appears, and the phone's socket closes at about 45 s. +> +> **2. The proposed fix does not work.** "Refuse to proceed on AWAITING_APPROVAL_MESSAGE: close immediately" is too late. The desktop raises the dialog in `onPeerReady`, as soon as the E2EE handshake completes (standing-host.ts:233-261), before the phone sends any request. So the phone first sees AWAITING_APPROVAL_MESSAGE only after the prompt is already on screen, and closing then still leaves a live dialog for 120 s. +> +> **A fix that works:** +> - **Skip the relay leg in the background.** Persist a per-host "relay approved" flag and set it after the first successful relay `listProjects` (RelayConnector.kt:72-74). In background (worker) refreshes, do not dial the relay at all while the flag is unset. Post a notification instead, such as "Open nodeterm to approve this phone on ". +> - **Show the code on the phone at any time.** The SAS is derived from the static ECDH `baseKey` (relay-socket.ts:168-170, 236, 602-603 and e2ee.ts `sasFromSharedKey`), so it is stable for a given phone key and host key. The app can compute it from `graph.boxKeys` plus `host.relayHostKeyB64` without connecting. It could then show the code in that notification or on the host screen, so a desktop prompt can always be verified. Today the phone only shows the code during a foreground relay connect, and while the phone is on the LAN the foreground connect goes over SSH and never shows it. + +## A24 + +**SecureStore treats ANY decrypt error as 'absent', so getOrCreate32 permanently overwrites the phone's identity** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: security; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt:60` + +**Evidence** + +getBytes wraps key lookup, `cipher.init` and `doFinal` in `catch (_: Exception) { null }` (lines 55-64). That catches KeyStoreException, ProviderException, IllegalStateException and other transient keystore failures, not only 'key wiped / wrong device'. getOrCreate32 (lines 77-82) then writes fresh random bytes over the stored value: `getBytes(name)?.takeIf { it.size == 32 }?.let { return it }; val fresh = ...; putBytes(name, fresh)`. AppGraph uses it for both BOX_SECRET and SSH_SEED (NodetermApp.kt:144-147). + +**Proposed fix** + +Regenerate only on positive evidence: the alias is absent (a new key was just generated), or decrypt fails with AEADBadTagException / KeyPermanentlyInvalidatedException. For any other exception, rethrow or retry and never overwrite. Consider `commit()` for the first write of the identity secrets. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. Wrong location: the AppGraph uses are at NodetermApp.kt:25 and NodetermApp.kt:28, not lines 144-147 (the file has only 51 lines). +> +> 2. The impact is overstated. The desktop does not "stop accepting" the phone. A relay connect with a new box key reaches standing-host.ts onPeerReady (lines 249-262). The key is not pinned, so the desktop raises a new SAS approval dialog: `approvals.add(pub)` + `remoteHostPeerPending`, and the phone shows AwaitingApproval (ConnectionManager.kt:111-113). The relay device token is not tied to the phone's key; it is minted from deviceId, hostDeviceId, hostPublicKeyB64 and label. So the real cost is: +> - every paired computer has to re-approve the phone, which needs someone at the desk, and that is the one thing a phone user usually cannot do; +> - direct SSH fails authentication until the phone is re-paired, because authorized_keys still holds the old public key. +> In AUTO route, ConnectionManager.kt:75-95 catches the SSH failure and falls back to the relay; SSH_ONLY hosts are simply broken. The stale authorized_keys lines are harmless leftovers: the old seed has been overwritten, so nothing can use them. +> +> 3. The trigger is narrower than implied. Kotlin's `lazy` does not cache a thrown exception. So if `key` fails to initialise in both `getBytes` and the `putBytes` that follows it, `putBytes` throws out of `getOrCreate32`, the lazy `boxKeys`/`sshIdentity` throws, and the result is a failed connection with no overwrite. An overwrite needs one of two things: +> - (a) a failure specific to decryption (in `init` or `doFinal`) while encryption, a moment later in the same synchronized call, succeeds; or +> - (b) `ks.getKey(ALIAS, null)` briefly returning null. +> Case (b) is worse. Line 31 then calls `KeyGenerator.generateKey()` under the same alias, which replaces the Keystore key itself, so every stored secret becomes undecryptable, the relay tokens included. How often Android Keystore actually does either was not measured here. Without that, severity is medium at most, arguably low. +> +> 4. The fix needs adjusting. KeyPermanentlyInvalidatedException cannot happen here, because the key has no user-authentication requirement (no `setUserAuthenticationRequired` in lines 34-38). The positive evidence of loss is: +> - the alias was absent when the key was created (check `ks.containsAlias(ALIAS)` explicitly, and generate only in that case); +> - AEADBadTagException on `doFinal`; +> - a malformed blob: IllegalArgumentException from Base64, or a blob shorter than IV + tag. +> Every other exception should propagate, like the desktop's PeerKeyLockedError, and never lead to a write. `getBytes` should return a three-way result (absent / value / unreadable-now) instead of `ByteArray?`. The relay-token readers (ConnectionManager.kt:101, HostsScreen.kt:121) have the same null-on-error behaviour. For them it only produces a misleading "Remote access isn't set up" message and deletes nothing. + +## A25 + +**No real push notifications: 15-minute background polling, no notification actions, no Live-Activity equivalent, and the desktop's phone-push switches are ignored** + +- Severity: **medium**; claimed by auditor: high; effort: medium; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:64` +- Note: Related: A73. + +**Evidence** + +iOS gets server-sent pushes that wake the app: src/core/push-notify.ts:1-8 ("Desktop → paired-phone APNs push … a paired iPhone should get a real APNs push even with the app backgrounded/killed"). Those pushes carry actions: push-notify.ts:57-58 ("The backend renders them as numbered notification actions"). They also drive Live Activities: push-notify.ts:455 ("`POST {apiBase}/v1/push/live-update`, feeding iOS Live Activities"). The user controls them per host and per kind: src/shared/types.ts:1770-1786 (`mobilePushNeedsYou`, `mobilePushDone`, `mobileLiveActivities`, `mobilePushPresenceAware` "Hold phone ALERTS while you're actively at this computer"), and push-notify.ts:92-97 describes a per-host mute (#435). Over plain SSH, iOS drops a grant at `~/.nodeterm/push-grants/.grant` (src/core/remote-push-grants.ts:1-12). Android only polls: `PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)` (InboxNotifier.kt:64). `build()` (InboxNotifier.kt:105-116) creates a plain notification with no `addAction` for Approve/Deny/options, and its tap opens the computer (`putExtra(MainActivity.EXTRA_HOST_ID…)`, :101), not the session. `announce()` posts every unresolved kind with one global switch (`graph.hosts.notificationsEnabled`), so the desktop's Needs-you/Done toggles, presence deferral and per-host mute do not apply. + +**Proposed fix** + +Add an FCM leg to the backend's `/v1/push/notify` and `/v1/push/live-update`, sharing the existing host-identity and grant auth. Then register an FCM token and write a push grant from the phone. Until that exists: add notification actions (Approve/Deny through `QuickActions`, numbered option actions), deep-link the tap to the node's terminal, and honor the mirror's push preferences if they are published. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Downgrade to medium and restate it as three app-side gaps plus one backend dependency that is already documented: +> (a) Backend (documented at InboxNotifier.kt:31-39, android/README.md:52-57, docs/android.md:90-91): with no FCM leg in `/v1/push/notify` and `/v1/push/live-update`, the app can only poll with WorkManager every 15 minutes, and there is no Live Activity equivalent. +> (b) InboxNotifier.kt:105-114: no notification actions. Add Approve/Deny for APPROVAL and numbered options for QUESTION, handled by a BroadcastReceiver that calls the existing `QuickActions.answerApproval` / `answerQuestion`. +> (c) MainActivity.kt:67-70 and :83-90: with `launchMode="singleTask"`, a tap while the app is alive goes to `onNewIntent`. That only handles the pair link, so the notification's `EXTRA_HOST_ID` is dropped and nothing navigates. Handle it in `onNewIntent`, and add the node id so the tap can open `Route.Terminal` or the Inbox entry. +> (d) Only one global switch (HostStore.kt:83), with no per-host mute. +> Drop the claim that desktop toggles are ignored. `mobilePushNeedsYou`, `mobilePushDone` and `mobilePushPresenceAware` are not in `MirrorSettings` (agent-status-mirror.ts:173-197), so no phone can read them. They only gate the desktop's own APNs send. Android already has separate attention and done channels (InboxNotifier.kt:41-55) for per-kind control. + +> Mostly correct, with these fixes: +> +> **Severity: medium, not high.** The gap is documented (InboxNotifier.kt:31-39, android/README.md:54-57, docs/android.md:90-91), and its root cause is that the backend's `/v1/push/*` is APNs-only. That backend is outside this repo. +> +> **The deep-link claim is wrong; replace it with the real bug.** +> - The tap does go to the Inbox tab: `Route.Host(openHost, tab = 2)` (MainActivity.kt:88), and tab 2 is Inbox (HostScreen.kt:112). +> - The actual defect: MainActivity is `singleTask` (AndroidManifest.xml:24), and `onNewIntent` (MainActivity.kt:67-70) handles only pair links. +> - `EXTRA_HOST_ID` is read only in `onCreate` (MainActivity.kt:83). +> - So with the app task alive, a notification tap does not navigate at all. +> - Fix: handle `EXTRA_HOST_ID` in `onNewIntent`, e.g. through a state flow the navigator observes. Also consider adding a node id so the tap can open that session directly. +> +> **Desktop toggles: the proposed fix won't work as written.** The toggles are not published: `MirrorSettings` (src/core/agent-status-mirror.ts:173-197) has no push fields, so "honor them if published" is not possible today. +> - Doing it needs a desktop change: add the needs-you/done/presence flags to `MirrorSettings`. +> - Android already offers per-kind muting through its separate `attention`/`done` channels (InboxNotifier.kt:48-55). +> +> **Live Activities:** not an independent gap. The Android equivalent (an ongoing notification) would also need FCM or a foreground service. +> +> **Fixable in-app now:** +> - Add `addAction` Approve/Deny and numbered options in `InboxNotifier.build`, handled by a BroadcastReceiver or worker that calls the existing `QuickActions` (QuickActions.kt:23-48). Only claude approvals can be answered this way (QuickActions.kt:35-37); others return OPEN_SESSION. +> - Fix the `onNewIntent` deep link. + +## A26 + +**New session and board edits are unavailable on the LAN (direct-SSH) connection that Auto picks first; iOS does both over SSH** + +- Severity: **medium**; claimed by auditor: high; effort: medium; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt:71` + +**Evidence** + +iOS registers phone-started sessions over direct SSH: src/core/project-node-append.ts:2-3 ("the phone's twin of this logic lives in nodeterm-ios ProjectNodeRegistrar and writes over direct SSH") and :23 ("the DIRECT-SSH registration path has always written it"). It also moves cards and edits labels over SSH: src/main/index.ts:3940 ("`setCardColumn` moves one card. The phone could already do this over direct SSH") and CLAUDE.md:4784 ("The iOS direct-SSH path has a Swift twin of the transform for projects on the host it dials"). Android: `val canCreate = (state as? ConnState.Connected)?.kind == TransportKind.RELAY && …` (HostScreen.kt:71), so the New-session FAB is hidden on SSH. SshHostConnection sets `HostCapabilities(boardWrites = false, git = false, nodeActions = false, registerNode = false, …)` (SshHostConnection.kt:61-63), and `registerNode`/`setCardColumn`/`editCardLabels` are `relayOnly(...)` (:254-258). BoardTab then shows "Read-only here — board edits need the relay connection." (BoardTab.kt:127-129). The `Auto` route tries SSH first and keeps it when it works (`if (route != RoutePreference.RELAY_ONLY && host.sshAvailable)`, ConnectionManager.kt:76), and the app never opens the relay for a relay-only verb even when the computer has a relay leg. + +**Proposed fix** + +When connected over SSH, lazily open the relay leg (if the computer has one) for `projects.registerNode` / `ensureBoard` / `setCardColumn` / `editCardLabels` / `node.*`. Or implement the SSH writes the way iOS does, but stream the file over stdin rather than argv so it avoids MAX_ARG_STRLEN. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Real, but the claim is overstated in four places, and I would rate it medium rather than high. +> +> 1. **Documented, deliberate decision.** docs/android.md:95-98 says: "Direct SSH is POSIX-only by design (like iOS): board writes, node actions and new sessions need the relay. iOS writes `project.json` over SSH for some of these; Android deliberately does not." android/README.md:20-22 also marks these as relay-only / read-only on SSH. Neither doc considers the finding's first fix, which is to open the relay just for these verbs. That fix is still valid. +> +> 2. **A user-facing workaround exists.** Settings → "How to reach each computer" → "Only through the relay" (SettingsScreen.kt:100-111) forces the relay route. New session and board writes then work on the LAN. The real defect is that the default Auto route hides them, and the missing FAB comes with no explanation (the board tab at least shows a hint). +> +> 3. **"A phone paired while remote access was off can never do either" is too strong.** If the user later turns on remote access, `adoptRelayIfAdvertised` (ConnectionManager.kt:165-187) mints a relay token over SSH. From then on, "Only through the relay", or being off the LAN, enables both features. The phone stays stuck only while remote access remains off. +> +> 4. **iOS's SSH parity is narrower than implied.** src/main/index.ts:3940-3942 says the iOS direct-SSH card move works "only for a project whose folder is on THIS machine and only while the whole file still fits in one argv string". CLAUDE.md:4789 measured this repo's own project.json at 114,695 bytes, about 15 KB under the 128 KB MAX_ARG_STRLEN ceiling. So the gap covers local folder projects under that size, not every project. +> +> **Better fix.** Keep SSH as the primary transport. For `registerNode`, `ensureBoard`, `setCardColumn`, `editCardLabels` and `node.*`, open the relay leg on demand when `host.relay` and a token exist. Where no relay leg exists, show why New session is unavailable instead of silently hiding the FAB. + +> Four corrections to the finding: +> +> 1. **Severity.** I would rate this medium, not high. It is a documented, deliberate gap (docs/android.md:95-98). A user whose computer has remote access on can work around it by setting the route to "Only through the relay" (SettingsScreen.kt:104). +> +> 2. **"Can never" is overstated.** A phone paired while remote access was off is not stuck forever. Late adoption (ConnectionManager.kt:165-187) mints a relay token from `~/.nodeterm/relay.json` once remote access is turned on. After that, relay-only routing or being off the LAN enables these features. Only while remote access stays off does the phone lose them for good. In that case iOS still has them over SSH. +> +> 3. **Two UX problems add to it.** +> - The FAB just disappears on SSH, with no reason shown. +> - The relayOnly error text (SshHostConnection.kt:261), "turn on remote access in nodeterm → Settings → Phone", is wrong when remote access is already on. The real problem is that the phone picked the SSH leg. +> +> 4. **Better fix.** In HostSession, when a relay-only verb is needed while `conn` is SSH and `host.relay` plus a stored relay token exist (often already minted by `adoptRelayIfAdvertised`), open a secondary relay connection lazily for `registerNode`, `ensureBoard`, `setCardColumn`, `editCardLabels` and `node.*`. Also show the FAB and board controls disabled with the reason, not hidden. The fallback is an SSH write that streams over stdin to a temp file, then does an atomic mv. That would also work with remote access off, but it must re-implement the kanban and node-append transforms, and it cannot reach SSH-project refs. + +## A27 + +**Cannot connect straight to a Linux dev host or a headless Server Edition (iOS's "phone SSHes into the host" setup)** + +- Severity: **medium**; claimed by auditor: high; effort: large; area: parity; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:38` + +**Evidence** + +iOS reaches plain SSH hosts on its own: src/core/remote-push-grants.ts:7 ("phone --SSH--> Linux dev host <--SSH-- macOS nodeterm", called "the common desktop setup"). src/main/remote-ssh/remote-status-push.ts:5-9 says the per-project slice `~/.nodeterm/agent-status-.json` exists because "a phone browsing the host directly sees live tmux sessions but no agent states". docs/SERVER.md:81 describes a headless host that "gives a phone SSHing into it full push / Live-Activity coverage", and :142 says "the phone offers this per connection (it installs under whichever user its SSH session logs in as)". The mirror's `server` block is "surfaced to the phone so it can show the installed version / commit" (agent-status-mirror.ts:156-161). Android can only add a computer by scanning a desktop QR (PairScreen.kt; the only entry points are Scan / paste code). Its SSH browse looks for userData only in `~/Library/Application Support/nodeterm` and `~/.config/nodeterm` (SshScripts.kt:38), not the Server Edition's `~/.nodeterm-server` (src/server/config.ts:108). It lists only the `-L node-terminal` socket (SshScripts.kt:56), never `nodeterm-rmt` (Models.kt:228: "never the phone's target"), and never reads `agent-status-.json`. `serverVersion` is parsed (ProjectsParser.kt:138) but never shown. + +**Proposed fix** + +Add a manual "Add SSH server" flow (host, user, port; install the phone's Ed25519 key). Extend the SSH browse to find `~/.nodeterm-server`, list the `nodeterm-rmt` socket, and read `~/.nodeterm/agent-status-*.json` slices, treating a stale `updatedAt` as no data. Show the `server` block (version, commit, installedAt), and offer the install-server.sh one-liner per connection. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The core claim stands, but three details need adjusting. +> +> 1. **Severity should be medium, not high.** The headline Android flow is pairing with a desktop, and that works. The gap is complete only for SSH-only users and headless Server Edition users. That is a meaningful parity gap in a narrower case, not a feature broken for most users. +> +> 2. **"Cannot use the Android app with that machine at all" is too strong for the Mac-driven dev host case.** host-service.ts:157-159 notes that in some setups the phone has no credentials for the SSH host. A user whose Mac is paired can still reach that Mac through the relay (and through direct SSH to the Mac) and browse its projects, SSH projects included. The statement is fully true only for: +> - a headless Server Edition, which has no pairing service anywhere under src/server; +> - a user who only has SSH access to the dev host. +> +> Also, the iOS "add SSH host manually" flow is inferred from the desktop's SSH-only-phone code paths (index.ts:2136-2143, push-notify.ts:92-99). The nodeterm-ios source is not visible to confirm it. +> +> 3. **The proposed fix needs three refinements:** +> - **Track the socket per session.** Both `node-terminal` and `nodeterm-rmt` can exist on one host at once (the session-memory notes in CLAUDE.md). +> - **Make attach on `nodeterm-rmt` attach-only.** `SshScripts.attach` uses `new-session -A`. On `nodeterm-rmt` that would create a missing session without the desktop's hook and account `-e` environment and its remote tmux.conf. +> - **Discover projects differently on a Mac-driven host.** There is no `workspace.json` there. Node ids would come from the `agent-status-.json` slices, the `tmux ls` output and `/.nodeterm/project.json`, with a stale `updatedAt` read as "no data" (STATUS_HEARTBEAT_MS = 60 s). +> +> `~/.nodeterm-server` is only the default location. It can be changed with `--data-dir` or `NODETERM_DATA_DIR`, so the browse should check the default and treat a miss as "unknown", not as "no nodeterm here". + +> Real, with these corrections: +> +> - **Anchor.** The "only QR/paste" claim belongs at PairScreen.kt:97-115 (accept() at :67-75), not at SshScripts.kt. SshScripts.kt:38 is correct only for the userData probe. +> - **Stronger root cause for the Server Edition.** The issue is not only the missing `~/.nodeterm-server` probe. src/server has no pairing service at all, so no QR exists to scan and Android has no route whatsoever. +> - **Severity.** "High" is defensible for users of these two topologies. Everyone who pairs with a desktop by QR is unaffected, though, so medium–high (a whole-topology parity gap) is more precise than "headline feature broken for most users". +> - **Fix: key install.** The phone cannot install its own Ed25519 key before it can authenticate. The manual "Add SSH server" flow needs a bootstrap: a one-time password login, or showing the public key line for the user to add to authorized_keys. It also needs TOFU host-key pinning like the paired path. +> - **Fix: push.** `~/.nodeterm/push-grants` and the backend's /v1/push fan-out are APNs-only (docs/android.md Known gaps). Adding the host would give Android browse, terminal, status slices and polling, but not headless push. Do not promise the "full push / Live-Activity coverage" from docs/SERVER.md:81. +> - **Fix: status slices.** Reading the `agent-status-.json` slices should treat an `updatedAt` older than about 2× STATUS_HEARTBEAT_MS (60 s, remote-status-push.ts:22) as no data. +> - **Fix: nodeterm-rmt targets.** Attaching to nodeterm-rmt sessions must use that socket for attach, has-session, send-keys and kill as well, not only for list. + +## A28 + +**SSH-project sessions over direct SSH are attached, approved and resumed on the wrong machine** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: parity; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:93` +- Note: Same issue as A09. + +**Evidence** + +SSH-project sessions run on the remote host. The desktop routes their approval answers there (`writePendingAnswer` writes `~/.nodeterm/pending/.answer` "on the project's host over its ControlMaster", src/main/remote-ssh/ssh-project.ts:1814-1823). CLAUDE.md also states "A remote node is NEVER spawned locally". iOS reaches such sessions on their own host (see the dev-host finding). Android lists them (SessionsTab.kt:111 keeps every open project; :236-238 labels them "on user@host"). Over the direct-SSH connection to the Mac it then runs `exec "$NT_TMUX" -L node-terminal … new-session -A -s 'nt-'` on the Mac (SshScripts.kt:93). That creates an empty local shell, reports `fresh=true`, and TerminalController offers `claude --resume ` in it. Approvals take the same wrong path: SshScripts.answerApproval checks the Mac's `~/.nodeterm/pending/.json` and prints `gone` when it is missing (:136). That maps to `ApprovalOutcome.ALREADY_HANDLED` (SshHostConnection.kt:268), and the UI toasts "Already handled." (InboxTab.kt:88) while the remote hook is still holding the request. + +**Proposed fix** + +Over SSH, refuse or relay-route nodes whose project has `sshTarget` (the relay's `approvals.answer` already routes through the desktop's `answerPermission`). Longer term, reach them by connecting to that host directly, as iOS does. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause and all cited lines are correct, but the proposed fix is only half right. +> +> **Approvals: relay-routing does fix them.** `approvals.answer` goes through `answerPermission`, which routes to the project's host. +> +> **Attach: relay-routing does NOT fix it.** The relay `pty.attach` has the same wrong-machine behaviour on the desktop side, where it pre-existed: +> - `host-service.ts` `handleAttach` (:419-432) calls `pty.sessionExists(nodeId)` and then `pty.attachDetached(nodeId, sinks, {cols, rows})`. +> - `PtyManager.attachDetached` (pty-manager.ts:2661-2667) passes only `persistKey`, with no `sshRemote` or `requireRemote`. `spawnSession` therefore runs a local `tmux new-session -A` on the Mac's `node-terminal` socket. +> - It is arguably worse on the relay. When the canvas holds a live remote session, `sessionExists` answers true via `liveSessionForPersistKey`, so the phone gets `fresh:false` over a brand-new empty local shell. +> +> **Better fix:** +> 1. On BOTH transports, refuse to attach to nodes whose project has `sshTarget`. Show something like "runs on user@host — open it there". The same applies to End session over SSH, which would kill a local `nt-` that never existed. +> 2. For approvals of such nodes, use the relay `approvals.answer` when a relay leg exists. Otherwise tell the user it must be answered on the computer. Do not fall through to OPEN_SESSION or "Already handled." +> 3. Separately, the desktop's relay attach path should refuse (or route over the ControlMaster) for nodes that `workspaceStore.sshProjectIdForNode` resolves to an SSH project. +> +> A precision note on wording: the resume is only offered, never typed automatically. Accepting it launches the agent CLI on the Mac, where the transcript does not exist. + +## A29 + +**No source-control screen, although the protocol layer already implements the git verbs iOS uses** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: parity; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt:95` + +**Evidence** + +The phone vocabulary includes a source-control sheet: src/main/remote/host-service.ts:117-120 ("the jailed core bridge that lets a relay-only phone (no direct SSH) run the source-control sheet in ONE round trip per operation instead of N ssh execs"). src/main/index.ts:3903 ("The jailed core bridge both phone hosts serve: typed git verbs") and host-git-bridge.test.ts:1 ("The jailed core bridge for the PHONE vocabulary: typed `git.*` verbs") agree. "instead of N ssh execs" shows iOS also does source control over SSH. Android declares `suspend fun git(verb: GitVerb, cwd: String, …)` (HostConnection.kt:95; `GitVerb` STATUS/DIFF/STAGE/UNSTAGE/COMMIT/PUSH/PULL/HISTORY at :103), and RelayHostConnection advertises `git = true` (RelayHostConnection.kt:55). But no app code calls `.git(` (grep finds 0 call sites under android/app), and SSH throws `relayOnly("Source control")` (SshHostConnection.kt:258). + +**Proposed fix** + +Add a per-project Source Control screen over `conn.git(...)`: status list, diff viewer, stage/unstage, commit box, push/pull, history, with the host's error messages shown as returned. Add an SSH implementation that runs `git -C ` inside the project roots. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three refinements: +> +> 1. **The gap is also undocumented.** docs/android.md has a protocol mapping table (lines ~33-45) and a "Known gaps" section (lines 88-104). Neither mentions git or source control, even though the doc says it records "what is not done". The fix should document the gap as well as close it. +> +> 2. **Better fix: relay first, then SSH.** Ship the screen over the relay first, following the pattern the app already uses for board writes, node actions and new sessions: gate it on `conn.capabilities.git` and hide it or show the relay-only reason on SSH. The relay path already exists (RelayHostConnection.kt:280). Its error contract is explicit: host-service.ts:514-523 answers "git is not served on this host." / "cwd is outside the shared project roots.", and the UI should show those strings as returned. An SSH leg (`git -C ` over the session) can follow as a separate step. If it is built, restrict cwd to the project roots from `projects.list`, to match the desktop's `isWithinRoots` jail, and use typed verbs only. Otherwise document it in Known gaps, the same way board writes are documented as relay-only. +> +> 3. **Line and evidence details.** The capability flag is at RelayHostConnection.kt:54-56 (the `HostCapabilities(...)` constructor, `git = true` on :55), and the implementation is at :280-281. The claim that iOS does source control over SSH is inferred from a desktop comment and is not verified. + +## A30 + +**Pressing Deny on the desktop is not respected: the phone re-dials about 8 s later and the SAS approval dialog reappears** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt:212` +- Note: Related: A05, A07. + +**Evidence** + +Desktop side: on Deny, standing-host.ts:398-404 (`ipcMain.on(IPC.remoteHostReject…)`) only calls removeFromPool(p) → session.close() and ensurePool(). There is no deny list. Phone side: the close surfaces in RelayConnector as the generic `HostException("The connection closed while waiting for approval.")` (RelayConnector.kt:70), so the phone cannot tell a rejection from a network drop. It becomes ConnState.Failed. While any screen watches the computer, the poll loop `while (isActive) { refreshNow(); delay(POLL_MS) }` (ConnectionManager.kt:212-221) calls refreshNow → ensureConnected → connectLocked again, with no backoff and no memory of the refusal. When SSH is unavailable (off-LAN, a Windows host, or the Relay-only route), each attempt does a fresh /v1/relay/join plus handshake on the replacement listener, and onPeerReady raises a new SAS dialog (standing-host.ts:258). The same loop re-raises the dialog every ~5 min 8 s after an unanswered approval times out (RelayConnector.kt:82). + +**Proposed fix** + +Treat a close during the approval wait as a terminal refusal for this HostSession. Stop the auto-reconnect and show "The computer declined this phone" with an explicit retry button. Back off after an approval timeout. On the desktop, remember a rejected key for some time instead of prompting again. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The claim is right but understates the scope in one respect, and a few details need adjusting. +> +> 1. The poll loop is not the only thing that redials. `InboxWorker.doWork` (InboxNotifier.kt:118-129) is a 15-minute `PeriodicWorkRequest` (:64). It calls `withTimeoutOrNull(45_000) { session.refreshNow() }` for every paired host, whether or not a screen is watching. A denied phone on the relay route therefore raises the SAS dialog again about every 15 minutes indefinitely, even with the app closed. Each of those prompts is abandoned 45 s later, when the timeout cancels the connect. A phone-side fix limited to `HostSession` or the poll loop would not stop this. The worker must also skip a host whose last relay attempt was refused or is still unapproved. +> +> 2. The timing depends on the real relay. "About 8 s" assumes the relay closes the phone's socket when the host closes its side. The test broker does (host-fixture.ts:76-78). If the real relay does not, the phone fails through the 30 s RPC timeout instead (RelaySocket.kt:326), so the redial comes after about 38 s. Either way the dialog comes back. +> +> 3. Line numbers: the loop body is ConnectionManager.kt:214-219, and :212 is the `startWatching` declaration. +> +> 4. The fix belongs mainly on the desktop. Any client can simply redial, so the security mitigation is to remember a rejected public key for a while in standing-host.ts or phone-approval.ts and not prompt for it again. Relatedly, `mintDevice` needs only the host's public key and hostDeviceId, which are advertised in relay.json. On the phone, the fix is UX: treat a close during the approval phase as a refusal, stop auto-reconnect in both the poll loop and the worker until the user taps retry, and back off after an approval timeout. +> +> 5. Severity: medium is reasonable. The case is limited to the relay route and a phone that was denied or left unanswered, but the effect is repeated prompting on a security dialog. + +## A31 + +**A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt:84` + +**Evidence** + +(1) Keepalive: `client.connection.keepAlive.keepAliveInterval = 20` (line 353) configures sshj's DefaultConfig provider. That provider is KeepAliveProvider.HEARTBEAT (javap of sshj-0.39.0 DefaultConfig), and Heartbeater.doKeepAlive only writes an SSH_MSG_IGNORE and never expects a reply, so it cannot detect a dead peer. The sshj Reader also catches SocketTimeoutException and continues, so `client.timeout = 30_000` does not help either. (2) run(): `val out = cmd.inputStream.readBytes()` runs before `cmd.join(timeoutSec…)` (lines 88-89), and ChannelInputStream.read uses a bare Object.wait(). The timeout never bounds the read, so a command in flight when the peer vanished blocks forever. (3) Every other failure, such as startSession timing out, is wrapped as `HostException("The SSH connection failed…")` (line 96). fireClosed runs only `if (!isConnected)`, which stays true. ConnectionManager.refreshNow treats any HostException as an 'answer' and does NOT disconnect (ConnectionManager.kt:195-203), so the dead connection is reused on every poll and connectLocked's relay fallback is never reached. (4) The only error sink, `_lastError` (ConnectionManager.kt:53-54, 198), is never read by any UI (grep: no reference outside ConnectionManager.kt). + +**Proposed fix** + +Use `KeepAliveProvider.KEEP_ALIVE` (keepalive@openssh.com with a max missed count) in the SSHClient config. Bound run() with a real deadline (read on a worker thread, or close the channel on timeout). In run() and refreshNow, classify channel-open and transport timeouts as transport failures and disconnect, so Auto falls back to the relay. Surface lastError in HostScreen. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three refinements, none of which changes the verdict. +> +> (a) "Heartbeater cannot detect a dead peer" is slightly overstated. Its IGNORE writes are exactly what make the kernel eventually time out an otherwise idle connection. Detection does happen, but only after the TCP retransmission timeout (about 15 minutes), never within an app-level bound. It is not "never". +> +> (b) A laptop that wakes inside the retransmission window resumes the same TCP connection, so the sleep case is a long stale window rather than a permanent wedge. The desktop IP-change and VPN cases really are dead until the kernel gives up. +> +> (c) The InboxWorker impact is narrower than stated. For an unwatched host it calls `session.disconnect()` after every run (InboxNotifier.kt:127), and a fresh `connect()` uses a 4 s connectTimeout under Auto, so it falls back to the relay correctly. It only loses time when it reuses a dead conn that was left open: stopWatching does not disconnect. It hangs past its 45 s timeout only if a read was in flight. +> +> Fix details. Set `KeepAliveProvider.KEEP_ALIVE` on the DefaultConfig BEFORE constructing SSHClient. KeepAliveRunner sends keepalive@openssh.com with want-reply and calls `transport.die()` after `maxAliveCount` misses, which fires the existing disconnectListener → fireClosed → relay fallback. Also bound the read in run() by closing the session or channel from a timer: channel close/notifyError unblocks the wait. Treat a channel-open timeout (ConnectionException) as a transport failure that disconnects, rather than as an "answer" HostException. Show lastError in the HostScreen banner. + +## A32 + +**No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: critic; kind: gap +- Location: `android/app/src/main/assets/terminal/terminal.js:22` + +**Evidence** + +terminal.js loads only the FitAddon (`term.loadAddon(fit)`, line 22). There is no WebLinksAddon and no `linkHandler`, so plain URLs in agent output are never linkified. OSC 8 links fall back to xterm's default activate, which calls confirm(). The WebView gets only a WebViewClient and no WebChromeClient (TerminalController.kt:175), so that dialog cannot appear and the link does not open. The shouldOverrideUrlLoading branch that 'opens links in the browser' (TerminalController.kt:177) is effectively unreachable. Selection is also unavailable: the vendored xterm.js handles touchstart/touchmove only for viewport scrolling, disables its selection service while mouse events are active (tmux runs `mouse on`), and xterm.css sets `.xterm { user-select: none }`. terminal.js additionally calls `e.preventDefault()` on every single-finger touchmove (line 80). The only copy path is OSC 52 emitted by the host, and on a touch phone the only way to trigger it is tmux copy-mode driven by mouse drag or prefix keys, neither of which is reachable. README.md:18 nevertheless advertises 'OSC 52 copy'. + +**Proposed fix** + +Load @xterm/addon-web-links with a handler that calls the bridge to open http(s) URLs. Set a linkHandler that skips confirm(), or install a WebChromeClient. Add a long-press 'select / copy screen' mode, e.g. capture the visible buffer via `term.buffer` and offer copy/share of lines, since tmux mouse mode makes xterm's own selection unusable. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Four parts of the finding need correcting. +> +> 1. Two copy paths do exist, but neither is practical. The finding says OSC 52 copy can't be reached from a touch phone; that is not strictly true. +> - Prefix keys: the key row has a Ctrl chip (TerminalScreen.kt:153). TerminalController.onInput turns Ctrl+letter into a control byte (TerminalController.kt:138-141). So Ctrl, b, [ enters tmux copy-mode (the default C-b prefix; the conf sets none). From there a keyboard selection and copy are possible but awkward and hard to discover. +> - Double/triple tap: tmux 3.4's built-in root bindings are `bind -n DoubleClick1Pane { ... if -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' { send -M } { copy-mode -H; send -X select-word; ...; send -X copy-pipe-and-cancel } }`, and the same for TripleClick1Pane (select-line). When the pane's app has not grabbed the mouse (a plain shell, codex), the clicks the WebView synthesizes from taps may therefore copy a word or line via OSC 52. This is not verified on a device. In a Claude pane, where the fullscreen TUI grabs the mouse, the click goes to the app instead. +> So the accurate statement is: there is no practical or general copy path, and never a selection of arbitrary text. That is not the same as "none at all". +> +> 2. The OSC 8 part is stronger than the finding shows. Links do reach the phone because pty-manager.ts:326 declares `,*:hyperlinks`. +> +> 3. Proposed fix, links: +> - Do not load @xterm/addon-web-links. The desktop replaced it on purpose: the addon cannot join the hard-wrapped rows that tmux repaints, so a long OAuth URL only matched its first row (src/renderer/terminal/file-links.ts:404-409). +> - Port createUrlLinkProvider and set `term.options.linkHandler` to a handler that calls a new NodetermBridge.openUrl, limited to http(s). +> - Account for the fact that, with mouse reporting on, a tap on a link is also sent to tmux as a click. +> +> 4. Proposed fix, copy: a long-press "copy screen" or "select lines" sheet built from `term.buffer.active` remains the right approach. +> +> The line citations in the finding are correct: terminal.js:22, :80; TerminalController.kt:175, :177; README.md:18. + +## A33 + +**On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: critic; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt:28` + +**Evidence** + +The relay attach that creates a phone-started session passes only `{cols, rows}` (host-service.ts:432 `pty.attachDetached(nodeId, sinks, { cols, rows })`). The desktop therefore spawns it in `options.cwd || os.homedir()` (pty-manager.ts:2834), and the session-host backend receives that same `cwd` (pty-manager.ts:3346). The phone's only way to reach the project is the launch line. But `SAFE_DIR = Regex("^/…")` (Agents.kt:28) rejects every Windows path (`C:\Users\…`), so `cd '' &&` is omitted (Agents.kt:52). Likewise `CLAUDE_CONFIG_DIR=''` is omitted for a Windows account dir (Agents.kt:54), and the shell variant's `Regex("^/…")` returns null (SessionsTab.kt:331). TerminalController still registers the node with `launch.accountId` (TerminalController.kt:238). Windows computers are relay-only, and New session is offered only over the relay (HostScreen.kt:71), so Windows is exactly the platform that takes this path. + +**Proposed fix** + +Have the host own the cwd: extend `pty.attach` or `projects.registerNode` so the desktop creates a phone-started session in the project's cwd with the account env (the desktop already knows both). Failing that, emit a platform-appropriate launch line for a win32 host (the mirror or projects blob can carry the host OS). Never register an accountId the launch did not actually apply. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Mostly accurate. Three refinements. +> +> (1) "New session is offered only over the relay" is true for every host OS (HostScreen.kt:71). Windows is the platform where that relay-only path also loses the cd/env. Mac and Linux relay launches get `cd '/path' &&` and work. +> +> (2) The finding's alternative fix is weaker than it sounds. A "platform-appropriate launch line" is not enough on its own. The Windows session host runs the default Windows shell (localSessionShell). There, `cd 'C:\x' && ...` is a parse error in PowerShell 5.1, cmd does not accept single-quoted paths, and the `VAR=value cmd` env prefix is POSIX-only. So simply relaxing SAFE_DIR would make things worse, not better. +> +> (3) The primary proposed fix should not be `projects.registerNode`. The phone deliberately registers AFTER typing the launch line (TerminalController.kt:231-238), so the node does not exist when `pty.attach` creates the session. The right seam is to extend `pty.attach` (host-service.ts:382-433) with an optional projectId and accountId. The host would resolve the cwd from its own project registry and the account env via `claudeConfigDirFor`, validated the same way the canvas does. Until that exists, the phone should neither offer the account picker nor send `accountId` to registerNode when the launch line could not apply CLAUDE_CONFIG_DIR. + +## A34 + +**The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:136` + +**Evidence** + +The Ctrl transform only applies when `ctrlArmed && data.length == 1` (TerminalController.kt:136-144). The input bar sends through `nt.submit` → `term.paste(text)` (terminal.js:129-133). Under a tmux client xterm always has bracketed-paste mode on (CLAUDE.md: tmux's own paste-through `?2004h`), so onData receives `\e[200~z\e[201~` (length > 1) and the Ctrl is not applied. The following '\r' (length 1, outside '@'..'_') then disarms Ctrl. The other way to type into xterm directly, the soft keyboard via the ⌨ chip, does not work (already reported). + +**Proposed fix** + +Apply an armed Ctrl in Kotlin before submitting: if the draft is a single character, send the control byte via `raw()` and skip Enter. Otherwise disarm the chip when the input bar is used. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. **Wrong line numbers for terminal.js.** The file has 121 lines. `nt.submit` is at terminal.js:110-114 (`term.paste(text)` at 112, the delayed `onInput('\r')` at 113), not 129-133. The onData hookup is at terminal.js:50. The Ctrl condition is TerminalController.kt:138-142 (the finding's line 136 is the `fun onInput` declaration). The input bar is TerminalScreen.kt:124-139 and the chip is at TerminalScreen.kt:153. +> +> 2. **The impact is slightly overstated.** "Only the four ^C/^D/^R/^L chips work" is not quite right. The chip does apply to single keystrokes typed straight into xterm: tapping the terminal to bring up the soft keyboard, per the comment at terminal.js:48-50. Whether that works with Android IMEs is a separate question. The bug is specifically that the chip has no effect on the input bar, which is the main text entry. Low severity is appropriate. +> +> 3. **The fix should drop Enter in every case.** Check ctrlArmed in the Kotlin send handler: if the draft is one character that maps to a control byte (`uppercase in '@'..'_'`, optionally `'?'` → 0x7f), send it via `raw()` with no Enter, then disarm. Otherwise disarm when the input bar is used. Point 6 above is why Enter must be dropped even when bracketed paste is off. + +## A35 + +**approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.'** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: critic; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt:255` +- Note: Related: A06. + +**Evidence** + +Android: `return if (body?.b("answered") == true) SENT else ALREADY_HANDLED` (RelayHostConnection.kt:255), and InboxTab shows the toast "Already handled." (InboxTab.kt:88). Desktop, the verb added in this branch: `.then((answered) => …{ answered })` and `.catch(() => …{ answered: false })` (host-service.ts:724-725). answerPermission returns false when the write fails: writePendingAnswerLocal's `catch { return false }` (pending-approvals.ts:56-58), and for an SSH-project node `sshProjectManager.writePendingAnswer` fails whenever its ControlMaster is down (index.ts:2782-2785). The phone's stillWaiting re-read has just confirmed the node is still blocked. + +**Proposed fix** + +Return a reason from the verb (`{answered:false, reason:'gone'|'write-failed'}`) and show 'Couldn't answer — try again or open the session' for a failed write. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause is stated a little wrong. The desktop does not return `answered:false` for "already handled" at all: `answerPermission` never checks whether the hook is still holding the request, so on the relay path every `answered:false` is a delivery failure (write error, no SSH conn record, ssh exec failure, or a throw). The desktop comment at host-service.ts:691 and the test header at host-inbox-verbs.test.ts:6-7 are wrong to say otherwise. A related side effect: answering an already-timed-out hook returns `answered:true` (SENT), which the SSH path avoids. +> +> Better fix, matching the SSH script's semantics: +> - In `answerPermission`, test for `.json` first (local: stat under `pendingDir(homedir())`; SSH: `test -f` over the ControlMaster). +> - Return a discriminated result: `{answered:true} | {answered:false, reason:'gone'|'write-failed'}`. +> - Map only `reason:'gone'` to ALREADY_HANDLED. +> - Map `write-failed`, and an absent reason from an older desktop, to an error toast such as "Couldn't answer — try again or open the session", the same way `SshHostConnection.answerApproval` throws HostException for a failed write. + +## A36 + +**After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: critic; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:105` + +**Evidence** + +On a relay drop, RelayHostConnection.onClosed calls `sink.onExit(null)` for every stream (RelayHostConnection.kt:121-126). TerminalController then sets `state = TermState.Ended("Disconnected.")` (TerminalController.kt:105-110). HostSession reconnects automatically 1.5 s later when watched (ConnectionManager.kt:141-149), but that only re-lists projects. TerminalController does not observe `session.state`, and re-attaching needs the manual Reattach button (TerminalScreen.kt:95). The SSH stream behaves the same way (`sink.onExit(cmd.exitStatus)` with a null status). + +**Proposed fix** + +When the exit code is null (a transport drop, not a pane exit), have TerminalController wait for the HostSession to return to Connected and call attach() again automatically with bounded retries. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The mechanism is right. Four refinements, the last two about the fix: +> +> 1. **SSH detection is slower.** A dead SSH channel does not null `HostSession.conn` straight away. `fireClosed` runs only when the next `run()` fails (SshHostConnection.kt:84-98), which is usually the next 8 s poll. On SSH the host-level reconnect therefore follows the drop by up to about 8 s, not 1.5 s. An automatic reattach attempted before that point goes through `ensureConnected()`, gets the stale connection back, and its `hasSession` `run()` throws a HostException. That lands the terminal in Ended again, this time with "Couldn't open the terminal over SSH…". +> 2. **Relay reconnect can stall at approval.** The relay re-dial can pass through `ConnState.AwaitingApproval` (ConnectionManager.kt:114). Waiting for `Connected` is correct, but the wait should have no short fixed deadline. +> 3. **A null code is not proof of a transport drop.** A relay `Op.ERROR` frame whose `exitCode` is JSON null also yields `onExit(null)` (RelayHostConnection.kt:68-71; the desktop forwards the pty's `exitCode` verbatim at host-service.ts:342-349). A cleaner signal is to mark streams closed by the connection-level `onClosed` path, or to key the retry on `session.state` leaving `Connected`. +> 4. **Keep the retry bounded and conditional.** Retries should be capped, and they should run only while the screen is showing (not `disposed`), so a real host-side failure cannot loop. + +## A37 + +**proguard-rules.pro would not survive turning on minification (R8 missing-class errors)** + +- Severity: **low**; claimed by auditor: low; effort: small; area: build; kind: risk +- Location: `android/app/proguard-rules.pro:3` + +**Evidence** + +app/build.gradle.kts l.21-23 keeps `isMinifyEnabled = false` and says shrinking 'needs keep rules nobody has written yet'. proguard-rules.pro says: 'If it is ever turned on, sshj, BouncyCastle and EdDSA need keeps …' and adds only `-dontwarn javax.naming.**` and `-dontwarn org.slf4j.**`. jdeps on the actual runtime jars shows references to classes android.jar does not have: sshj 0.39.0 `SSHClient` and `userauth.method.AuthGssApiWithMic` reference `org.ietf.jgss.*` (GSSContext, GSSManager, Oid, …) and `javax.security.auth.login.LoginContext`, and eddsa 0.3.0 `EdDSAEngine` references `sun.security.x509.X509Key`. R8 in AGP 8 fails the build on missing classes unless they are `-dontwarn`ed. + +**Proposed fix** + +Add `-dontwarn org.ietf.jgss.**`, `-dontwarn javax.security.auth.login.**` and `-dontwarn sun.security.x509.**`, and drop the unnecessary `org.slf4j` dontwarn (slf4j-api is on the classpath). Alternatively, change the comment so it no longer implies the rules are complete. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding holds, with two corrections. +> +> 1. It overstates the framing. The file does not really present itself as a ready-made, complete rule set. build.gradle.kts:21 says outright "shrinking needs keep rules nobody has written yet", and proguard-rules.pro:1-2 only says keeps "need" to exist. The real inconsistency is smaller: that comment says the rules have not been written, while proguard-rules.pro:3-7 does contain partial keeps. Those keeps would fail R8 on missing classes as soon as they were used. +> +> 2. A better anchor is proguard-rules.pro:6-7 (the dontwarn block), not line 3. The fix: +> - Add `-dontwarn org.ietf.jgss.**`, `-dontwarn javax.security.auth.login.LoginContext` (or `.**`) and `-dontwarn sun.security.x509.**`. +> - `-dontwarn org.slf4j.**` is redundant but harmless, because slf4j-api is a declared runtime dependency of sshj in both its POM and its .module file. It is a nit, not a required part of the fix. +> +> It is unclear whether the result then passes R8. That needs an actual `assembleRelease`, which cannot run here because Google Maven is blocked. Severity: low (latent). The wording "would break assembleRelease" is accurate only once someone enables minification. + +## A38 + +**Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question** + +- Severity: **low**; claimed by auditor: low; effort: small; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt:53` + +**Evidence** + +Android: `if (status.state != expected) return false`, with `expected = AgentState.BLOCKED` for approvals (`WAITING` for questions), which returns ALREADY_HANDLED. Desktop agent-status-mirror.ts:1520-1528: when a parent AskUserQuestion is pending and a concurrent approval arrives, `produceInboxFromState(... 'blocked' ...)` publishes the approval card with its pendingId, but the mirror keeps `state: next.state` = 'waiting' ('keep the parent's waiting badge and question correlation until its own answer'). Conversely, line 1611 (`kind = hasQuestion || nextState === 'waiting' ? 'question' : 'approval'`) can emit a question card while the state is 'blocked'. + +**Proposed fix** + +For approvals, check that the event is still unresolved and that its pendingId is still live, and accept either needs-you state (blocked or waiting). Do the same for questions. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line citations (mirror 1520-1528 and 1611) are correct. Restrict the finding to the approval direction. The question-while-'blocked' half needs an uncorrelated question and is not reachable in the normal Claude flow after #821. +> +> The proposed fix is too broad and would be harmful as written. Relax the node-state gate ONLY on the ticketed path. When `event.pendingId != null`, skip the `state == BLOCKED` requirement. Keep only the "fresh event is still unresolved" re-check that hook-reply-approvals.md prescribes, then call `conn.answerApproval`; the desktop or the answer file reports "gone" when the ticket expired. Do NOT accept WAITING for the ticketless send-keys fallback (claude, no pendingId), and do not accept BLOCKED for answerQuestion. On a node whose AskUserQuestion picker is on screen, typing `1` would pick option 1 of the question rather than approve anything. + +## A39 + +**The account chip reads `account.label`, which the mirror never writes, so it falls back to the raw account UUID** + +- Severity: **low**; claimed by auditor: low; effort: small; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt:129` + +**Evidence** + +Android: `accountLabel = account?.let { it.s("label") ?: it.s("accountId") }`. The mirror's `account` is `ObservedClaudeAccount` = `{configDir, accountId: string|null, known, remote?}` (shared/types.ts:1348-1370), which has no `label`. MirrorSettings.claudeAccounts carries only `{id, dir}`. SessionsTab.kt:265 renders `status?.accountLabel`. + +**Proposed fix** + +Derive the label as the desktop's AccountChip does: for a known managed id, look up a label (or show a short form); for an unlinked dir, use the last segment of configDir. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three small corrections. Nothing here changes the verdict. +> +> (1) It is not a separate chip. The value is joined into the Sessions row's " · "-separated detail text (SessionsTab.kt:260-267), so the UUID mostly shows up as ellipsized noise at the end of the line. +> +> (2) The parse site starts at ProjectsParser.kt:121 (`val account = e.o("account")`). The fallback is on line 129. +> +> (3) Better fix: the mirror already carries labels for local managed accounts. `usage.accounts[]` has `{accountId, label, email}`, built from settings (agent-status-mirror.ts:312-317, `label: acct?.label ?? null`), and Android already parses that block (ProjectsParser.kt parseUsage → `UsageAccount.label`, used in InboxTab.kt:236). So the fix is: +> - Resolve `account.accountId` against `status.usage.accounts`, using label, then email. +> - If there is no match, show a short form of the id rather than the full UUID. `usage` is dropped from SSH slices, and it is absent when no usage snapshot exists. +> - When `accountId` is null and `known` is false, show the last path segment of `configDir`. +> - When `accountId` is null and `known` is true, show nothing (the system account). +> +> Dropping the `label` read entirely would also remove dead code. + +## A40 + +**The pending-launch path and the attach hand-off never re-check `disposed`: a stream can stay attached forever, or a phone-started node gets registered with no agent launched** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:234` + +**Evidence** + +- attach() checks `if (disposed)` once, on the background thread (:209), then posts `main.post { stream = s; state = TermState.Attached ... }` (:213). If dispose() ran on main between that check and the posted runnable, the runnable installs `s` on a disposed controller, and nothing ever detaches it. +- afterAttach (:231-242) runs on graph.scope: `delay(900)`, then `s.write(launch.command + "\r")`, then `conn.registerNode(...)`, with no disposed or stream check. If the user leaves during those 900 ms, dispose() has already detached the stream (relay `pty.kill`), so the launch line is dropped. registerNode still runs, against a tmux session the phone's attach already created. + +**Proposed fix** + +Re-check `disposed` inside the main.post and detach if it is set. In afterAttach, abort (and don't registerNode) if the controller was disposed or the stream has changed since the delay started. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The mechanism is right, with three clarifications and a better fix. +> +> 1. (a) and (b) cannot both happen on the same attach. In the (a) race the stream is NOT detached, so the pending launch actually succeeds (the write and registerNode both land). The launch is lost only in the (b) window, where the posted runnable already installed the stream and dispose() then detached it. +> +> 2. The launch is also unrecoverable. PendingLaunches.take (:229) already consumed the request, so reopening the terminal cannot retry it. +> +> 3. Cited lines: the (a) window is between TerminalController.kt:209 and :213. The unguarded launch sequence is :234-241. +> +> Better fix for (b): the proposed "abort and don't registerNode" still leaves the `nt-` tmux session that the attach created (`new-session -A`) running as an invisible orphan with a bare shell. Either: +> - let the launch finish regardless of the viewer: dispose() defers the detach of a stream that still has a pending launch until afterAttach has written the line and called registerNode; or +> - on abort, call `s.endSession()` (`pty.destroy`) so the created session is killed, rather than just skipping registration. +> +> Fix for (a): in the main.post runnable, re-check `disposed` and detach `s` if it is set. Mark `disposed` @Volatile, or cancel attachJob from dispose(). + +## A41 + +**The composed prompt is cleared even when no stream is attached, so the text is silently lost** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt:132` + +**Evidence** + +TerminalScreen.kt:131-138: `controller.submit(draft, enter = true); draft = ""`, with no state check. submit → JS `term.paste(text)` → `bridge.onInput(d)` → TerminalController.kt:143 `stream?.write(out)`. stream is null while Connecting or after Ended ("Disconnected."), so the bytes are dropped. + +**Proposed fix** + +Disable Send (or keep the draft and show a message) unless controller.state is Attached. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct. On the line reference: the handlers span TerminalScreen.kt:131-138 (onSend at 131-134, the IconButton at 135-138), not just line 132. The same silent drop affects the key-row chips and the ^C/^D chips (`key()` goes to JS and then `bridge.onInput`; `raw()` calls `stream?.write` at :289-291), but those are single keystrokes and lose almost nothing. +> +> Suggested fix: make `submit` return whether it was delivered, e.g. `fun submit(...): Boolean { if (state != TermState.Attached) return false; ... ; return true }`, and clear `draft` only on true. Alternatively, set `enabled = controller.state == TermState.Attached` on the Send IconButton and ignore onSend when not attached. +> +> Gate on `state` rather than reading `stream`. The two are updated together in the same main-thread post (:213-215, :107-108), while `stream` is also read from the JS bridge thread without synchronization. + +## A42 + +**NewSessionDialog crashes if the selected project disappears while the dialog is open** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt:327` + +**Evidence** + +`val projects = snapshot.openProjects().filter { it.sshTarget == null }` is recomputed from the live snapshot that HostScreen re-lists every 8 s, while `projectId` is remembered. The Start button does `val p = projects.first { it.id == projectId }` (:327), which throws NoSuchElementException inside onClick when that project was closed or removed on the desktop meanwhile. + +**Proposed fix** + +Use firstOrNull and dismiss or show an error; alternatively disable Start when projectId is not in `projects`. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line (SessionsTab.kt:327) and the root cause are correct. A more precise fix: work out the selection from the current list, e.g. `val selected = projects.firstOrNull { it.id == projectId } ?: projects.firstOrNull()`. Enable Start only when `selected != null`, and use `selected` in onClick instead of `first {}`. That way the dialog never crashes. It also avoids a subtle trap: if the remembered id disappears and no radio button is shown as selected, Start should not go to a hidden project. If all local open projects are gone, disable Start or dismiss the dialog. + +## A43 + +**The HostScreen tab and scroll position reset after returning from a terminal** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt:62` + +**Evidence** + +`var tab by rememberSaveable { mutableIntStateOf(initialTab) }` (:62). AppContent (MainActivity.kt:112-118) renders only `nav.current` and has no SaveableStateHolder, so HostScreen leaves composition when a Terminal is pushed and its saveable state is dropped. The normal push is `Route.Host(host.id)` (tab 0). + +**Proposed fix** + +Wrap each route in rememberSaveableStateHolder().SaveableStateProvider(key), or store the selected tab back into the Route on change. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Root cause and line are right. The proposed fix is incomplete, though. `rememberSaveableStateHolder().SaveableStateProvider(key)` only preserves `rememberSaveable` state. The Board tab's selected project is a plain `remember` (`var projectId by remember { mutableStateOf(null) }`, BoardTab.kt:86), and so is the Inbox archive toggle (`showArchive`, InboxTab.kt:68). Both would still reset to the first project / off after returning from a terminal. They need to become `rememberSaveable`, or be hoisted into the Route. Also, the SaveableStateProvider key must be unique per stack entry (for example a per-entry id), not the Route data class, which can repeat on the stack. And `removeState(key)` must be called on pop, or the saved state leaks. The simpler alternative is to write the selected tab back into the stack entry (replace `stack[i]` with `r.copy(tab = newTab)`). That fixes the tab, but not the scroll positions or BoardTab's projectId. + +## A44 + +**System back discards Settings edits (device name, relay API base)** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt:59` + +**Evidence** + +The name and apiBase are persisted only in the top-bar back IconButton (:59-63: `graph.hosts.deviceName = name; if (apiBase.startsWith("https://")) graph.hosts.apiBase = apiBase; nav.pop()`). The system back gesture goes to AppContent's `BackHandler(enabled = nav.stack.size > 1) { nav.pop() }` (MainActivity.kt:111), which pops without saving. + +**Proposed fix** + +Persist on change (or on dispose), or register a BackHandler inside SettingsScreen that saves before popping. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is accurate. On the fix: the simplest change that keeps the https-only rule for apiBase is a `BackHandler { save(); nav.pop() }` inside SettingsScreen. The innermost enabled BackHandler wins, so it takes precedence over AppContent's. It should share one `save()` lambda with the IconButton at :59-63. Saving on every keystroke would be wrong for apiBase: the HostStore setter would persist half-typed values, and the `startsWith("https://")` check would let through intermediate values like "https://a". So if you save outside the back action, use a DisposableEffect onDispose save rather than a per-keystroke write. One more point: when the typed apiBase is not https, the top-bar back also drops it without a message. That is intended validation, but the user is not told. + +## A45 + +**No onRenderProcessGone handler on the terminal WebView** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: risk +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:175` + +**Evidence** + +The WebViewClient (:175-184) overrides only shouldOverrideUrlLoading. Per WebViewClient.onRenderProcessGone, the default returns false, and then "application will crash if render process crashed, or be killed if render process was killed by the system". Because of the missing lifecycle handling, terminal WebViews are also kept alive in the background. + +**Proposed fix** + +Override onRenderProcessGone: return true, drop the WebView, and set the controller to Ended ("Reopen terminal") so a new WebView can be created. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The cited line, root cause and severity are right. The proposed fix is incomplete. +> +> 1. Returning true from `onRenderProcessGone` and setting `TermState.Ended` is not enough on its own. The existing Reattach button (TerminalScreen.kt:95) calls `controller.attach()`, which reuses the controller but never rebuilds the view. The `AndroidView` factory runs once per composition (TerminalScreen.kt:79), so no new WebView is created. After `webView = null`, `js()` returns silently (TerminalController.kt:192-193): a re-attached stream would paint into nothing, and `pageReady`/`pendingJs` would stay stale. +> +> 2. The handler must also: +> - detach the current `stream`; +> - remove the WebView from its parent and call `destroy()`; +> - reset `pageReady`, `pendingJs` and `cols`/`rows`; +> - bump a generation counter that keys the `AndroidView` (e.g. `key(gen) { AndroidView(...) }`), so Compose builds a fresh WebView before attaching again. +> +> 3. Optionally, call `setRendererPriorityPolicy(RENDERER_PRIORITY_BOUND, true)` so a backgrounded renderer is reclaimed ahead of the app, now that the app survives its loss. + +## A46 + +**The ⌨ key-row chip only focuses the DOM textarea, which cannot raise the soft keyboard** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:293` + +**Evidence** + +`fun focusTerminal() = js("nt.focus()")` → terminal.js `focus: function () { term.focus() }`. Nothing in the app calls webView.requestFocus() or InputMethodManager.showSoftInput (grep finds neither). A script-initiated focus() from evaluateJavascript is not a user gesture, and the Android IME attaches to the focused View (the Compose text field, or nothing). + +**Proposed fix** + +In focusTerminal, call webView.requestFocus() and then InputMethodManager.showSoftInput(webView, 0) alongside the JS focus. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The conclusion and the line (TerminalController.kt:293, triggered from TerminalScreen.kt:171) are correct. The root-cause wording needs adjusting. Blink does not require a transient user gesture here. Script focus is allowed in a main frame (Frame::AllowFocusWithoutUserActivation returns true outside fenced frames), and the keyboard is gated only on STICKY activation, meaning any gesture since page load. The chip actually fails for three reasons: +> (a) Element::Focus returns immediately when the textarea is already the focused element, which is the normal state after the user has tapped the terminal once. +> (b) Sticky activation is missing if the user has never touched the page. +> (c) The WebView is not the focused Android view while the Compose draft field has focus. +> +> Better fix: +> 1. In focusTerminal, clear the Compose focus first (LocalFocusManager.clearFocus() from TerminalScreen) so the draft field lets go of the IME. +> 2. Call webView.requestFocus(). +> 3. Blur and then refocus the textarea (`nt.blur(); nt.focus()`), so Blink's already-focused early return does not swallow the call. +> 4. Post InputMethodManager.showSoftInput(webView, InputMethodManager.SHOW_IMPLICIT) with webView.post {} so it runs after the view has become the IME's served view. A synchronous call right after requestFocus can race. +> +> Relying on showSoftInput is what makes this work regardless of the page's activation state. Needs a device check, since this was never built or run. + +## A47 + +**The Keystore decrypt runs on the main thread in the host list's composition, once per row per recomposition** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt:121` + +**Evidence** + +Inside each LazyColumn item, `routeSummary(host, graph.secure.getString(SecureStore.relayTokenKey(host.id)) != null)`. SecureStore.getBytes (SecureStore.kt:56-67) runs Cipher.init + doFinal with an AndroidKeyStore key, which is a binder round trip to keystore2. The row recomposes on every ConnState change (Connecting/AwaitingApproval/Connected/Failed) as well as on list changes. + +**Proposed fix** + +Store a non-secret `hasRelayToken` flag on the host record, or compute it once in a remembered/background-loaded state. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The mechanism, line and severity (low) are all correct. Three additions: +> +> 1. **Lock contention.** The `@Synchronized` monitor in SecureStore (lines 44, 52, 70, 76) also makes this composition wait on any concurrent background SecureStore call, such as ConnectionManager.kt:101, 167, 181 or 183. +> +> 2. **Wasted decrypts.** The decrypt runs even for hosts with `host.relay == null`, where routeSummary ignores the result. +> +> 3. **Better fix.** Store a non-secret `hasRelayToken` boolean on PairedHost, or check only `prefs.contains(key)` without decrypting. Either one: +> - moves the check off the Keystore entirely; +> - updates correctly through the existing `graph.hosts.update` that follows `putString` in adoptRelayIfAdvertised (ConnectionManager.kt:183-186). +> +> A plain `remember { }` would still run the decrypt on the main thread once per row. If the decrypt must stay, use `produceState` on `Dispatchers.IO` keyed on `(host.id, host.relay)`. + +## A48 + +**The seen-events set is trimmed in hash order and updated without synchronization, which can produce duplicate notifications** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt:98` + +**Evidence** + +`fun markSeen(ids) { val next = (seenEvents() + ids).toList().takeLast(500).toSet(); prefs.edit().putStringSet("seenEvents", next).apply() }`. getStringSet returns a HashSet, so takeLast(500) drops arbitrary old ids rather than the oldest. The method is not @Synchronized and is called from the worker thread (InboxNotifier.kt:86), from graph.scope (TerminalController.kt:262) and from main (InboxTab.kt:76). Concurrent read-modify-write loses updates. + +**Proposed fix** + +Store (id, ts) pairs and trim by age, make markSeen @Synchronized, and use commit()/an atomic update. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Mostly accurate. Two corrections: +> +> 1. The concurrency half is much narrower than the eviction half. SharedPreferences `apply()` updates the in-memory map synchronously, so the race window is microseconds. Also, TerminalController.kt:262's id is usually resolved on the desktop by the `ackRead` just before it, so losing that write rarely causes a re-notify. The dominant real cause is the hash-order eviction once the set passes 500. Each markSeen of k new ids then evicts k arbitrary old ids, and any of them that is still unresolved and under 6 h old gets re-announced. +> +> 2. Better fix: announce() only considers events younger than 6 h (InboxNotifier.kt:79). So store id→seenAt (for example a JSON map in one string pref) and prune entries older than about 6 h plus a margin, rather than using a count cap; nothing still eligible is ever evicted. Make markSeen @Synchronized like the other mutators in the class. commit() is not needed, because apply() already publishes to memory atomically; the missing piece is the lock around the read-modify-write. + +## A49 + +**SSH host-key TOFU pin is saved during key exchange (before auth) and is not tied to the pairing** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: security; kind: risk +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt:330` + +**Evidence** + +The verifier pins whatever key answers first: `pinned == null -> { pin.pin(fp); true }` (line 330). This runs inside `client.connect()`, before `client.authPublickey(...)` (line 348). The catch block (lines 353-357) never undoes the pin, so an SSH server that REJECTS our key still becomes the pin (it is persisted via HostStore.update, ConnectionManager.kt:154-157). Pairing does not anchor the pin: `PairedHost.from` sets `sshHostKeyFingerprint = null` (PairedHost.kt:83), and neither the QR nor the sealed /pair response carries an SSH host key. Every non-Windows host advertises SSH whether or not sshd is running: pairing-service.ts:440 `directSsh = platform !== 'win32'` and line 652 only adds `ssh:false` on Windows. macOS ships with Remote Login off. For such a host the pin stays null and the phone keeps dialing the host's private LAN IP from whatever network it is on, including every 15 minutes from InboxWorker. Once pinned, a mismatch is a hard stop with no relay fallback: ConnectionManager.kt:88-93 rethrows HostKeyChangedException before the relay block. + +**Proposed fix** + +Record the fingerprint in verify() but persist it only after authPublickey succeeds. Anchor the pin in pairing: have the desktop return its SSH host key fingerprints (e.g. from /etc/ssh/ssh_host_*_key.pub) inside the sealed /pair response, and store them in PairedHost.from so the first connect is verified. Do not background-dial a host whose SSH has never authenticated, and allow relay fallback when the pin was never confirmed by a successful auth. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Downgrade to low. Four corrections: +> +> 1. The unpinned window is narrow. On macOS/Linux the pairing QR is only shown while sshd answers on 127.0.0.1:22 (src/shared/pairing-gate.ts; PhonePairPopover.tsx:49; PhoneSection.tsx:95). The app then connects over SSH immediately on the pairing LAN (PairScreen.kt:158 → HostScreen.kt:66 startWatching), so the first pin is normally the real computer's key. The "Remote Login off, so the pin stays null forever" scenario does not happen at pairing. +> +> 2. Pinning during key exchange mirrors OpenSSH's known_hosts behavior. The real defects are: +> - The KDoc says the pin is null "before the first successful connect", but a key exchange whose authentication failed also pins (SshHostConnection.kt:44-47 vs 330/346-357). +> - Pairing cannot anchor the pin: the desktop /pair response (pairing-service.ts:827) and the QR carry no SSH host key. Anchoring needs a desktop change, which would also serve the iOS app. +> +> 3. The more likely user-facing impact is the one listed only as secondary evidence. After a correct pin, a private-IP collision on another network makes Auto mode fail with a "host key changed" error and never tries the relay (ConnectionManager.kt:88-93). +> +> 4. Better fix: +> - On a mismatch, refuse SSH and show the warning, but still fall through to the relay. The relay is independently authenticated (hostKeyB64 / relay host key plus the SAS approval), so this is safe. +> - Persist the pin only after `authPublickey` succeeds. +> - Have the desktop return its SSH host key fingerprints inside the sealed /pair response, and store them in `PairedHost.from`. + +## A50 + +**The only distributable build is a debuggable APK, so Keystore-protected secrets can be pulled over adb/JDWP** + +- Severity: **low**; claimed by auditor: medium; effort: medium; area: security; kind: risk +- Location: `android/app/build.gradle.kts:19` + +**Evidence** + +The release buildType has no signingConfig (lines 19-25). The README's only install path is `./gradlew :app:assembleDebug → app-debug.apk` (android/README.md:32-38). CI uploads `nodeterm-android-debug` (.github/workflows/android.yml:73-80). The desktop sends users to that folder (src/renderer/lib/links.ts:7 `ANDROID_APP_URL = '.../tree/main/android'`). AGP marks debug builds `android:debuggable=true`, which enables `adb shell run-as dev.nodeterm.android` and JDWP attach to the app process. SecureStore's guarantee ('it never leaves the secure hardware') is about the key material only: the key is usable by any code running as the app's UID or inside its process. + +**Proposed fix** + +Ship a signed, non-debuggable release build (configure signingConfig and publish release artifacts), and point the README and desktop link at it. Until then, state in the README that the debug APK exposes the phone's keys to anyone with adb access. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> **Severity is overstated. This is closer to low than medium.** The attack needs physical access to an unlocked phone that has USB debugging on and has accepted the attacker's adb RSA prompt. Someone in that position can already drive the non-debuggable app's own UI (terminal, new session) to run commands on the paired computer. What the debuggable flag adds is exfiltration of durable, reusable credentials: the unrestricted SSH key, and relay impersonation via box secret plus device token. Sideloading also does not require USB debugging; an APK can be installed from the browser, so "often switched on precisely to sideload" is plausible but not required. +> +> **The cited line is right** (build.gradle.kts:19-25). The root cause is more precisely the absence of any signed release artifact or release pipeline. No debug-specific setting is wrong on its own: debug builds are meant to be debuggable. +> +> **The fix should cover three things:** +> - A signed, non-debuggable release (a `signingConfig` fed from CI secrets, `assembleRelease`, artifacts attached to a GitHub Release). +> - Pointing README and `ANDROID_APP_URL` at that release. +> - Until then, a README warning that the debug APK exposes the phone's pairing credentials to anyone with adb access. +> +> **A related, unverified distribution problem:** debug APKs built on CI are signed with a per-runner debug keystore. If that keystore is freshly generated each run, one CI artifact cannot update another in place. Uninstalling first wipes the Keystore and the prefs, which forces a re-pair. + +## A51 + +**allowBackup=false does not stop device-to-device migration at targetSdk 35: hosts, pins and deviceId are cloned** + +- Severity: **low**; claimed by auditor: low; effort: small; area: security; kind: gap +- Location: `android/app/src/main/AndroidManifest.xml:13` + +**Evidence** + +The manifest sets only `android:allowBackup="false"`, with no `android:dataExtractionRules`, and build.gradle.kts:14 targets SDK 35. Android 12's behavior changes state that for apps targeting 31+, allowBackup=false disables cloud backup but not device-to-device transfer; only a `` exclusion in dataExtractionRules does. `nodeterm.hosts` (every PairedHost, its SSH pin and relay block) and the phone's `deviceId` (HostStore.kt:66-69) are plain SharedPreferences and move to the new phone. `nodeterm.secure` ciphertext moves too but cannot be decrypted there, so SecureStore regenerates the identity. The backend keys the device row on that deviceId: pairing-service.ts:921 says the mint 'upserts on it'. + +**Proposed fix** + +Add android:dataExtractionRules with `` and `` excluding the `nodeterm.secure` and `nodeterm.hosts` shared_prefs (or excluding everything). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Mostly right. Refinements: +> +> 1. **Revoke only happens on Pro desktops.** `revokeRelayDevice` returns 'skipped' when the desktop holds no entitlement (`pairing-service.ts:156`: `if (!entitlement || !relayDeviceId || !apiBase) return 'skipped'`). So the "Remove revokes both phones" effect only happens on an entitled (Pro) desktop. +> 2. **A second, likely effect on free desktops (backend not in the repo, so not verified).** The mint comment at `pairing-service.ts:84-87` says the backend's free-tier re-registration demands `priorDeviceToken` for an existing deviceId, and without it returns 403, leaving a "silent LAN-only pairing". The migrated phone re-registers an existing deviceId but cannot decrypt its prior token (`PairScreen.kt:140` returns null). Unless the backend's same-desktop allowance applies, its relay leg may be refused. On a Windows desktop that refusal fails the pairing outright (502). +> 3. **Wording of the fix.** Exclusion paths for the sharedpref domain need the `.xml` suffix: `` and `path="nodeterm.hosts.xml"`, under both `` and ``, referenced with `android:dataExtractionRules="@xml/data_extraction_rules"`. +> 4. **A sturdier fix.** Keep `deviceId` inside SecureStore, or re-mint it whenever the box secret had to be regenerated. That way the relay id always changes together with the keys it belongs to, whatever the backup rules say. +> +> Severity "low" is reasonable. It only bites after an Android 12+ phone migration, and the host list is recoverable by re-pairing. + +## A52 + +**Approval and finish notifications put command text and the agent's last message on the lock screen** + +- Severity: **low**; claimed by auditor: low; effort: small; area: security; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:108` + +**Evidence** + +`.setContentText(ev.title + (ev.detail?.let { " — $it" } ?: ""))` plus BigTextStyle with the detail (lines 108-109). There is no setVisibility, setPublicVersion or channel lockscreenVisibility, so the default VISIBILITY_PRIVATE applies, which shows full content on a secure lock screen under Android's default 'show sensitive content' setting. For approvals, `detail` is the tool summary: the first line of the Bash command, the file path with the diff size, or the fetched URL (agent-status-mirror.ts:1143-1198). For DONE events it is the agent's last message line (agent-status-mirror.ts:1691). + +**Proposed fix** + +Keep `detail` out of the notification by default: for example, set VISIBILITY_PRIVATE with a setPublicVersion that shows only 'Needs you — ' / '', and add an opt-in 'Show details on lock screen' setting. Or use VISIBILITY_SECRET for the attention channel. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The cited line is right: line 108, with line 109 carrying the BigTextStyle. +> +> The first proposed fix would not work. VISIBILITY_PRIVATE is already the default, and Android shows the public version only when the user has turned on "hide sensitive content" for the lock screen. Under the default setting, a PRIVATE notification is still shown in full whether or not a public version exists. So "VISIBILITY_PRIVATE plus setPublicVersion" does not hide the detail in the case the finding describes. +> +> Fixes that would work: +> - Use VISIBILITY_SECRET, on the notification or as the channel's lockscreenVisibility. +> - Or leave `detail` out of both contentText and BigTextStyle by default, and put it behind an opt-in setting (for example "show details in notifications"). +> +> The setPublicVersion that shows only "Needs you — " is worth adding alongside either fix. It only helps users who already hide sensitive content, so on its own it does nothing for the default case. +> +> The finding should also say that the same detail already reaches the iOS phone through the APNs push body (src/core/push-notify.ts:361). This is a difference in platform defaults, not an Android-only leak. + +## A53 + +**OSC 52 handler has no size cap (the desktop caps at 1,000,000) and setPrimaryClip is unguarded** + +- Severity: **low**; claimed by auditor: low; effort: small; area: security; kind: bug +- Location: `android/app/src/main/assets/terminal/terminal.js:55` + +**Evidence** + +Android: `var payload = idx >= 0 ? data.slice(idx + 1) : data; if (payload && payload !== '?') bridge.onCopy(payload)`, with no length limit and no `;` required. TerminalController.kt:153-161 decodes it and calls `cm.setPrimaryClip(...)` inside `main.post {}` with no try/catch. Desktop: src/renderer/terminal/osc52.ts `const MAX_BASE64 = 1_000_000 ... if (i < 0) return null ... if (!payload || payload === '?' || payload.length > MAX_BASE64) return null`, and it decodes with `fatal: true`. The vendored xterm.js accepts OSC payloads up to `PAYLOAD_LIMIT=1e7`. + +**Proposed fix** + +Mirror parseOsc52: require `;`, cap the base64 at the desktop's limit or lower, and decode strictly. Wrap setPrimaryClip in try/catch and toast on failure. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct, but the proposed fix is partly insufficient. Capping at the desktop's `MAX_BASE64 = 1_000_000` would NOT prevent the crash: +> - tmux 3.4 forwards a 986,675-character base64 OSC 52 (measured), which is under 1,000,000 characters. +> - That decodes to about 740 KB of text, which ClipData parcels as UTF-16 at about 1.5 MB, still over the binder limit. +> +> The fix that actually matters: +> 1. Wrap `setPrimaryClip` in try/catch and show a "copy too large" toast on failure. +> 2. Add a much lower cap in `terminal.js` and/or `onCopy`, for example a few hundred KB of base64 or about 100k characters of decoded text, to stay well under the shared 1 MB binder buffer. +> +> Requiring `;` and decoding with a strict UTF-8 decoder (`CharsetDecoder` with `CodingErrorAction.REPORT`) are parity niceties only; neither prevents the crash. +> +> The severity of low is defensible. Medium is also arguable, because remote pane output can crash the app, but only through a deliberately oversized sequence or a very large copy-mode selection. + +## A54 + +**PairingClient trusts an unbounded Content-Length / EOF body from the pairing endpoint** + +- Severity: **low**; claimed by auditor: low; effort: small; area: security; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt:253` + +**Evidence** + +`val buf = ByteArray(length)` uses a Content-Length taken from `toIntOrNull()`, with no upper bound or sign check (lines 247-254). Without a Content-Length it reads with `input.readBytes()` until EOF (line 263). The only limit is the 64 KiB header cap and a per-read soTimeout. PairScreen catches only `Exception` (PairScreen.kt:159). The endpoint is whatever `host:pairPort` the payload names, and a `nodeterm://pair` link from any web page can supply it (the user still has to tap Pair). + +**Proposed fix** + +Reject a negative Content-Length or one above a small cap (the real response is well under 64 KiB), bound the EOF read the same way, and add an overall deadline for the exchange. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Line numbers: the Content-Length parse is at PairingClient.kt:132-137 (`toIntOrNull()` at line 135), `ByteArray(length)` is at line 139, and the EOF `input.readBytes()` is at line 148. The finding cites 247-254, 253 and 263, which do not exist in this 152-line file. +> +> The negative-value case does not crash. NegativeArraySizeException is a RuntimeException, so PairScreen.kt:159's `catch (e: Exception)` catches it. The screen then shows the exception's bare message (e.g. "-1") as the error instead of a sentence. Only the huge-positive and unbounded-EOF cases (OutOfMemoryError) crash the app. +> +> Proposed fix, confirmed as sound: +> - Reject a negative Content-Length, and any value above a small cap such as 64 KiB, with a PairingException. +> - Cap the no-length EOF read at the same size. +> - Bound the whole exchange with an overall deadline, e.g. `withTimeout`, and close the socket on cancellation so the blocking read unblocks. + +## A55 + +**Inbox and Usage are per computer; iOS merges them across all paired computers** + +- Severity: **low**; claimed by auditor: medium; effort: medium; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt:64` + +**Evidence** + +docs/mobile-usage-inbox.md:130: "**Agents tab (feed)**: merged events across connections, newest first". :124: "**Usages tab**: one section per paired connection that reports `usage` (header: host name + relative `updatedAt`)". Android's `InboxTab(nav, hostId, session, snapshot)` (InboxTab.kt:64) and `UsageTab(snapshot)` each render one computer inside HostScreen's tabs. The only routes are Hosts/PairHost/Settings/Host/Terminal (MainActivity.kt:28-33), with no cross-computer inbox. HostsScreen shows no needs-you count per computer. + +**Proposed fix** + +Add a top-level Inbox/Usage screen that merges every paired computer's snapshot, labels each card with its computer, and adds a needs-you badge to each computer row. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Refinements only; nothing in the claim is wrong. UsageTab is defined in the same file (InboxTab.kt:213), not in its own file. The Route list is at MainActivity.kt:28-34. HostScreen already computes a per-computer needs-you count (HostScreen.kt:70). The cheapest part of the fix is to lift that `count { it.actionable }` onto each HostsScreen row, using the session's cached snapshot. The merged Agents feed and Usages view also need each card and section to carry its hostId. The feed's Open, Approve and Answer actions have to call the right computer's `session.ensureConnected()`, because QuickActions is per-session today (InboxTab.kt:103-115). + +## A56 + +**Approval cards lack iOS's "Always allow" answer** + +- Severity: **low**; claimed by auditor: low; effort: small; area: parity; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt:23` + +**Evidence** + +docs/hook-reply-approvals.md:38-40, phone answerer: "`InboxApproval` writes it over the connection when the approval event carries `pendingId`; else falls back to send-keys. Digit `2`/\"Always allow\" keeps using send-keys in v1". Android's `answerApproval(conn, event, allow: Boolean)` (QuickActions.kt:23) supports only allow ("1" or the ticket) and deny (Esc). InboxTab offers only Approve / Deny / Open (InboxTab.kt:107-114). + +**Proposed fix** + +Add an "Always allow" action for Claude approvals. It re-checks that the node is still blocked, then sends `2` with send-keys; for a held ticket, open the session instead, since the prompt is not on screen. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line references are right. Two corrections: +> +> 1. **Evidence.** The iOS behaviour rests only on docs/hook-reply-approvals.md:39-40. docs/mobile-usage-inbox.md:135-140, the v1 phone spec, defines only Approve and Deny. +> +> 2. **Impact and fix.** `hookReplyApprovals` is on by default (src/shared/types.ts:1986), so most Claude approvals are held tickets. A keyed "Always allow" only works for unticketed ones. +> - For a held ticket, "open the session instead" does not put the choice in front of the user right away. The prompt is not painted until the hook times out (`NODETERM_PERM_WAIT_SECS`, default 45 s, per docs/hook-reply-approvals.md:24-30). Until then the opened pane shows no prompt to pick `2` from. +> - Better fix: add an "Always allow" button only when `event.agentId == "claude"` and `event.pendingId == null`. Re-check that the node is still BLOCKED with `stillWaiting`, then `sendKeys(nodeId, "2")`. +> - For ticketed approvals, either hide the button, or open the session with a note that the prompt appears after the hold times out. +> - Also add the gap to docs/android.md "Known gaps" if it is not built. + +## A57 + +**Multi-select AskUserQuestion cards fall back to "Open session"** + +- Severity: **low**; claimed by auditor: low; effort: small; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt:118` + +**Evidence** + +src/core/agent-status-mirror.ts:384-386: `multiSelect` "Rides the pipeline so the phone renders multi-select chips". src/core/push-notify.ts:61-62: "Rides the `nt` block so the phone renders multi-select". Android shows option chips only when `ev.options.isNotEmpty() && !ev.multiSelect` (InboxTab.kt:118), and `QuickActions.answerQuestion` returns OPEN_SESSION when `event.multiSelect` (QuickActions.kt:43). + +**Proposed fix** + +Render toggleable chips for multi-select questions and send the chosen digits followed by the picker's submit key, re-checking that the node is still waiting before each send. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Line and root cause are right: InboxTab.kt:118 and QuickActions.kt:43 both deliberately route multi-select questions to "Open session", in line with the fail-safe policy at QuickActions.kt:17-18. Two corrections to the wider claim. +> +> 1. The gap that can actually be shown is on the display side. Android does not show a multi-select question's options at all; the else branch at InboxTab.kt:124-126 renders only "Open session". The desktop comments (agent-status-mirror.ts:384-386, push-notify.ts:61-63) say the phone should render multi-select chips. Whether iOS also answers these questions cannot be verified from this repo. +> +> 2. The proposed answer path is speculative. No keystroke sequence for Claude Code's multi-select picker (toggle key, submit key) is measured or documented anywhere here. A safer fix has two steps: +> - Now: render the options read-only (numbered, non-interactive) next to "Open session", so the card shows what is being asked. +> - Later: add answer-from-Inbox only after measuring the picker's real key semantics on a live CLI, and keep the stillWaiting re-check before sending. + +## A58 + +**Usage and feed cards omit iOS's pace line and the context indicator on event cards** + +- Severity: **low**; claimed by auditor: low; effort: small; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt:246` + +**Evidence** + +docs/mobile-usage-inbox.md:128-129: "Pace line: compare `usedPercent` vs elapsed fraction of the window (`windowMinutes` else 300/10080 by kind) → \"5h usage pace slower/faster\"". :131-132: "Cards show the node's `contextPercent` ring when known". Android's `UsageBar` (InboxTab.kt:246-273) shows only the percentage and reset time, never using `windowMinutes`. `EventCard` (InboxTab.kt:163-208) shows no context % (only the live working cards do). + +**Proposed fix** + +Compute the pace from `resetsAt` and `windowMinutes`, using the 300/10080-minute defaults by kind, and show `inbox.nodes[nodeId].contextPercent` on approval, question and done cards. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line range for EventCard is slightly off. It is InboxTab.kt:162-206, not 163-208. UsageBar is 245-272 and the reset text is at line 263. The fix should compute the pace only when `resetsAt` is non-null: elapsed = 1 - (resetsAt - now) / (windowMinutes ?: (if kind == "session" then 300 else 10080)) / 60000. Compare that with usedPercent/100 to get "pace slower/faster". Also note that `windowMinutes` is null in some desktop mirror payloads (src/core/agent-status-mirror.test.ts:1584), so the per-kind defaults are required. The context value should come from `snapshot.status?.inbox?.nodes?.get(ev.nodeId)?.contextPercent`, which means passing it into EventCard. + +## A59 + +**No built-in dictation (iOS has on-device Whisper plus a Cloud engine)** + +- Severity: **low**; claimed by auditor: low; effort: small; area: parity; kind: gap +- Location: `android/app/src/main/AndroidManifest.xml:4` + +**Evidence** + +src/core/speech/whisper-models.ts:8-9: "The fences here are lessons already paid for on iOS: a download streams to a per-download `.part.`". src/core/speech/cloud-speech.ts:4-6: "the SAME wire contract the iOS Cloud engine speaks (multipart WAV + locale, Bearer license token)". CLAUDE.md:5124: "**Mobile** keeps its own list, tracked separately as issue #591" (speech languages). Android has no speech code (a grep for speech/dictat/RECORD_AUDIO under android/ finds nothing), and the manifest declares only INTERNET, ACCESS_NETWORK_STATE, POST_NOTIFICATIONS and CAMERA (AndroidManifest.xml:4-8). The terminal input bar is a Compose `OutlinedTextField` (TerminalScreen.kt:124), so the keyboard's own voice typing still works. + +**Proposed fix** + +Add a mic button to the input bar using Android's SpeechRecognizer (or on-device whisper.cpp) that fills the draft and never auto-submits. Send the same multipart request to `/v1/transcribe` once it exists. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two corrections. +> +> 1. The Cloud half of the parity claim does not matter yet. /v1/transcribe does not exist. CLAUDE.md:5101 says "not built yet", and cloud-speech.ts:6-7 says every call "maps 404 to a friendly 'not available yet'". So the iOS Cloud engine cannot transcribe anything today either. The real, current gap is on-device dictation plus a language choice. +> +> 2. The gap is also missing from docs/android.md "Known gaps" (lines 88-105). Either implement dictation or list it there. Citing TerminalScreen.kt:123-139 (the input Row) is more precise than manifest line 4. +> +> The proposed fix is reasonable. Android's SpeechRecognizer needs RECORD_AUDIO plus a runtime permission prompt. It should fill the draft only and never call controller.submit, which matches the desktop rule that nothing auto-submits. + +## A60 + +**Interop fixture needs the Electron binary, which is downloaded at test time inside the 20 s ready window, despite the workflow saying 'not Electron'** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: bug +- Location: `android/protocol/src/test/interop/host-fixture.ts:22` + +**Evidence** + +The workflow comment at android.yml:39-42 says the fixture needs 'tweetnacl, ws and esbuild — not Electron ... hence no scripts', and InteropHarness.kt:72 bundles with `--external:electron`. The bundle still has three top-level `require("electron")` calls, from host-service.ts (`import { app, ipcMain } from 'electron'`, host-service.ts:28), host-identity.ts and host-canvas-hub.ts. I reproduced this with esbuild in the scratchpad: the bundle's lines 2330, 3565 and 4132 are `var import_electron… = require("electron")`, and they run when the module loads. Electron 42.11.3 has no postinstall. Its index.js ends in `module.exports = getElectronPath()`. When path.txt is missing, that function calls `downloadElectron()`, which does `spawnSync(process.execPath, [install.js])`, a synchronous download of about 110 MB from GitHub releases that extracts to 312 MB. If the download fails, it throws 'Electron failed to install correctly'. I simulated a clean node_modules/electron with an unreachable mirror, and `require` threw after 334 ms. On a fresh runner nothing downloads Electron beforehand. So the first `InteropHarness.start` pays for the download before the fixture can print its ready line, and the harness waits only `h.await(20_000)` for that line (InteropHarness.kt:93). A slow or failed download makes every interop test fail with 'fixture event not seen within 20000ms'. Local corroboration: node_modules/electron/path.txt and ~/.cache/electron are both dated 07:13, 18 minutes after `npm install` (06:54) and just before the protocol commit. That fits the first local interop run having done the download. + +**Proposed fix** + +Stop the fixture from loading the real electron package. For example, alias `electron` to a tiny stub exporting no-op `app`/`ipcMain` (`--alias:electron=./android/protocol/src/test/interop/electron-stub.ts`), or have the harness set `ELECTRON_OVERRIDE_DIST_PATH`, which makes index.js return a path without downloading. Then correct the workflow comment. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. **Severity is lower than claimed; I'd call it low-to-medium.** On a GitHub-hosted runner, a GitHub-releases download of ~125 MB plus unzip usually finishes well inside 20 s. The normal outcome is a hidden 125 MB download (312 MB on disk) that adds a few seconds. Failures only happen when that download is slow or fails. Then both interop classes fail after 20 s: RelayInteropTest and PairingInteropTest (one `InteropHarness.start` each). +> - An aggravating detail: when `h.await(20_000)` throws inside `start()`, the handle was never returned, so `close()` never runs. The node process is orphaned and keeps downloading, and the next test can start a second, concurrent download into the same dist/. +> +> 2. **The local timestamp corroboration does not hold up.** node_modules/electron/path.txt is 07:13:04, but android/protocol/build/interop/ was created at 07:16:38, 3.5 minutes later. The download therefore happened before the first bundle recorded in that directory, and the timestamps do not show that the harness triggered it. +> +> 3. **The proposed stub is incomplete.** Seven src/main/remote files import from electron, including host-identity.ts (`app, safeStorage`) and approved-devices.ts / peer-identity.ts (`app`), so a stub must also export `safeStorage`. +> - Today, plain-node `require("electron")` returns a path string, so every member is already undefined at runtime. An empty-object stub is therefore behaviour-preserving for the current tests. +> - The simplest fix is `pb.environment()["ELECTRON_OVERRIDE_DIST_PATH"] = ` in InteropHarness.start. The cleaner fix is esbuild `--alias:electron=`, which also removes the need for the electron package entirely. +> - Either way, correct the comment at android.yml:39-40. + +## A61 + +**Following CONTRIBUTING / android/README (`npm ci --ignore-scripts`) wipes a desktop developer's patched node_modules** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: bug +- Location: `CONTRIBUTING.md:111` + +**Evidence** + +CONTRIBUTING.md:107-111 is aimed at desktop contributors who change host-service.ts, the blob, the mirror and similar. It tells them to run `./gradlew -p protocol test` (from `android/`, after `npm ci --ignore-scripts`). android/README.md:48 says the same ('Run `npm ci --ignore-scripts` at the repo root first'). `npm ci` always deletes node_modules, and `--ignore-scripts` skips the root postinstall `node scripts/patch-node-pty.mjs && electron-rebuild -f -w node-pty,smart-whisper` and node-pty's own install script (`node scripts/prebuild.js || node-gyp rebuild`). After that, src/main/node-pty-patch.test.ts, which reads node_modules/node-pty/src/unix/pty.cc and src/win/conpty.cc, goes red in the contributor's `npm test`. On Linux node-pty ships no prebuild (its prebuilds/ folder only has darwin-*/win32-*), so `npm run dev` has no pty.node. On macOS it falls back to the unpatched ptmx-leaking prebuild that CLAUDE.md calls 'not optional' to patch. The harness itself only checks that node_modules/.bin/esbuild, node_modules/ws and node_modules/tweetnacl exist (InteropHarness.kt:81-85), so any ordinary `npm install` / `npm ci` already works. + +**Proposed fix** + +Say that a normal `npm install` is enough and that `--ignore-scripts` is only for a machine without the native toolchain (as CI uses it). Warn that it replaces an existing node_modules. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is accurate on the facts, the line numbers and the proposed fix. Severity is at the low end of medium, and arguably low: this is a documentation-only problem that affects developers, not the app or its users, and the test's own failure message names the recovery (`npm run rebuild`). +> +> Recommended fix, in two parts: +> 1. **Scope the flag in both docs.** In CONTRIBUTING.md:110-111 and android/README.md:48, say that an existing `npm install` is enough. `--ignore-scripts` is only for a machine without the native toolchain, as CI uses it (android.yml:39-42). +> 2. **Add a warning.** `npm ci` deletes node_modules, so running it with `--ignore-scripts` over a working desktop checkout leaves node-pty unpatched and unbuilt until `npm install` or `npm run rebuild` is run again. + +## A62 + +**SSH transport tests fail, not skip, on macOS: the gate checks only that /usr/bin/script exists, then runs util-linux-only flags** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: bug +- Location: `android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt:54` + +**Evidence** + +The gate at lines 54-55 is `tmuxAvailable() = ... tmux -V ... && File("/usr/bin/script").exists()`. Every pty-requesting exec then runs `listOf("script", "-qfec", "stty cols $cols rows $lines ...; $command", "/dev/null")` (lines 84-85). `-c ` (and `-f`, `-e` in this form) is util-linux syntax. macOS /usr/bin/script is the BSD one (`script [-aeFkqr] [-t time] [file [command ...]]`), which has no `-c` and exits with a usage error. On a Mac with Homebrew tmux on PATH, which is the normal nodeterm developer setup, the gate passes and the pty-backed tests fail: the pane process dies immediately, and `Sink.waitFor` throws "'…' never appeared". This affects the attach and cold-start tests. CONTRIBUTING.md:110-111 tells every contributor to run exactly this suite. + +**Proposed fix** + +Gate on util-linux `script` (for example, require `script --version` to report util-linux), or `assumeTrue(os.name == Linux)`, with a skip reason. Alternatively, allocate the pty in the MINA command without `script`. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two corrections to the finding: +> - **Root cause and scope.** On macOS the first failure is not `Sink.waitFor` in the pty tests. `@BeforeAll` fails earlier: the private `TMUX_TMPDIR` is made by `Files.createTempDirectory("nt-ssh")` under `/var/folders/…/T/` (line 126). tmux resolves that to `/private/var/…`, so the socket path comes to about 110 characters, over macOS's 103. `tmux new-session` fails with "File name too long", and `assertEquals(0, code, out)` at line 187 throws. That fails all 8 tests in the class, not just attach and cold start. The BSD-`script` incompatibility at line 85 is real but only shows up after that is fixed. +> - **Citation.** The CONTRIBUTING.md reference is lines 106-109. +> +> The fix needs both parts: +> 1. **Short socket directory.** Create a short, realpath'd `TMUX_TMPDIR`, the way `src/core/tmux-test-socket.ts` `makeTmuxTmpdir` does: prefer `/tmp` resolved to `/private/tmp`, and check `/tmux-/node-terminal` fits in 103 characters. +> 2. **`script` handling.** Either gate on util-linux `script` with a skip reason (for example, `script --version` reports util-linux, or `assumeTrue(os.name == Linux)`), or branch to BSD syntax: `script -q /dev/null /bin/sh -c '…'`. + +## A63 + +**Android workflow path filters miss files that change the tested wire behavior, contrary to CLAUDE.md** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: gap +- Location: `.github/workflows/android.yml:7` + +**Evidence** + +The triggers cover only `android/**`, `src/main/remote/**`, `src/main/pairing-*.ts` and the workflow file itself (lines 6-17). ci.yml runs no Gradle. CLAUDE.md:5390-5396 says a change to 'a host-service.ts verb, the projects.list blob, the pairing payload, the mirror file, or the SSH-visible file contracts ... the Android workflow (.github/workflows/android.yml) runs on those paths'. Several of those producers are outside the filter. (1) host-service.ts validates the phone's params with `isValidPendingId` from src/core/agents/pending-approvals.ts (host-service.ts:37; this is exactly what the interop `approvals.answer` test exercises), with `parseCardLabelEdit` from src/core/project-kanban-write.ts (:36), and with `TITLE_MAX` from src/core/project-node-append.ts (:35). All three are in the fixture bundle; I listed its esbuild metafile inputs. (2) The projects.list blob is built by `listProjectsOutput` in src/main/index.ts:632, whose sync comment at :621-623 names only NodetermProjects.swift, not ProjectsParser.kt. The new approvals.answer/inbox.ack wiring is also in src/main/index.ts (about :4019). (3) The mirror is src/core/agent-status-mirror.ts, and acks are consumed by src/core/ack-sweep.ts. (4) The URL-form pairing QR that PairingPayload parses comes from src/shared/pair-qr.ts. (5) package-lock.json pins the ws/tweetnacl/esbuild/electron the fixture runs. None of these triggers the workflow. + +**Proposed fix** + +Add the fixture's transitive sources (at least src/core/agents/pending-approvals.ts, src/core/project-kanban-write.ts, src/core/project-node-append.ts, src/shared/**), src/main/index.ts, src/core/agent-status-mirror.ts, src/core/ack-sweep.ts, src/shared/pair-qr.ts and package-lock.json to both path lists. Or drop the path filter, since the protocol job is cheap. Also name ProjectsParser.kt in the index.ts marker comment. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause is right for the fixture's transitive sources, but the list is incomplete and part of the fix does nothing. The esbuild metafile shows 29 of the 43 bundled repo sources outside the filter, including src/main/windows-ssh-keys.ts (via pairing-service), src/core/license.ts, src/core/fs-ops.ts, src/core/platform.ts and many files under src/shared/. So the path list should cover at least `src/core/**`, `src/shared/**`, `src/main/windows-ssh-keys.ts` (or `src/main/*.ts`) and `package-lock.json`. The simplest fix is still to drop the path filter for the cheap protocol job. +> +> Adding src/main/index.ts, src/core/agent-status-mirror.ts, src/core/ack-sweep.ts and src/shared/pair-qr.ts to the trigger would NOT make drift there fail anything. The fixture serves a hand-written blob and mirror (host-fixture.ts:140-165, :182 `listProjects: async () => blob`), and none of those producers is in the bundle. Closing that gap needs one of two things: +> - tests that build the blob, mirror and QR from the real producers, for example by extracting the blob assembly out of index.ts into a core module the fixture can import; +> - or CLAUDE.md:5392-5396 stops claiming the workflow covers "the projects.list blob ... the mirror file". +> +> The index.ts marker comment (:621-623) should also name ProjectsParser.kt. Line references: the blob builder is at index.ts:633 (not 632), and the inbox wiring is at index.ts:4024-4025. + +## A64 + +**Docs say the protocol tests check the mirror, the projects.list blob and the ~/.nodeterm files against desktop code, but those shapes are hand-copied in the tests** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: gap +- Location: `CONTRIBUTING.md:107` + +**Evidence** + +CONTRIBUTING.md:107-111 says a change to 'the `projects.list` blob, ..., the agent-status mirror, or the `~/.nodeterm/{pending,acks,relay.json}` files' is caught because `./gradlew -p protocol test` 'runs it against this repo's own host code'. CLAUDE.md:5392-5396 makes the same promise. In the relay fixture, the blob, including the whole mirror JSON with `inbox.events`/`pendingId`, is a literal written by hand (host-fixture.ts:129-165 and `listProjects: async () => blob` at :180). `answerPermission`, `ackRead`, `registerNode` and the three kanban writers are fakes that only emit events (:181-204). SshTransportTest.kt:168-184 hand-writes workspace.json and agent-status.json, and tests pending/acks by writing files itself (:283-294). No test runs `listProjectsOutput`, `agent-status-mirror.ts`, `ack-sweep.ts` or the index.ts `inbox` wiring. Only connectHostSession's RPC routing/validation and createPairingService are real. + +**Proposed fix** + +Either narrow the docs to what is actually exercised (relay framing/handshake and host-service validation, pairing service) and name the hand-copied contracts, or generate the fixture's blob and mirror from the real writers (for example, build the mirror with AgentStatusMirror and the blob with the same assembly listProjectsOutput uses). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The CONTRIBUTING.md:107-111 overclaim is real, and the fixture and test citations are correct (host-fixture.ts:129-165, :182, :183-205; SshTransportTest.kt:152-189 and :283-296). One part of the finding is wrong: CLAUDE.md:5388-5390 does not overclaim test coverage. It accurately names connectHostSession, createPairingService and the SSH server, and it tells contributors to update the hand-maintained fixture. CLAUDE.md's false statement is at :5395-5396, "the Android workflow ... runs on those paths". android.yml:5-17 only filters on android/**, src/main/remote/** and src/main/pairing-*.ts. So changes to src/main/index.ts (listProjectsOutput), src/core/agent-status-mirror.ts, src/core/ack-sweep.ts and src/core/agents/pending-approvals.ts do not trigger Android CI at all. docs/android.md:69-70, which lists `projects.list` as verified against the real host, is also overstated. Better fix, either or both of: +> (a) Narrow CONTRIBUTING.md, CLAUDE.md and docs/android.md to what is real: relay framing, handshake and host-service verb routing/validation, pairing, crypto, and the SSH scripts against real tmux. Name the hand-copied contracts (blob, mirror, v3 index, pending/acks, relay.json) and add the missing source paths to android.yml's `paths` filter. +> (b) Generate the fixture's mirror with the real AgentStatusMirror. Export the split markers and the blob assembly from a core module so the fixture and index.ts share one definition. In the SSH test, drive ack-sweep and pending-approvals against the files the Kotlin client writes. + +## A65 + +**User-facing docs and desktop UI present the Android app as working, but it has never been built by AGP or run on a device, and no device checklist exists** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: gap +- Location: `android/README.md:11` +- Note: PARTLY OBSOLETE: the premise "never built by AGP" is wrong. CI run 36109984730 built the debug APK with AGP on 2026-09-25. The "never run on a device" half still holds. + +**Evidence** + +android/README.md:11-26 marks 15 features ✓ (terminal with OSC 52 copy, swipe-to-scroll, WorkManager notifications, and more) with no caveat. README.md:49 and :103 now say 'phone companions for iOS and Android' and 'scan with the nodeterm app on iPhone or Android'. The desktop UI now links 'nodeterm for Android ↗' (PhonePairPopover.tsx:196-201, PhoneSection.tsx:213-218), and its pairing copy was generalized to 'the nodeterm phone app'. docs/android.md's 'What is verified' section (lines 62-86) only says the app module 'is built by CI ... it has no instrumented tests yet'. On this branch the workflow is new (added in 2f58918) and has never run. The app Kotlin was only type-checked against stubs because Google Maven was unreachable, so aapt2, the manifest merger, D8 and any device run are all unverified. CLAUDE.md requires stating what could not be run as a numbered device checklist (rule 16, docs/grok-agent.md §9 format) and says 'a doc line with no such test is a plan, not a fact'. docs/android.md has no such checklist. + +**Proposed fix** + +State in android/README.md and docs/android.md that :app has not yet been built by AGP or run on a device. Add a numbered device checklist covering pairing (QR, code, deep link), SSH and relay attach, WebView terminal and OSC 52, notifications, and Keystore persistence. Consider holding the desktop 'nodeterm for Android' link until a CI build has passed. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Drop the build claims and downgrade the severity to low. +> +> Wrong in the original finding: +> - ".github/workflows/android.yml has never run" and ":app has never been built by AGP" are both false. Run 36109984730 on head 2f58918 passed both jobs, `:app:assembleDebug` included, and uploaded a 15.8 MB debug APK. +> - The list "aapt2, manifest merger, D8 unverified" is false. +> - R8 does not apply (`isMinifyEnabled = false`, android/app/build.gradle.kts:22). +> - Do not tell the docs to say ":app has not been built by AGP". That would now be false, and docs/android.md:85-86 is correct as written. +> +> The remaining finding is: +> - The app has never been installed or run on a device or emulator, and no doc says so. +> - The android/README.md:11-26 ✓ table has no caveat. +> - docs/android.md has no numbered device checklist in the rule-16 / grok-agent.md §9 format. +> +> Proposed fix: +> - Add a one-line caveat near the ✓ table, e.g. "the APK is built by CI; no row has yet been checked on a device — see docs/android.md §Device checklist". +> - Add that numbered checklist to docs/android.md. Cover: +> - QR scan via CameraX/ML Kit, pasted code, and the `nodeterm://pair` deep link from the system camera +> - SSH attach and TOFU pin on a real LAN +> - relay attach plus SAS approval +> - WebView terminal input bar, special keys, swipe scroll and OSC 52 clipboard +> - WorkManager inbox notifications, including the Android 13+ POST_NOTIFICATIONS grant +> - Keystore persistence across app restart and reinstall +> - the cold-start resume offer +> +> Holding back the desktop "nodeterm for Android" link is optional rather than needed, since a build has passed. Note that ANDROID_APP_URL points at eneskirca/nodeterm tree/main/android, which only exists once this branch merges upstream. + +## A66 + +**ANDROID_APP_URL points at a folder that exists on neither upstream nor fork main yet, and it points at source code rather than an installable** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: gap +- Location: `src/renderer/lib/links.ts:7` + +**Evidence** + +The constant is `export const ANDROID_APP_URL = 'https://github.com/eneskirca/nodeterm/tree/main/android'`. This checkout's origin is github.com/CPlusPlus17/nodeterm, and `git ls-tree origin/main android` is empty. The android/ folder exists only on claude/android-ios-parity-75kfem, so the link 404s until this lands on eneskirca/nodeterm main specifically. A merge into the fork alone leaves it broken. Even when the folder exists, the link sits beside 'Get the nodeterm iOS app ↗' (PhonePairPopover.tsx:196-201) and after 'Don't have the app yet?' (PhoneSection.tsx:205-219), but it opens a Gradle source tree, not a download. bugReport.ts:7 already defines `REPO_URL` for the same repository. + +**Proposed fix** + +Derive the link from `REPO_URL` and merge upstream before shipping the copy. Label it as source/build instructions (for example, 'Android (build from source) ↗') until a downloadable release exists. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Line references: +> - PhonePairPopover.tsx: the iOS button is 191-196 and the Android button is 197-202. +> - PhoneSection.tsx: the Android button is 213-218, inside the "Don't have the app yet?" paragraph at 205. +> +> Root cause: the dead link is a timing artifact, not a shipping defect. It resolves when the branch merges to eneskirca/nodeterm main, and releases are cut from upstream v* tags. The part that stays true after the merge is the wording: "nodeterm for Android ↗" sits beside an App Store link and after "Don't have the app yet?", but the target is a Gradle source tree. The only APK is an expiring CI run artifact that needs a GitHub login. +> +> Better fix: +> - Relabel both call sites as source/build, e.g. "nodeterm for Android (build from source) ↗". Once a signed APK is published, point the link at a GitHub Release asset. +> - Deriving the URL from REPO_URL (`${REPO_URL}/tree/main/android`) removes the duplicate constant. It does not change behaviour on a fork, because REPO_URL is also hardcoded to upstream. + +## A67 + +**The interop fixture is excluded from every tsconfig, so `npm run typecheck` never checks it against the desktop interfaces it implements** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: gap +- Location: `tsconfig.node.json:17` + +**Evidence** + +tsconfig.node.json's `include` covers electron.vite.config.ts, src/main, src/preload, src/shared, src/core, src/server and src/session-host. tsconfig.web.json covers src/renderer and src/shared. Neither covers android/protocol/src/test/interop/host-fixture.ts. That file implements `HostPtyManager` and the kanban/inbox/nodeActions bridge (host-fixture.ts:97-204) and is only bundled by esbuild, which strips types without checking them (InteropHarness.kt:67-75). It passes tsc today (I checked it with a scratch config extending tsconfig.node.json), but nothing keeps it that way. + +**Proposed fix** + +Add `android/protocol/src/test/interop/**/*.ts` to tsconfig.node.json's include (or a small dedicated tsconfig run by `npm run typecheck`). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line reference is slightly off: the `include` array in tsconfig.node.json is at lines 18-26 (line 17 is the closing brace of `compilerOptions`). The implementing code spans host-fixture.ts:97-212, not 97-204. The esbuild call in InteropHarness.kt is at lines 67-71 (about 65-75 including the setup around it). +> +> Two additions to the finding: +> - android.yml's path filter leaves out `src/core/**`, but the fixture imports `src/core/platform` and `src/core/pty-manager`. Some desktop drift therefore skips even the runtime interop tests. +> - The `pty as unknown as PtyManager` cast at host-fixture.ts:178 means even a type-checked fixture only checks the `HostPtyManager` interface, not its fit to `PtyManager`. +> +> The proposed fix works: add `android/protocol/src/test/interop/**/*.ts` to tsconfig.node.json's `include`. I verified the file compiles clean under that config, with `@types/ws` resolving from the repo's node_modules. + +## A68 + +**Workflow triggers break repo conventions: every branch push plus pull_request doubles runs, and there is no merge_group** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: risk +- Location: `.github/workflows/android.yml:6` + +**Evidence** + +`push:` has only `paths:` and no `branches:` (lines 6-11), and `pull_request:` has the same paths (lines 12-17). For a PR branch in this repo, each commit therefore runs both jobs twice. The concurrency group `${{ github.workflow }}-${{ github.ref }}` separates refs/heads/ from refs/pull//merge, so neither run cancels the other. ci.yml:7-11 and security.yml:10-14 restrict `push` to `branches: [main]` and add `merge_group:` because 'required checks must also report on the queue's synthetic merge commits'. android.yml has no merge_group, so the queue's combined commit is never tested against the Android contract. + +**Proposed fix** + +Use `push: branches: [main]` like ci.yml, keep `pull_request` (with widened paths), and add `merge_group:`. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Keep the duplicate-run finding (android.yml:6-17 and 20-22), severity low. +> +> Fix: add `branches: [main]` to `push:` and keep its `paths:` filter, so the merged commit on main is still tested. Keep `pull_request:` with `paths:` (widening the paths is a separate matter). That removes the duplicate runs on same-repo PR branches, and possible tag-push runs as well. +> +> Drop the merge_group reasoning or reframe it. The merged commit is tested post-merge through push-to-main. The workflow cannot be a required check because of its `paths` filters, not because merge_group is missing. And merge_group takes no `paths` filter, so adding it would run Android on every queued PR. Only add merge_group if Android is deliberately made a required check. In that case, replace the workflow-level `paths` filter with an in-job path check that always reports a status. + +## A69 + +**The Gradle/Kotlin code has no dependency-update, CodeQL or wrapper-validation coverage** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: gap +- Location: `.github/dependabot.yml:4` + +**Evidence** + +dependabot.yml has only the `npm` and `github-actions` ecosystems. There is no `gradle` entry for /android or /android/protocol, whose crypto and network stack is pinned in protocol/build.gradle.kts:28-34 and app/build.gradle.kts:78 (okhttp 4.12.0, sshj 0.39.0, bcprov-jdk18on 1.78.1). security.yml:40 limits CodeQL to `languages: javascript-typescript`, so the new Kotlin NaCl port, relay socket, host-key pinning and Keystore code get no static analysis. android.yml runs a committed binary, android/gradle/wrapper/gradle-wrapper.jar (sha256 7d3a4ac4…), without a wrapper-validation step, and it has no Gradle cache, so every run downloads the distribution, AGP and all dependencies again. + +**Proposed fix** + +Add Dependabot `gradle` entries for /android and /android/protocol. Add `java-kotlin` to CodeQL (build-mode none works without the Android SDK). Add `gradle/actions/wrapper-validation` (or setup-gradle, which also caches) to android.yml. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three corrections, one of them material. +> +> 1. Line numbers. +> - `dependabot.yml:4` is a comment; the ecosystem entries are at lines 5 and 36. +> - The pins are at `protocol/build.gradle.kts:27` (okhttp), `:30` (sshj) and `:32` (eddsa), not lines 28-34. +> - `app/build.gradle.kts:78` (bcprov) is correct. +> +> 2. The CodeQL fix as proposed would analyse nothing. `build-mode: none` for `java-kotlin` covers Java source only; Kotlin needs a build, and every file under `android/` is Kotlin (43 `.kt`, 0 `.java`). Adding `java-kotlin` with build-mode none would scan nothing while looking like coverage. The fix needs `build-mode: manual` (or autobuild) with a build step: +> - `./gradlew -p protocol compileKotlin` needs no Android SDK. +> - `:app:compileDebugKotlin` works on ubuntu-latest, which ships the Android SDK. +> +> 3. The impact wording is stronger than I could verify. Dependabot security alerts come from GitHub's dependency graph, not from `dependabot.yml`. I believe (from memory, not checked) that Gradle coverage in that graph needs the dependency submission API. If so, the complete fix also adds `gradle/actions/dependency-submission`; that would also make the existing `dependency-review` job in `security.yml` see Gradle changes. +> +> The rest of the proposed fix stands: Dependabot `gradle` entries for `/android` and `/android/protocol`, and `gradle/actions/setup-gradle` in `android.yml`, which validates the wrapper and caches. Adding `distributionSha256Sum` to `gradle-wrapper.properties` would also pin the distribution itself. + +## A70 + +**On Windows the interop tests fail with CreateProcess instead of skipping** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: bug +- Location: `android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt:66` + +**Evidence** + +The harness does `val esbuild = File(repoRoot, "node_modules/.bin/esbuild")` and then `ProcessBuilder(esbuild.path, ...)` (lines 66-68). `available()` gates only on that file existing (line 83). On Windows, npm also creates the extensionless sh shim, so the gate passes. CreateProcess cannot run a shell script, though: it looks for `esbuild.exe`, which does not exist beside esbuild.cmd. The lazy `bundle` then throws, and the test errors instead of skipping. CLAUDE.md treats Windows as a first-class desktop target and requires platform-bound tests to be gated. + +**Proposed fix** + +Invoke esbuild through node (`node node_modules/esbuild/bin/esbuild …`) or through its JS API, or resolve `esbuild.cmd` on Windows. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause and the line numbers are right. Minor wording: CreateProcess may fail with error 193 (tries the sh file) rather than error 2 (looks for esbuild.exe); either way the result is an IOException. +> +> The proposed fix is incomplete, and on its own it is harmful. If only the esbuild call is changed (for example `node node_modules/esbuild/bin/esbuild ...`), bundling succeeds on Windows and two new problems appear in PairingInteropTest: +> +> (a) The pairing tests are platform-bound. The fixture calls `createPairingService(..., { timeoutMs: 60_000 })` without a `platform` option (host-fixture.ts:237-247). On win32, `directSsh = platform !== 'win32'` is false (pairing-service.ts:440), so the QR carries `ssh:false` (line 652). The first test's `assertTrue(payload.sshAvailable)` (PairingInteropTest.kt:53) then fails. +> +> (b) The tests' home isolation does not work on Windows. PairingInteropTest.kt:38 sets only `HOME`, but pairing-service.ts:259-260 resolves `AGENT_DIR` from `os.homedir()`, which reads `USERPROFILE` on Windows. The relay-on test would reach the win32 branch, which mints and then calls `persistDevice({... token: agentToken ...})` (pairing-service.ts:803-822). That writes a paired test device, with a live bearer token, into the contributor's real `%USERPROFILE%\.nodeterm\agent.json`. +> +> A correct fix has two parts: +> 1. Invoke esbuild through node, not the `.bin` shim, so RelayInteropTest can run on Windows. +> 2. Either gate PairingInteropTest off Windows (`assumeFalse(System.getProperty("os.name").startsWith("Windows"), reason)`), or have the fixture pass `platform: 'linux'` and have the test also set `USERPROFILE` to the temp home. + +## A71 + +**android/README says 'JDK 17+', but the pinned Gradle 8.14.3 cannot run on JDK 25** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: gap +- Location: `android/README.md:36` + +**Evidence** + +The README says 'Needs JDK 17+ and the Android SDK'. The wrapper pins `gradle-8.14.3-bin.zip` (gradle-wrapper.properties:3). Gradle 8.14 supports running on Java up to 24; Java 25 support arrived in Gradle 9.1. A contributor whose default JAVA_HOME is the current LTS (25) gets a Gradle startup failure from `./gradlew`. CI pins 17 (android.yml:47-50, 68-71), and the protocol tests were run locally on 21. + +**Proposed fix** + +State the supported range ('JDK 17–24; Android Studio's bundled JDK works'), or bump the wrapper to a Gradle version that supports 25 (check AGP compatibility). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line (android/README.md:36) and the conclusion are correct. The finding says only "Gradle startup failure"; the precise cause is the Kotlin 2.0.21 compiler embedded in Gradle 8.14.3. Its `com.intellij.util.lang.JavaVersion.parse` rejects "25"/"25.0.1" with an IllegalArgumentException. This fires while settings.gradle.kts compiles, so the user sees "What went wrong: 25.0.1". The Kotlin DSL's own JVM-target mapping clamps unknown versions to JVM_22, so that part is safe. +> +> Fix options: +> 1. Simplest: change the README to "JDK 17–24 (Android Studio's bundled JDK works; JDK 25 needs Gradle 9.1+)". +> 2. Add `gradle/gradle-daemon-jvm.properties` with `toolchainVersion=17`, so the daemon runs on 17 whatever JAVA_HOME is. This is incubating in 8.14, and the launcher still starts on JAVA_HOME. +> 3. Bump the wrapper to Gradle 9.1+. This needs its AGP compatibility with AGP 8.9.1 checked first; I have not verified that. + +## A72 + +**Phone-started sessions are created without the agent-specific env, so for their whole life they get no hook-reply approvals, no canvas control and no pane ownership** + +- Severity: **low**; claimed by auditor: medium; effort: medium; area: critic; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:229` +- Note: Related: A08. + +**Evidence** + +New session works by attaching a fresh `term-…` id and typing the launch line (TerminalController.kt:229-241; the docs/android.md table says 'pty.attach of a fresh term-… id, launch line, then projects.registerNode'). The relay attach passes no agentId (host-service.ts:432), and the desktop's own comment at pty-manager.ts:2254-2259 says these spawns carry 'no ownerProjectId — and no cwd, agent, account…'. hookServer.buildPtyEnv therefore omits `NODETERM_AGENT_ID`, `NODETERM_PERM_WAIT_SECS` and `NODETERM_CANVAS_CONTROL`, all gated on agentId (hook-server.ts:1249-1251). Pane ownership is recorded only when an ownerProjectId is supplied (pty-manager.ts:2309-2310). The node is registered afterwards, so the desktop mounts it warm, and tmux ignores `-e` on an existing session (CLAUDE.md: 'Sessions are pinned for life'). Nothing ever adds the missing env. + +**Proposed fix** + +Pass the intended agentId (and project and account) through the create path: add optional `agentId/projectId/accountId` to `pty.attach` for a not-yet-existing session, validated host-side like registerNode, or register first and let the desktop spawn with a held launch. Then the session gets the same env a canvas-started node does. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The behaviour is real, but four details need correcting. +> +> 1. **Root cause is on the desktop, not in the Android code.** The gap is in the relay host's `pty.attach` verb (`host-service.ts:432` passes only `{cols, rows}`), and the desktop documents it at `pty-manager.ts:2254-2259`. Android is copying the iOS new-session flow (`docs/android.md:43`), so iOS presumably has the same gap. The fix belongs on the desktop side, with the Android client sending the extra fields. +> +> 2. **"No hook env" is too broad, and so is the desktop's own comment.** `hookEnv` is still built for every `persistKey` (`pty-manager.ts:2893-2896`). `NODETERM_NODE_ID` and `NODETERM_HOOK_ENDPOINT` are present, so status badges and inbox events still work. What is missing is only the agent-gated part: `NODETERM_AGENT_ID`, `NODETERM_PERM_WAIT_SECS`, `NODETERM_CANVAS_CONTROL` and the Codex launcher PATH. Approvals still work from the phone through the keystroke fallback (`QuickActions.kt:36-38`), so this is a degraded path, not a broken one. +> +> 3. **Severity should be medium-low.** +> - Agent messaging is off by default (`agentMessagingDefault`). +> - Every pane is already unproven after a desktop restart. +> - Plain Codex is a supported mode. +> - The real losses are deterministic approvals and canvas control for Claude sessions started from the phone. +> - The user can repair a session on the desktop with "Restart agent and shell", which recycles the session so it respawns with the right env. +> +> 4. **Of the two proposed fixes, only the first is sound.** +> - "Register first and let the desktop spawn" does not work in general. The desktop only mounts nodes of the active project, and nothing spawns a registered node in a background project. The phone's own `pty.attach` would still create a bare session. +> - The workable fix is optional `agentId` (and account) on `pty.attach`, applied only when the session does not yet exist. The host should validate the value against builtin/custom agents and let `buildPtyEnv`/`canControlCanvas` decide the grant. +> - `ownerProjectId` must be resolved on the host (the index entry id), not taken from the phone. Otherwise it breaks `pane-ownership.ts`'s rule that ownership never comes "off the wire". + +## A73 + +**Notifications are documented as 'live every 8 seconds while a computer is open', but the in-app poll never posts a notification** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:125` +- Note: Related: A25. + +**Evidence** + +InboxNotifier.announce has exactly one caller: InboxWorker.doWork (InboxNotifier.kt:125), the 15-minute WorkManager job. HostSession's 8 s poll (ConnectionManager.kt:212-221) only updates `_snapshot`. Yet the kdoc says '…plus the in-app 8 s refresh while a computer's screen is open' (InboxNotifier.kt:37-38). The Settings switch tells the user 'Checked about every 15 minutes in the background, and live while a computer is open.' (SettingsScreen.kt:84). android/README.md:55-56 says '…and live every 8 seconds while a computer is open'. + +**Proposed fix** + +Call InboxNotifier.announce from HostSession after each successful refresh, skipping events the user is currently looking at (e.g. the open terminal's node, or the Inbox tab). Alternatively, correct the copy in all three places. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line is correct: :125 is the only caller, and the definition is at :71. Three corrections: +> +> 1. **Impact.** On the HostScreen, the Inbox tab badge ("Inbox (N)", HostScreen.kt:70,112) already shows new actionable events live. The unannounced cases are the terminal screen, other paired computers, and the app in the background. +> 2. **The proposed fix only covers the computer being watched.** "Call announce from HostSession after each refresh" works only for that computer, because other computers' sessions are not polled at all (`startWatching` is per-host and is started only by HostScreen and TerminalScreen). So it does not fix the "on another computer's screen" case the finding describes. +> 3. **Better fix.** Either: +> - Call `announce` from `refreshNow` and skip the node open in the terminal. This also needs the seen-set updated for events shown on the Inbox tab, or the worker will later re-announce events the user already saw. +> - Or, simpler and honest: change the three copy sites to say notifications arrive only from the ~15-minute background check, and that the Inbox tab updates live while a computer's screen is open. +> +> Severity: medium is arguable, low-medium is more accurate. It is a documentation promise that is not kept, not a broken headline flow. + +## A74 + +**The LAN leg dials a DHCP IPv4 frozen at pairing time; when another SSH host answers at that address, the host-key 'hard stop' also blocks the relay fallback** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt:88` + +**Evidence** + +PairedHost.host is the QR's `host` (PairedHost.from, PairedHost.kt), which the desktop fills with `pickLanIPv4(os.networkInterfaces())` (pairing-service.ts:588). Nothing ever updates it: late adoption reads relay.json but not the address. connectLocked tries SSH to that address first in Auto. On a HostKeyChangedException it sets Failed and throws immediately: 'A changed host key is a security signal … do not quietly route around it' (lines 88-93), so the independently authenticated relay leg is never attempted. The error text says 'someone may be intercepting the connection'. The same private address commonly belongs to a different SSH-running machine: the computer's lease reassigned at home, or the phone on another network using the same 192.168.x.y range. + +**Proposed fix** + +Key the SSH pin to the computer rather than to the IP, and on a mismatch skip SSH but still try the relay (its identity is pinned separately, so no trust is lost). Refresh the LAN address when you can: publish it in `~/.nodeterm/relay.json` or the mirror and update it over the relay, or re-resolve it from a stable hostname. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two parts of the finding are overstated and should be corrected. +> +> 1. The user is not locked out until they re-pair. SettingsScreen.kt:98-110 has a per-computer route override, "Only through the relay" (`RoutePreference.RELAY_ONLY`), which skips the SSH leg (ConnectionManager.kt:76) and reaches the computer through the relay. The real problem is that the error text never mentions that setting. It tells the user to "remove and re-pair", or implies an attack. Severity is medium at most: the case is narrow and there is a manual workaround. Low is also defensible. +> +> 2. The proposed fix "key the SSH pin to the computer rather than to the IP" is based on a misreading. The pin is already stored per paired computer, not per IP: `PairedHost.sshHostKeyFingerprint`, read through `pinFor(host)` via `graph.hosts.get(host.id)` (ConnectionManager.kt:154-157). What goes wrong is the stale dial target, not how the pin is keyed. +> +> Better fix: +> - In AUTO, when `HostKeyChangedException` is thrown, record and show the warning but keep going to the relay leg, which has its own pinned identity. Keep the hard stop for SSH_ONLY. +> - Change the message to point at the "Only through the relay" setting. +> - Optionally, have the desktop publish its current LAN address (for example in relay.json, or in the relay's `projects.list` reply). The phone could then refresh `host.host` after a successful relay connect. Clear the SSH pin only if the relay-authenticated computer confirms the new host key. + +## A75 + +**The New session account picker lists managed Claude accounts by raw UUID** + +- Severity: **low**; claimed by auditor: low; effort: small; area: critic; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt:316` + +**Evidence** + +The picker renders `Text(id ?: "System account")` for each `snapshot.status?.settings?.claudeAccounts` entry (SessionsTab.kt:316-320). The mirror's settings block carries only `{id, dir}` (agent-status-mirror.ts:184 `claudeAccounts?: { id: string; dir: string }[]`). The same snapshot does carry each account's `label`/`email` in `usage.accounts` (buildMirrorUsage, agent-status-mirror.ts:299-321; parsed into UsageAccount), but the picker never looks there. + +**Proposed fix** + +Resolve display names from `snapshot.status?.usage?.accounts` (label, else email) by accountId, falling back to the id. Longer term, add label/email to MirrorSettings.claudeAccounts. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct. Two refinements: +> +> 1. **Line numbers.** The rows are built at SessionsTab.kt:316 and the `Text(id ?: "System account")` call is at line 319; the source list is set at line 292. +> +> 2. **The fix only works on the desktop.** Only the desktop shell calls setMirrorUsageProvider (src/main/index.ts:3890). src/server/index.ts has no buildMirrorUsage/setMirrorUsageProvider call, so a Server Edition host publishes settings.claudeAccounts but no usage block. Usage can also be empty before the first poll. Looking the name up in `usage.accounts` (label, else email, else id) therefore only helps desktop hosts once usage has loaded. +> +> The durable fix is an additive `label` (and optionally `email`) field on MirrorSettings.claudeAccounts. It should be written by both producers (src/main/index.ts:2112 and src/server/index.ts:460, which already have `a.label` from settings) and parsed into ManagedAccount on Android. Old readers ignore unknown fields. + +## A76 + +**Over direct SSH, opening a Sleeping (Eco-hibernated) session lands on a bare shell with no wake or resume offer** + +- Severity: **low**; claimed by auditor: low; effort: small; area: critic; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:247` + +**Evidence** + +Eco exits the CLI and leaves a shell in the pane. Over the relay, attaching wakes it: host-service calls `remoteViewer.attached`, which fires `agent:wake` (index.ts:3986-3989). Over SSH nothing tells the desktop about the attach. SshHostConnection.wake is `relayOnly(...)` (SshHostConnection.kt:251), and SessionsTab hides 'Wake' unless `capabilities.nodeActions` (SessionsTab.kt:150-156). The phone's own resume offer fires only when `s.fresh` (TerminalController.kt:247), and a hibernated session is not fresh. The app knows both `hibernated` and the sessionId (Launch.resumeCommand) but offers nothing. + +**Proposed fix** + +Show the same resume banner when `snapshot.statusOf(nodeId)?.hibernated == true` on an SSH attach, reusing Launch.resumeCommand with the node's cwd and account. Or route the wake through the relay when a relay leg exists. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two details are overstated or wrong. +> +> 1. **"No in-app way" is too strong.** Settings has a route choice, "Only through the relay" (SettingsScreen.kt:104, `RoutePreference.RELAY_ONLY`). If remote access is set up, switching to it makes the next attach go over the relay, and that attach wakes the node. The user can also type the resume line by hand. The real gap is narrower: in the default AUTO route on the LAN, the app shows nothing for a Sleeping session. +> +> 2. **The proposed fix's signature is wrong.** `Launch.resumeCommand(agent, sessionId)` (Agents.kt:31) takes no cwd or account. None are needed either: the hibernated pane already sits in the node's cwd, and its tmux session env already carries the account's CLAUDE_CONFIG_DIR / CODEX_HOME. +> +> The simplest fix is to widen the condition at TerminalController.kt:247 from `if (s.fresh)` to `if (s.fresh || status?.hibernated == true)`, and word the banner as "Wake ". +> - It stays an offer the user accepts, never typed unasked, matching the existing rule. +> - The desktop's `hibernated` flag clears itself on the resumed CLI's SessionStart / live hook states, so a resume started from the phone leaves the desktop consistent. +> - One weaker spot: the desktop's wake checks that a shell owns the pane before typing, and uses KILL_LINE. This banner does neither, but the user can see the pane before tapping. + +## A77 + +**IME insets are not handled for Android 15's enforced edge-to-edge (targetSdk 35): the terminal gets double bottom padding when the keyboard opens, and other screens have no IME padding at all** + +- Severity: **low**; claimed by auditor: low; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt:77` + +**Evidence** + +targetSdk = 35 (app/build.gradle.kts), so edge-to-edge is enforced on Android 15+. `android:statusBarColor` in themes.xml is ignored and adjustResize no longer resizes the window. TerminalScreen applies `Modifier.padding(padding).imePadding()` (line 77) without `consumeWindowInsets(padding)`. Material3 Scaffold only observes consumed insets and does not consume them for content: the material3 1.8.2 bytecode shows `onConsumedWindowInsetsChanged` and `exclude`, but no `consumeWindowInsets`. The bottom inset is therefore navigation bar plus IME height, and the IME inset already includes the navigation bar. PairScreen's paste field and SettingsScreen's 'Relay API' field, at the bottom of a scrolling Column, have no imePadding at all. + +**Proposed fix** + +Use `Modifier.padding(padding).consumeWindowInsets(padding).imePadding()` in TerminalScreen, and add `imePadding()` to the scrolling columns in PairScreen and SettingsScreen. Call `enableEdgeToEdge()` so behaviour is the same on API < 35. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. **Scope is Android 15+ (API 35) devices only, not "all devices with targetSdk 35".** +> - On API 26-34 the decor still fits system windows because the app never enables edge-to-edge. +> - There the root layout consumes the system-bar and IME insets, so Compose's `WindowInsets` read 0. Scaffold's padding and `imePadding()` are both no-ops, and `adjustResize` really resizes the window. +> - So TerminalScreen, Pair and Settings behave correctly there. +> +> 2. **`enableEdgeToEdge()` is optional consistency, not part of the fix.** +> - The needed change is `consumeWindowInsets(padding)` before `imePadding()` at `TerminalScreen.kt:77`, plus `imePadding()` on the scrolling Columns at `PairScreen.kt:92` and `SettingsScreen.kt:69`. +> - Adding `enableEdgeToEdge()` would move API 26-34 onto the inset path as well, so every screen would then depend on its inset handling. +> +> 3. **The PairScreen claim is narrower than stated.** +> - The paste field (`PairScreen.kt:109-114`) sits fairly high on the screen: after two text lines and a button, and it is 120dp tall. On a typical portrait phone it usually stays above the keyboard. +> - What is more likely covered is the "Use this code" button beneath it (line 115), and the field itself in landscape or on short screens. +> - The Settings "Relay API" field is the stronger case. +> +> 4. **Version note.** The bytecode cited (material3 1.8.2) is the JetBrains desktop stub. The Android build uses material3 from BOM 2025.06.00 (1.3.x), which has the same Scaffold inset logic. + diff --git a/docs/android-handover.md b/docs/android-handover.md new file mode 100644 index 0000000000..eb75276b0f --- /dev/null +++ b/docs/android-handover.md @@ -0,0 +1,272 @@ +# Android companion: handover (2026-09-26) + +Read this first if you are picking up the Android work. It records where the work stands, what has +and has not been verified, what is known to be broken, and the plan in order. The full list of +findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](android-audit-2026-09.md) +(IDs `A01`–`A77`). The design notes are [`android.md`](android.md) and the user-facing readme is +[`../android/README.md`](../android/README.md). + +## TL;DR + +- The Android app exists (`android/`), and **CI built its debug APK successfully with AGP** (run + [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730), artifact + `nodeterm-android-debug`, which expires 2026-12-24). The protocol tests pass in CI too. +- **It has never been run on a phone.** A 170-agent audit found **9 distinct release blockers**. The + worst: typing in a direct-SSH terminal drops the connection (`A01`), and the direct-SSH session + list is empty on every real desktop (`A02`). Do not hand the APK to anyone until the next section + is done. +- The next session should **fix the blockers**, then do a **device test pass** using the checklist + below, then work down the medium findings. No PR is open, and none should be opened unless the + user asks. + +## Where things are + +| | | +|---|---| +| Repo / branch | `CPlusPlus17/nodeterm`, branch `claude/android-ios-parity-75kfem` (pushed) | +| Commits | `a0c07e6` protocol module + the two desktop relay verbs; `2f58918` the Compose app, docs, CI, desktop copy; plus the handover commit that adds this file | +| PR | none (do not open one unless asked) | +| CI | `.github/workflows/android.yml`: **Protocol** (JVM + desktop interop) and **App** (`assembleDebug`). Both green on `2f58918` | + +What is in the tree: + +- `android/protocol` is pure Kotlin/JVM with no Android dependency. It holds the NaCl port, the relay + client, the host RPC, pairing, direct SSH over sshj, and the parsers. It has 41 tests. The + interop tests run the desktop's own `connectHostSession` / `createPairingService` through + `android/protocol/src/test/interop/host-fixture.ts`; the SSH tests use Apache MINA sshd with a + sandboxed tmux. +- `android/app` is the Compose app: pairing, hosts, the Sessions/Board/Inbox/Usage tabs, the + terminal (xterm.js in a WebView), settings, and WorkManager notifications. +- `android/tools/typecheck` is an offline Kotlin type-check of the app for sandboxes without Google + Maven. See its README for what it can and cannot catch. +- Desktop side: + - `src/main/remote/host-service.ts` gained the relay verbs **`approvals.answer`** and + **`inbox.ack`**, tested in `src/main/remote/host-inbox-verbs.test.ts`. + - `src/main/index.ts` extracted `answerPermission` and wired `hostBridge.inbox`. + - The renderer copy now names both phone apps (`PhonePairPopover.tsx`, `PhoneSection.tsx`, + `lib/links.ts`). + - CLAUDE.md gained an Android paragraph under Conventions → three surfaces, and CONTRIBUTING.md + and README.md gained notes. + +## What is verified, and what is not + +Verified: + +- The protocol against the desktop's real code: the relay handshake, SAS, approval wait, + `projects.list`, attach/snapshot/input/resize/exit, scroll, node actions, board verbs, + `approvals.answer`, `inbox.ack`, registration, and pairing (E2EE `/pair`, the key landing in + `authorized_keys`). +- Relay security properties: no re-key, reflection, replay/reorder, and a foreign key. +- SSH transport against a real SSH server with tmux. +- The debug APK builds with AGP in CI. + +**Not verified:** + +- **Anything on a device.** No install, no pairing, no terminal use has happened on a phone. +- The relay join request shape. The client sends `{deviceToken, hostId}` to `POST /v1/relay/join` + and accepts `pairingToken | token | joinToken` in the reply, but it has not been checked against + the live backend (the backend repo is not here). +- Release/minified builds. R8 is off; see `A37`. +- **Caveat on the tests:** their fixtures hand-copy some desktop shapes: the mirror, the + `projects.list` blob and the `~/.nodeterm` files (`A64`). That is how `A02` passed its test while + being wrong on every real desktop. + +## Environment notes for a cloud session + +- **Google Maven is blocked** (`dl.google.com` / `maven.google.com` answer 403 from the egress + proxy). AGP cannot resolve, so `./gradlew :app:assembleDebug` does not work in the sandbox. Do not + retry it or route around it. Instead: + - Type-check the app with `cd android/tools/typecheck && gradle compileKotlin`. It passed on the + handover commit. + - Rely on CI for the real AGP build: push, then read the Android workflow run. +- Maven Central sometimes answers 429. Retrying works. +- System `gradle` is 8.14.3 on JDK 21 in the sandbox; the wrapper pins 8.14.3 and CI uses JDK 17. +- The protocol tests need `npm ci --ignore-scripts` at the repo root first (for the esbuild-bundled + interop fixture) and a `tmux` on PATH. Run them with + `cd android/protocol && gradle test --offline`, or `cd android && ./gradlew -p protocol test`. + Without node the interop tests skip. +- Desktop checks for any change to `src/main/remote/*`: + - `npx vitest run src/main/remote/host-inbox-verbs.test.ts` + - `npm run typecheck` +- Test-writing traps already hit once: + - A JUnit 5 test whose body returns non-Unit is silently skipped. Use `runBlocking { … }`. + - In the SSH test harness, closing stdin on a pty command sends ^D and kills tmux. + - An exact-match tmux **pane** target is `=name:`, not `=name`. tmux 3.4 answers "can't find + pane". + +## The findings in one page + +77 findings survived verification, and none were refuted. Merging the duplicates (`A01`=`A04`, +`A05`=`A17`=`A23`, `A09`=`A28`, `A11`=`A19`) leaves 72 distinct items. The severity counts below are +over all 77: 8 high and 2 medium release blockers, 26 other medium, and 41 low. The work packages +below use the audit IDs. + +### WP1: unambiguous blockers (small, do first) + +| ID | Problem | Fix direction | +|---|---|---| +| A01 / A04 | `SshStream.write/resize` do socket I/O on the **main thread**. That throws `NetworkOnMainThreadException`, which `runCatching` swallows after sshj has advanced its cipher state, so the next packet kills the connection. Keyboard open, rotation, A−/A+ and the key chips all trigger it. `SshHostConnection.close()` from click handlers (`HostsScreen` Forget, `SettingsScreen` route change, `PairScreen`) leaks the socket the same way. | Give `SshStream` one single-thread executor. Route write, resize and scroll through it: this keeps ordering between main-thread chips and JavaBridge-thread input. Run `close()` / `client.disconnect()` off the main thread (`Dispatchers.IO`). Catch `RuntimeException` by closing the transport, not by swallowing it. | +| A02 | The SSH prelude looks for the desktop's data in `…/nodeterm`, but the desktop's userData is **`…/node-terminal`** (package `name`, no top-level `productName`). Confirmed: `src/core/agents/hook-endpoint-failover-sh.ts:79-80` uses `node-terminal`. | Probe `node-terminal` first and keep `nodeterm` as a fallback, in `SshScripts.kt:38` and its KDoc at :18-19. Fix the `SshTransportTest` fixture (:154) to the real name. The same wrong path is in `docs/uninstall.md:24` and `scripts/uninstall.sh:64` on the desktop side; fix those too. | +| A03 | The SSH tmux client starts without a UTF-8 locale, so ╭, accents, CJK and emoji come out as `_`. | In the attach script, export `LANG=en_US.UTF-8` unless LC_ALL/LC_CTYPE/LANG already says UTF-8 (the rule of `resolveLocaleLang` in `pty-manager.ts`). Add `-u`. Add a test with LANG unset. | +| A06 (+A35) | **Desktop:** `approvals.answer` writes the answer even after the hook's 45 s hold ended, then reports success and emits the synthetic answered event, which clears NEEDS YOU everywhere. The canvas button has the same pre-existing gap. `answered:false` cannot tell "gone" from "write failed". | In `answerPermission` (`src/main/index.ts`), check that `.json` still exists before writing (`fs.access` locally, `test -f` in the same remote command over SSH). Return false and skip the synthetic event when it is gone. Reply with a reason (`gone` / `failed`). On the phone, map `gone` for a still-blocked node to "open the session". | +| A10 | CI debug APKs are signed with a fresh debug key each run, so updating means uninstalling, which wipes pairings. | Commit a debug keystore (`android/.gitignore` already has `!debug.keystore`), point `signingConfigs.debug` at it, and say in the README that it is a public debug key. | + +### WP2: blockers that need a decision (recommendations in bold) + +| ID | Problem | Options | +|---|---|---| +| A05 / A17 / A23 (+A30) | The background `InboxWorker` can make the phone's **first** relay handshake, which raises the desktop's SAS approval dialog while the phone shows nothing. It repeats every 15 min, and pressing Deny is not respected: the phone re-dials about 8 s later. | **Add a per-host "relay approved" flag, set only after a foreground relay connect succeeds. The worker never enters the relay leg until it is set.** After "Awaiting approval" or a refusal, back off and stop re-dialing until the user acts. (Aborting on AwaitingApproval does NOT help: the desktop raises the dialog at handshake completion, `standing-host.ts:256-261`.) | +| A07 | Pairing never pins the phone's relay (box) key, and Auto prefers SSH on the LAN. So the one-time relay approval never happens while the user is at the desk, and the first remote connect waits 5 min and fails. The desktop design has the same gap, so iOS likely shares it (`standing-host.ts:424-434`). | **(b) Send the phone's persistent box public key inside the sealed `/pair` body and have `pairing-service.ts` pin it (the standing host's `isPinned` store) when pairing succeeds.** This is additive: an older desktop ignores the field. Raise it for iOS with @eneskirca. Interim alternative (a): right after pairing, run one relay handshake on the LAN and show the SAS on PairScreen. Either way, PairScreen must stop saying "✓ Reachable from anywhere" before approval. | +| A08 (+A72) | A cold attach over direct SSH (`new-session -A` with no `-e` env) creates the desktop's tmux session **without the hook env**. An agent resumed there never reports status, the desktop never repairs it, and if the desktop's client started the tmux server the session inherits **another node's** `NODETERM_NODE_ID` (misattribution). | **Do not cold-create over SSH. When `hasSession` says no, offer "Open on your computer" and use the relay when available, where `attachDetached` injects the env.** The alternative is to pass the full hook env with `-e` (NODETERM_NODE_ID, NODETERM_HOOK_ENDPOINT, NODETERM_HOOK_VERSION, …); that duplicates desktop logic and drifts. | +| A09 / A28 | Nodes of the desktop's **SSH projects** live on a third machine, but over direct SSH the phone attaches to the desktop's LOCAL tmux. That creates an empty phantom session and offers to resume on the wrong machine. | **Over direct SSH, don't attach nodes whose project is an ssh-ref. Show them with "open via remote access" and use the relay.** Check first how the desktop's `pty.attach` handles a remote node. | + +### WP3: device test pass (after WP1 + WP2) + +Install the CI APK (from the new stable debug key) on a real phone and walk the checklist below. +Record results in `docs/android.md` → "What is verified". Anything that fails becomes a new finding. + +### WP4: medium bugs (after the device pass) + +`A11`/`A19` notification tap ignores the target computer · `A12` relay `sendKeys` into a pty that +does not exist yet · `A13` registering while attached lets the desktop attach with `-D` and detach +the phone · `A14` inline (cwd-less) project sessions never registered · `A15` resume drops the +Claude account (and cwd over relay) · `A16` project permission mode/default account ignored · +`A18` no lifecycle handling (8 s poll + relay stream keep running in background) · `A20` cancelled +connect leaks SSH and counts as failure · `A21` denied notification permission never re-requested · +`A22` back stack not saved across recreation · `A24` SecureStore overwrites identity on any decrypt +error (**data loss: fix early**) · `A31` silently dead SSH peer wedges "On your network" · +`A33` Windows host new-session cwd/account · `A34` Ctrl chip not applied to input-bar text · +`A36` terminal stays "Disconnected" after auto-reconnect. + +### WP5: iOS parity gaps + +`A25` real push (needs an FCM leg in the backend; also notification actions) · `A26` new session +and board edits over direct SSH · `A27` connect straight to a Linux host / Server Edition (also +probe `~/.nodeterm-server`) · `A29` source-control screen (verbs already in `HostConnection`) · +`A32` link opening and text selection in the terminal · `A55` merged inbox/usage across computers · +`A56` "Always allow" · `A57` multi-select questions · `A58` pace line / context indicator · +`A59` dictation · `A75` account picker shows UUIDs · `A76` wake/resume for Sleeping sessions over SSH. + +### WP6: low-severity, docs and CI + +The rest of `A37`–`A77`: R8 rules, lock-screen notification content, OSC 52 size cap, pairing +response size cap, SSH TOFU pin timing, device-to-device migration, the CI path filters and trigger +conventions, dependabot/CodeQL/wrapper validation, test gates on macOS/Windows, doc claims, the +tsconfig coverage of the interop fixture, and `ANDROID_APP_URL` pointing at a folder not on the +default branch yet. `A65` is partly obsolete (CI did build the APK). + +### WP7: PR + +When the user asks: open a PR from this branch, following the repo's `pr-writing` rules and any PR +template. Mention @eneskirca for the mobile implications: the pairing-key pin from `A07`, and +`approvals.answer` / `inbox.ack`, which the iOS app can adopt. + +## Decisions the user still has to make + +1. **A07**: pin the phone's relay key at pairing (desktop + protocol change, additive), or the + interim "one handshake right after pairing" flow? Recommended: pin at pairing. +2. **A08**: refuse cold-create over direct SSH, or inject the hook env? Recommended: refuse and + route to the computer. +3. **A09**: hide or relay-route SSH-project nodes over direct SSH? Recommended: relay-route. +4. **Distribution**: a committed public debug key for sideloading (`A10`), and later a real release + signing key plus a store listing. Nothing exists yet for the latter. + +## Device checklist (owed; nothing here has been run) + +Run each on a real phone against a real desktop, and note OS versions. + +1. Install the CI APK. Install the next CI APK over it without uninstalling (checks `A10`). +2. Pair by QR with a macOS desktop, then with a Linux desktop, then with a Windows desktop (the + Windows QR carries `"ssh":false`, so pairing is relay-only). +3. Pair by pasting the code, and by opening a `nodeterm://pair?code=…` link. +4. On the LAN (Auto route): the session list shows the desktop's projects and sessions (checks + `A02`). +5. Open a terminal and type with the soft keyboard. Rotate. Use A−/A+ and each key chip. The + connection must survive (checks `A01`). +6. Non-ASCII renders correctly: Claude's rounded borders, accented letters, CJK, emoji (checks + `A03`). +7. Swipe to scroll tmux history. Copy via tmux selection (OSC 52 reaches the Android clipboard). +8. "Sized to another screen · Fit this screen" appears when the desktop is larger, and Fit works. +9. On cellular (not the LAN): connect through the relay. The desktop shows the SAS; the phone shows + the same code; approve. Reconnect later: no second prompt. +10. With the phone backgrounded for 15+ minutes, no unexpected SAS dialog appears on the desktop + (checks `A05`). +11. Approve and deny a held Claude permission from the Inbox within 45 s. Retry after the hold + expired: the phone must not report success (checks `A06`). +12. Answer an AskUserQuestion from the Inbox. +13. Open a finished session on the phone: the desktop's unread dot clears. +14. Board: move a card, add/remove/create a label; the desktop board updates without a reload. +15. New session (Claude, shell) from the phone: the node appears on the canvas, the agent runs, and + status badges update. +16. Wake / refresh / rename / end session from the phone. +17. Reboot the desktop, then open a session from the phone: the resume offer appears and resumes + the right conversation under the right account (checks `A15`). +18. A background notification arrives (within ~15 min); tapping it opens that computer (checks + `A11`). +19. Kill the app process in the background (via developer options) and reopen it: the screen and + back stack are sensible (checks `A22`). +20. Forget a host; change a host's route in Settings; pair two computers at once. +21. Put the desktop to sleep while the phone is connected over SSH: the phone notices and falls back + (checks `A31`). +22. Android 15 edge-to-edge: the keyboard does not double-pad the terminal (checks `A77`). +23. Light and dark system theme; a tablet or foldable if available. + +## Conventions for whoever continues + +- Develop on `claude/android-ios-parity-75kfem`, and push with + `git push -u origin claude/android-ios-parity-75kfem`. Commit messages end with the session's + attribution trailer lines. +- CLAUDE.md → Conventions → three surfaces has the Android rule. A change to a `host-service.ts` + verb, the `projects.list` blob, the pairing payload, the mirror file or the `~/.nodeterm` file + contracts owes the Android client and its interop fixture **in the same change**. +- Every fix gets a regression test where the layer allows. The protocol layer is JVM-testable, and + the WP1 SSH fixes are testable against the MINA/tmux harness. Fix the test fixtures that + hand-copied wrong shapes (`A02`, `A64`) so they would have failed. +- Before pushing, run: + - the protocol tests; + - the offline type-check; + - for desktop changes, the affected vitest files and `npm run typecheck`. + + After pushing, confirm the Android workflow is green. +- Update this file, `docs/android.md` and the audit index when work lands. Mark a finding as fixed + by editing its index row, e.g. `A02 ✅ fixed in `. +- Never claim device verification that did not happen. + +## Follow-up prompt + +Paste this into the next session. Edit the "Decisions" line first if you want different choices +than the recommendations. + +```text +Continue the Android companion work on branch claude/android-ios-parity-75kfem of CPlusPlus17/nodeterm. + +Start by reading docs/android-handover.md (status, plan, environment notes, conventions) and +docs/android-audit-2026-09.md (findings A01–A77 with evidence and fixes). Re-read the cited code +before changing it; the audit is at commit 2f58918. + +Do, in order, committing in small logical commits and pushing when each work package is green: +1. WP1 blockers: A01/A04 (SSH I/O off the main thread, one serial executor in SshStream, close() + off main), A02 (desktop userData dir is node-terminal; fix the SSH prelude, its test fixture, + and docs/uninstall.md + scripts/uninstall.sh), A03 (UTF-8 locale for the SSH tmux client), + A06 + A35 (approvals.answer must not report success after the hold expired; reason in the reply; + phone maps "gone" to open-session), A10 (committed public debug keystore so CI APKs update in place). +2. WP2 with these decisions: A05/A17/A23/A30 — background worker never makes a first relay + handshake (per-host relayApproved flag set by a successful foreground relay connect) and a denied + or pending approval backs off instead of re-dialing; A07 — send the phone's box public key in + the sealed /pair body and have the desktop pin it on successful pairing (additive; keep older + desktops working), and fix PairScreen's "reachable from anywhere" claim; A08 — never cold-create + a tmux session over direct SSH, offer to open it via the relay instead; A09/A28 — nodes of the + desktop's SSH projects are opened via the relay, never against the desktop's local tmux. +3. Then WP4 (medium bugs), starting with A24 (SecureStore identity overwrite). + +For every fix add a regression test where the layer allows (protocol tests are JVM + interop + +MINA/tmux). Before each push run: the protocol tests (cd android/protocol && gradle test --offline, +after npm ci --ignore-scripts at the repo root), the offline app type-check +(cd android/tools/typecheck && gradle compileKotlin), and for desktop changes the affected vitest +files plus npm run typecheck. After each push, check that the Android GitHub workflow is green. +Google Maven is blocked in this sandbox — do not try to reach it; CI does the real AGP build. +Keep docs/android.md, android/README.md, docs/android-handover.md and the audit index in sync +(mark fixed findings). Follow CLAUDE.md, including the Android rule under Conventions. Do not open a +PR unless I ask. At the end, report what was fixed, how it was verified, what is still unverified +(nothing has run on a device yet), and the updated next steps. +``` diff --git a/docs/android.md b/docs/android.md index 35cf336333..1f2ee77625 100644 --- a/docs/android.md +++ b/docs/android.md @@ -83,7 +83,15 @@ unchanged. (tmux 3.4 answers "can't find pane"). The **app** module is built by CI (`.github/workflows/android.yml`) against the runner's Android -SDK; it has no instrumented tests yet. +SDK. The first run, [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730) +on `2f58918`, built the debug APK successfully. The app has no instrumented tests and has **not been +run on a device**. An audit of the code found release blockers, all still open: +[`android-audit-2026-09.md`](android-audit-2026-09.md). The plan, the device checklist and the +decisions still open are in [`android-handover.md`](android-handover.md). + +Two test caveats. The fixtures hand-copy some desktop shapes: the mirror, the `projects.list` blob +and the `~/.nodeterm` files (audit `A64`). Because of that, one wrong path (`A02`, the desktop's +userData directory is `node-terminal`, not `nodeterm`) passed its test. ## Known gaps From af1f8207cb459c2f5bc4fd6b9a566aed36d84c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:20:15 +0000 Subject: [PATCH 004/284] fix(android): keep SSH terminal I/O off the main thread (A01/A04) SshStream.write/resize wrote to the socket on the caller's thread, which includes the Android main thread (keyboard/rotation resize, A-/A+, key chips, Resume, Fit). There that throws NetworkOnMainThreadException after sshj has advanced its sequence number and cipher, runCatching swallowed it, and the next packet dropped the whole connection. - One single-thread writer per SshStream carries write, resize and scroll, so main-thread chips and JavaBridge-thread typing stay in order. - A non-IO exception out of the write path tears the transport down and reports it through onClosed instead of being swallowed. - SshHostConnection.close() and HostSession.disconnect() no longer touch the socket on the caller's thread; a failed SSH_MSG_DISCONNECT still closes the socket instead of leaking it and sshj's reader thread. Tests: a JVM stand-in for StrictMode (a socket that throws when used from a thread marked "main") drives resize/write/close from that thread over real MINA sshd + tmux, and a poisoned transport must surface as onClosed. The first test fails when writes run inline (mutation checked). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../android/conn/ConnectionManager.kt | 7 +- .../protocol/ssh/SshHostConnection.kt | 110 ++++++++++++++++-- .../dev/nodeterm/protocol/SshTransportTest.kt | 103 +++++++++++++++- 3 files changed, 209 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index 5885e1ac38..608ef81273 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -230,10 +230,15 @@ class HostSession(val hostId: String, private val graph: AppGraph) { } } + /** + * Drop the connection. Callers include click handlers on the MAIN thread (Forget, a route change, + * re-pairing), so the close itself runs on [Dispatchers.IO]: a socket write there throws + * `NetworkOnMainThreadException` and would leak the socket (audit A01). + */ fun disconnect() { val c = conn conn = null - runCatching { c?.close() } + if (c != null) scope.launch(Dispatchers.IO) { runCatching { c.close() } } _state.value = ConnState.Idle } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index eab1a1a068..2121fd07e1 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -22,8 +22,10 @@ import dev.nodeterm.protocol.model.ProjectInfo import dev.nodeterm.protocol.model.ProjectsParser import dev.nodeterm.protocol.model.ProjectsSnapshot import dev.nodeterm.protocol.pairing.SshIdentity +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import net.schmizz.sshj.DefaultConfig @@ -38,7 +40,11 @@ import java.security.MessageDigest import java.security.PrivateKey import java.security.PublicKey import java.util.Base64 +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.TimeUnit +import javax.net.SocketFactory /** Trust-on-first-use pin for the computer's SSH host key (`SHA256:`, OpenSSH's format). */ interface HostKeyPin { @@ -74,7 +80,7 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } /** True while the SSH transport is up. */ - val isConnected: Boolean get() = client.isConnected && client.isAuthenticated + val isConnected: Boolean get() = client.isConnected && client.isAuthenticated && client.socket?.isClosed != true /** * Run a script through `/bin/sh -c` and return stdout (bounded wait). A dead transport is @@ -191,6 +197,23 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos lastCwds = snapshot.projects.flatMap { p -> p.nodes.mapNotNull { n -> (n.cwd ?: p.cwd)?.let { n.id to it } } }.toMap() } + /** + * The terminal stream over one exec'd pty channel. + * + * EVERY socket write goes through ONE serial executor owned by the stream ([io]), never the + * caller's thread (audit A01/A04). [TerminalStream] is a non-suspend contract, and its callers + * include the Android MAIN thread (resize on a keyboard/rotation/A−/A+, the ^C key chips, Resume, + * Fit) as well as the WebView's JavaBridge thread (typed input). A socket write on the main thread + * throws `NetworkOnMainThreadException` — and it throws AFTER sshj's `Encoder.encode` has advanced + * the packet sequence number and the cipher stream, so the transport is corrupt from then on and + * the very next packet drops the connection. One executor also keeps the two producers IN ORDER + * (a ^C chip can neither overtake nor split typed bytes), which fixing each call site would not. + * + * Failures are never swallowed: a [RuntimeException] out of the write path means the transport's + * state is unknown, so the whole connection is torn down ([breakTransport]) and reported through + * `onClosed`; an [java.io.IOException] with the transport still up is only this CHANNEL closing + * (tmux exited), which the reader thread reports as the stream's exit. + */ private inner class SshStream( private val session: Session, private val cmd: Session.Command, @@ -198,6 +221,10 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos private val sink: TerminalSink ) : TerminalStream { private val stdin: OutputStream = cmd.outputStream + private val io: ExecutorService = Executors.newSingleThreadExecutor { r -> + Thread(r, "nodeterm-ssh-writer").apply { isDaemon = true } + } + @Volatile private var ended = false fun start() { Thread({ @@ -213,23 +240,45 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos // channel closed } runCatching { cmd.join(2, TimeUnit.SECONDS) } + ended = true + io.shutdown() sink.onExit(cmd.exitStatus) runCatching { session.close() } }, "nodeterm-ssh-stream").apply { isDaemon = true }.start() } - override fun write(text: String) { - synchronized(this) { - runCatching { - stdin.write(text.toByteArray(Charsets.UTF_8)) - stdin.flush() + /** Queue [block] on the stream's writer; a stream that has ended drops it. */ + private fun enqueue(block: () -> Unit) { + if (ended) return + try { + io.execute { + if (ended) return@execute + try { + block() + } catch (e: java.io.IOException) { + if (!isConnected) fireClosed(e.message) + // else: this channel closed under us; the reader thread reports the exit. + } catch (e: Throwable) { + ended = true + breakTransport(e) + } } + } catch (_: RejectedExecutionException) { + // detached or exited: nothing left to write to + } + } + + override fun write(text: String) { + val bytes = text.toByteArray(Charsets.UTF_8) + enqueue { + stdin.write(bytes) + stdin.flush() } } override fun resize(cols: Int, rows: Int) { // The exec'd channel is a SessionChannel, which is also a Session.Shell (the window-change owner). - runCatching { (session as Session.Shell).changeWindowDimensions(cols.coerceAtLeast(1), rows.coerceAtLeast(1), 0, 0) } + enqueue { (session as Session.Shell).changeWindowDimensions(cols.coerceAtLeast(1), rows.coerceAtLeast(1), 0, 0) } } /** The same SGR wheel event the relay host writes (host-service.ts `handleScroll`): tmux's @@ -240,6 +289,19 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } override suspend fun detach() { + // Close BEHIND the writes already queued, so a keystroke typed just before leaving lands. + val done = CompletableDeferred() + try { + io.execute { + runCatching { session.close() } + done.complete(Unit) + } + io.shutdown() + withTimeoutOrNull(3_000) { done.await() } + } catch (_: RejectedExecutionException) { + // already ended + } + ended = true withContext(Dispatchers.IO) { runCatching { session.close() } } } @@ -248,6 +310,28 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } } + /** + * An exception escaped sshj's write path that was not a plain I/O failure: the packet sequence + * number and cipher may already have advanced, so every later packet would be garbage to the + * server. Tear the transport down now and say so, rather than leaving a connection that looks + * alive until its next packet. + */ + private fun breakTransport(e: Throwable) { + disconnectQuietly() + fireClosed("the SSH connection broke (${e.message ?: e.javaClass.simpleName})") + } + + /** `client.disconnect()`, and if that could not finish (its own write failed), the socket itself. */ + private fun disconnectQuietly() { + try { + client.disconnect() + } catch (_: Throwable) { + // fall through: make sure the socket and sshj's reader thread do not leak + } + // Idempotent, and `Socket.isConnected` stays true after a close, so do not gate on it. + runCatching { client.socket?.close() } + } + override suspend fun wake(nodeId: String) { relayOnly("Waking a sleeping session") } override suspend fun refresh(nodeId: String) { relayOnly("Refreshing a terminal view") } override suspend fun rename(nodeId: String, title: String) { relayOnly("Renaming a session") } @@ -298,9 +382,14 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos onClosed = listener } + /** + * Intentional close. Never on the caller's thread: it is called from click handlers (Forget, a + * route change, re-pairing) on the Android main thread, where `SSH_MSG_DISCONNECT` would throw + * `NetworkOnMainThreadException` before sshj closes the socket — leaking it and its reader thread. + */ override fun close() { closedFired = true // an intentional close is not a drop - runCatching { client.disconnect() } + Thread({ disconnectQuietly() }, "nodeterm-ssh-close").apply { isDaemon = true }.start() } companion object { @@ -317,9 +406,11 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos user: String, identity: SshIdentity, pin: HostKeyPin, - connectTimeoutMs: Int = 8_000 + connectTimeoutMs: Int = 8_000, + socketFactory: SocketFactory? = null ): SshHostConnection { val client = SSHClient(DefaultConfig()) + if (socketFactory != null) client.socketFactory = socketFactory var mismatch: HostKeyChangedException? = null client.addHostKeyVerifier(object : HostKeyVerifier { override fun verify(hostname: String, port: Int, key: PublicKey): Boolean { @@ -353,6 +444,7 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos client.connection.keepAlive.keepAliveInterval = 20 } catch (e: Exception) { runCatching { client.disconnect() } + if (client.isConnected) runCatching { client.socket?.close() } mismatch?.let { throw it } throw HostException("Couldn't connect over SSH to $user@$host:$port (${e.message ?: e.javaClass.simpleName}).") } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index 73b5a4a132..58e17c3458 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -187,7 +187,51 @@ class SshTransportTest { assertEquals(0, code, out) } - private fun connect(pin: HostKeyPin = MemoryPin()) = SshHostConnection.connect("127.0.0.1", port, "dev", identity, pin) + private fun connect(pin: HostKeyPin = MemoryPin(), factory: javax.net.SocketFactory? = null) = + SshHostConnection.connect("127.0.0.1", port, "dev", identity, pin, socketFactory = factory) + + /** + * Android's StrictMode, reproduced on the JVM (which has no BlockGuard): a socket whose streams + * throw a RuntimeException — like `NetworkOnMainThreadException` — when used from a thread marked + * as "main". The JVM tests could not see audit A01/A04 without this. + */ + private class MainThreadGuard : javax.net.SocketFactory() { + val main = ThreadLocal.withInitial { false } + val sockets = java.util.Collections.synchronizedList(ArrayList()) + val violations = java.util.concurrent.atomic.AtomicInteger() + @Volatile var poisonAll = false + + private fun check() { + if (poisonAll) throw IllegalStateException("simulated failure after the cipher advanced") + if (main.get()) { + violations.incrementAndGet() + throw IllegalStateException("NetworkOnMainThreadException (simulated)") + } + } + + private fun guarded(): java.net.Socket = object : java.net.Socket() { + override fun getOutputStream(): OutputStream { + val real = super.getOutputStream() + return object : OutputStream() { + override fun write(b: Int) { check(); real.write(b) } + override fun write(b: ByteArray, off: Int, len: Int) { check(); real.write(b, off, len) } + override fun flush() { check(); real.flush() } + override fun close() = real.close() + } + } + }.also { sockets.add(it) } + + override fun createSocket(): java.net.Socket = guarded() + override fun createSocket(host: String, port: Int) = guarded().apply { connect(java.net.InetSocketAddress(host, port)) } + override fun createSocket(host: String, port: Int, l: java.net.InetAddress, lp: Int) = createSocket(host, port) + override fun createSocket(host: java.net.InetAddress, port: Int) = guarded().apply { connect(java.net.InetSocketAddress(host, port)) } + override fun createSocket(a: java.net.InetAddress, p: Int, l: java.net.InetAddress, lp: Int) = createSocket(a, p) + + fun onMain(block: () -> T): T { + main.set(true) + try { return block() } finally { main.set(false) } + } + } private class MemoryPin(var value: String? = null) : HostKeyPin { override fun pinned() = value @@ -249,6 +293,63 @@ class SshTransportTest { } } + @Test + fun `resizes and key chips from the main thread never touch the socket there, and keep order`() = runBlocking { + // A01/A04: before the fix the resize threw on the "main" thread after sshj had advanced its + // cipher state, and the next packet dropped the connection. + val guard = MainThreadGuard() + val conn = connect(factory = guard) + var closedReason: String? = null + conn.setOnClosed { closedReason = it ?: "closed" } + try { + conn.listProjects() + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + Thread.sleep(400) + guard.onMain { + stream.resize(90, 28) + stream.write("echo ma") + stream.write("in_\$((5*5))\r") + stream.resize(100, 30) + } + sink.waitFor("main_25") + // The connection is still usable for other work (listing, the Inbox) afterwards. + conn.listProjects() + assertEquals(0, guard.violations.get(), "no socket I/O ran on the main thread") + assertEquals(null, closedReason) + stream.detach() + } finally { + guard.onMain { conn.close() } + } + // close() from the main thread still really closes the socket (it used to leak). + val end = System.currentTimeMillis() + 5_000 + while (guard.sockets.any { !it.isClosed } && System.currentTimeMillis() < end) Thread.sleep(50) + assertTrue(guard.sockets.all { it.isClosed }, "close() from the main thread leaked the socket") + assertEquals(0, guard.violations.get()) + } + + @Test + fun `a write failure that corrupts the transport closes the connection instead of hiding it`() = runBlocking { + val guard = MainThreadGuard() + val conn = connect(factory = guard) + val closed = java.util.concurrent.CountDownLatch(1) + conn.setOnClosed { closed.countDown() } + try { + conn.listProjects() + val stream = conn.attach("term-a-1", 100, 30, Sink()) + Thread.sleep(300) + // Every socket write now fails with a RuntimeException, on whatever thread it runs — + // including the stream's own writer: the transport must be torn down, not ignored. + guard.poisonAll = true + stream.write("x") + assertTrue(closed.await(10, TimeUnit.SECONDS), "the broken transport was reported through onClosed") + assertFalse(conn.isConnected) + } finally { + guard.poisonAll = false + conn.close() + } + } + @Test fun `attaching an unknown node reports a cold start and creates the session`() = runBlocking { connect().use { conn -> From e7c22ebe9789fbe0712da73f8074dadc7529bfe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:22:05 +0000 Subject: [PATCH 005/284] fix(android,uninstall): the desktop's userData dir is node-terminal (A02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron names userData after package.json `name` ("node-terminal"); the top-level package.json has no productName, and electron-builder only copies extraMetadata into the packaged package.json. The desktop's own hook shell already walks `…/node-terminal`. The Android SSH prelude, docs/uninstall.md and scripts/uninstall.sh used `…/nodeterm`, so every direct-SSH listing came back empty and the uninstaller missed the data. - SSH prelude probes node-terminal first (macOS + XDG), nodeterm after it as a legacy fallback; KDoc corrected. - A listing with no userData dir is now an explicit HostException rather than an empty computer. - SshTransportTest's fixture uses the real directory name, so it would have caught this; new SshScriptsTest runs the prelude under /bin/sh against Linux, macOS, XDG, legacy and both-present HOMEs. - uninstall.sh/.md remove node-terminal (and legacy nodeterm) userData, caches, logs and either Keychain spelling; ssh-agent.ts comment fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../protocol/ssh/SshHostConnection.kt | 7 ++ .../dev/nodeterm/protocol/ssh/SshScripts.kt | 10 ++- .../dev/nodeterm/protocol/SshScriptsTest.kt | 65 +++++++++++++++++++ .../dev/nodeterm/protocol/SshTransportTest.kt | 3 +- docs/uninstall.md | 6 +- scripts/uninstall.sh | 26 ++++++-- src/main/remote-ssh/ssh-agent.ts | 5 +- 7 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshScriptsTest.kt diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index 2121fd07e1..b0798f5a25 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -109,6 +109,9 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos val meta = if (metaEnd >= 0) raw.substring(0, metaEnd) else "" val blob = if (metaEnd >= 0) raw.substring(metaEnd + SshScripts.META_END.length).removePrefix("\n") else raw val ud = meta.lineSequence().firstOrNull { it.startsWith("ud=") }?.removePrefix("ud=")?.takeIf { it.isNotBlank() } + // No userData dir is not "a computer with no sessions": it means we are looking in the wrong + // place (audit A02 shipped exactly that as an empty list). Say so instead. + if (ud == null && metaEnd >= 0) throw HostException(NO_USER_DATA) userData = ud val base = ProjectsParser.parseBlob(blob) val wsText = blob.substringBefore(ProjectsParser.PROJECTS_MARK) @@ -393,6 +396,10 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } companion object { + const val NO_USER_DATA = "nodeterm's data wasn't found on this computer over SSH (looked for " + + "~/Library/Application Support/node-terminal and ~/.config/node-terminal). Open nodeterm on the computer " + + "once, or connect through the relay." + /** OpenSSH-style `SHA256:` of the host key blob. */ fun fingerprint(key: PublicKey): String { val blob = Buffer.PlainBuffer().putPublicKey(key).compactData diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt index 5adebca3c7..2b2275ac16 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt @@ -15,8 +15,11 @@ import dev.nodeterm.protocol.model.TmuxNames * (`Contents/Resources/bin/tmux`) is the last resort, as it is for the desktop's own `findTmux`; * - the desktop's sessions live on the `-L node-terminal` socket (src/core/tmux-naming.ts), and its * generated config is `/tmux.conf`; - * - userData is `~/Library/Application Support/nodeterm` on macOS and `$XDG_CONFIG_HOME/nodeterm` - * (default `~/.config/nodeterm`) on Linux. + * - userData is `~/Library/Application Support/node-terminal` on macOS and + * `$XDG_CONFIG_HOME/node-terminal` (default `~/.config/node-terminal`) on Linux: Electron names it + * after package.json `name`, and the desktop has no top-level `productName` (the desktop's own + * hook shell walks the same dirs, src/core/agents/hook-endpoint-failover-sh.ts). The `nodeterm` + * spelling is probed after it as a legacy fallback (audit A02). */ object SshScripts { const val META_START = "##NT-META" @@ -35,7 +38,8 @@ object SshScripts { done fi NT_UD="" - for d in "${'$'}HOME/Library/Application Support/nodeterm" "${'$'}{XDG_CONFIG_HOME:-${'$'}HOME/.config}/nodeterm"; do + for d in "${'$'}HOME/Library/Application Support/node-terminal" "${'$'}{XDG_CONFIG_HOME:-${'$'}HOME/.config}/node-terminal" \ + "${'$'}HOME/Library/Application Support/nodeterm" "${'$'}{XDG_CONFIG_HOME:-${'$'}HOME/.config}/nodeterm"; do if [ -f "${'$'}d/workspace.json" ]; then NT_UD="${'$'}d"; break; fi done """.trimIndent() diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshScriptsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshScriptsTest.kt new file mode 100644 index 0000000000..007ed5ed46 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshScriptsTest.kt @@ -0,0 +1,65 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.ssh.SshScripts +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The generated shell run for real under `/bin/sh` against fake HOMEs laid out the way a desktop + * lays them out — no SSH involved, so each case can have its own HOME. + */ +class SshScriptsTest { + private fun sh(script: String, home: File, extraEnv: Map = emptyMap()): String { + val pb = ProcessBuilder("/bin/sh", "-c", script).redirectErrorStream(true) + pb.environment().clear() + pb.environment().putAll(mapOf("HOME" to home.path, "PATH" to "/usr/bin:/bin") + extraEnv) + val p = pb.start() + val out = p.inputStream.bufferedReader().readText() + p.waitFor() + return out + } + + private fun udOf(home: File, env: Map = emptyMap()): String = + sh(SshScripts.browse(), home, env).lineSequence().first { it.startsWith("ud=") }.removePrefix("ud=") + + private fun freshHome(): File = Files.createTempDirectory("nt-home").toFile() + + @Test + fun `the desktop's real userData dir (node-terminal) is found on Linux and macOS layouts`() { + assumeTrue(File("/bin/sh").canExecute()) + // A02: the desktop's userData is named after package.json `name` = node-terminal. + val linux = freshHome() + File(linux, ".config/node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(linux, ".config/node-terminal").path, udOf(linux)) + + val mac = freshHome() + File(mac, "Library/Application Support/node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(mac, "Library/Application Support/node-terminal").path, udOf(mac)) + + val xdg = freshHome() + val cfg = File(xdg, "cfg") + File(cfg, "node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(cfg, "node-terminal").path, udOf(xdg, mapOf("XDG_CONFIG_HOME" to cfg.path))) + listOf(linux, mac, xdg).forEach { it.deleteRecursively() } + } + + @Test + fun `the legacy nodeterm spelling is a fallback, never preferred`() { + assumeTrue(File("/bin/sh").canExecute()) + val legacy = freshHome() + File(legacy, ".config/nodeterm").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(legacy, ".config/nodeterm").path, udOf(legacy)) + + val both = freshHome() + File(both, ".config/nodeterm").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + File(both, ".config/node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(both, ".config/node-terminal").path, udOf(both)) + + val none = freshHome() + assertEquals("", udOf(none)) + listOf(legacy, both, none).forEach { it.deleteRecursively() } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index 58e17c3458..fc2a6b9206 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -151,7 +151,8 @@ class SshTransportTest { private val repo get() = File(root, "repo") private fun layOutDesktop() { - val ud = File(home, ".config/nodeterm").apply { mkdirs() } + // The REAL directory name (audit A02): Electron's userData is package.json `name`. + val ud = File(home, ".config/node-terminal").apply { mkdirs() } File(repo, ".nodeterm").mkdirs() File(repo, ".nodeterm/project.json").writeText( """{"version":1,"rev":3,"savedAt":"x","name":"file name ignored","color":"#000", diff --git a/docs/uninstall.md b/docs/uninstall.md index 9cd3f05556..2a9f9cf54d 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -21,10 +21,10 @@ to apply. Interactive runs (downloaded script, no pipe) confirm before touching | Location | What it holds | Written by | | --- | --- | --- | -| `~/Library/Application Support/nodeterm` (macOS) / `~/.config/nodeterm` (Linux) | The Electron user-data dir: `workspace.json`, `settings.json`, generated `tmux.conf`, `terminal-scrollback/`, `hook-endpoint.env`, `context-links/`, `canvas-control/`, `claude-accounts//` (managed-account config dirs), `speech-models/`, media caches, secret stores, `device-id`, browser storage | everything behind `CorePlatform.userDataDir` | +| `~/Library/Application Support/node-terminal` (macOS) / `~/.config/node-terminal` (Linux), plus the legacy `…/nodeterm` spelling if present | The Electron user-data dir (named after package.json `name`, which is `node-terminal`): `workspace.json`, `settings.json`, generated `tmux.conf`, `terminal-scrollback/`, `hook-endpoint.env`, `context-links/`, `canvas-control/`, `claude-accounts//` (managed-account config dirs), `speech-models/`, media caches, secret stores, `device-id`, browser storage | everything behind `CorePlatform.userDataDir` | | `~/.nodeterm` | Machine-stable state shared by every instance: `agent-hooks/*.sh` (the managed hook scripts), `ssh-cm/*.sock` (SSH ControlMaster sockets), `acks/`, `pending/`, `push-grants/`, `cx/`, `codex-thread-names/`, the app-private ssh-agent | `install-helper.ts`, `control-master.ts`, `pairing-service.ts`, … | -| `~/Library/Caches/nodeterm*`, `~/Library/Caches/com.nodeterm.app*`, `~/Library/Application Support/Caches/nodeterm-updater`, `~/Library/Preferences/com.nodeterm.app.plist`, `~/Library/Saved Application State/…`, `~/Library/HTTPStorages/com.nodeterm.app`, `~/Library/Logs/nodeterm` | Chromium/electron-updater caches, macOS window state | Electron / macOS | -| Keychain entry `nodeterm Safe Storage` (macOS) | The key Electron `safeStorage` encrypts local secrets with | Electron | +| `~/Library/Caches/node-terminal`, `~/Library/Caches/nodeterm*`, `~/Library/Caches/com.nodeterm.app*`, `~/Library/Application Support/Caches/nodeterm-updater`, `~/Library/Preferences/com.nodeterm.app.plist`, `~/Library/Saved Application State/…`, `~/Library/HTTPStorages/com.nodeterm.app`, `~/Library/Logs/node-terminal`, `~/Library/Logs/nodeterm` | Chromium/electron-updater caches, macOS window state | Electron / macOS | +| Keychain entry `nodeterm Safe Storage` or `node-terminal Safe Storage` (macOS) | The key Electron `safeStorage` encrypts local secrets with | Electron | | `/Applications/nodeterm.app` (or the Homebrew cask) | The app itself | `install.sh` / brew | | `~/.nodeterm-server-app`, `~/.nodeterm-server`, systemd units `nodeterm-server.service` + `nodeterm-server-update.{service,timer}` (system or `--user`) | Server Edition checkout, private Node runtime, data dir, service + daily auto-update timer | `install-server.sh` | diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index e6ad37b644..5a6121db9a 100644 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -60,10 +60,17 @@ note() { printf ' \033[2m%s\033[0m\n' "$1"; } HOME="${HOME:-$(cd ~ && pwd)}" # ---- locations (must mirror what the app writes; see docs/uninstall.md) ---------------------- +# Electron names userData after package.json `name` ("node-terminal"): the top-level package.json +# has no `productName` (only `build.productName`, which electron-builder does not copy into the +# packaged package.json), so the installed app's dir is `node-terminal`, the same path the app's +# own hook shell walks (src/core/agents/hook-endpoint-failover-sh.ts). `nodeterm` is kept as a +# legacy location so an older layout is still cleaned up. if [ "$OS" = "Darwin" ]; then - USER_DATA="$HOME/Library/Application Support/nodeterm" + USER_DATA="$HOME/Library/Application Support/node-terminal" + LEGACY_USER_DATA="$HOME/Library/Application Support/nodeterm" else - USER_DATA="${XDG_CONFIG_HOME:-$HOME/.config}/nodeterm" + USER_DATA="${XDG_CONFIG_HOME:-$HOME/.config}/node-terminal" + LEGACY_USER_DATA="${XDG_CONFIG_HOME:-$HOME/.config}/nodeterm" fi NT_HOME="$HOME/.nodeterm" # agent-hooks, ssh-cm sockets, acks, push-grants… SERVER_APP="${NODETERM_APP_DIR:-$HOME/.nodeterm-server-app}" @@ -356,7 +363,9 @@ DIRS_TO_REMOVE=() add_dir() { [ -e "$1" ] && { DIRS_TO_REMOVE+=("$1"); plan "Delete $1"; FOUND_ANY=1; }; } add_dir "$NT_HOME" add_dir "$USER_DATA" +add_dir "$LEGACY_USER_DATA" if [ "$OS" = "Darwin" ]; then + add_dir "$HOME/Library/Caches/node-terminal" add_dir "$HOME/Library/Caches/nodeterm" add_dir "$HOME/Library/Caches/com.nodeterm.app" add_dir "$HOME/Library/Caches/com.nodeterm.app.ShipIt" @@ -364,8 +373,10 @@ if [ "$OS" = "Darwin" ]; then add_dir "$HOME/Library/Preferences/com.nodeterm.app.plist" add_dir "$HOME/Library/Saved Application State/com.nodeterm.app.savedState" add_dir "$HOME/Library/HTTPStorages/com.nodeterm.app" + add_dir "$HOME/Library/Logs/node-terminal" add_dir "$HOME/Library/Logs/nodeterm" else + add_dir "${XDG_CACHE_HOME:-$HOME/.cache}/node-terminal" add_dir "${XDG_CACHE_HOME:-$HOME/.cache}/nodeterm" fi add_dir "$SERVER_APP" @@ -379,14 +390,16 @@ if [ "$OS" = "Darwin" ]; then elif [ -d "$APP_BUNDLE" ]; then plan "Delete $APP_BUNDLE"; FOUND_ANY=1 fi - if security find-generic-password -s "nodeterm Safe Storage" >/dev/null 2>&1; then - plan "Delete the 'nodeterm Safe Storage' Keychain entry"; FOUND_ANY=1 - fi + for kc in "nodeterm Safe Storage" "node-terminal Safe Storage"; do + if security find-generic-password -s "$kc" >/dev/null 2>&1; then + plan "Delete the '$kc' Keychain entry"; FOUND_ANY=1 + fi + done fi # -- per-project data: enumerate BEFORE the workspace index is deleted; never auto-deleted. PROJECT_DIRS="" -for ws in "$USER_DATA/workspace.json" "$SERVER_DATA/workspace.json"; do +for ws in "$USER_DATA/workspace.json" "$LEGACY_USER_DATA/workspace.json" "$SERVER_DATA/workspace.json"; do [ -f "$ws" ] || continue cwds="$(jsedit list-projects "$ws" 2>/dev/null || true)" while IFS= read -r cwd; do @@ -507,6 +520,7 @@ if [ "$OS" = "Darwin" ]; then || warn "Could not delete $APP_BUNDLE — drag it to the Trash" fi security delete-generic-password -s "nodeterm Safe Storage" >/dev/null 2>&1 || true + security delete-generic-password -s "node-terminal Safe Storage" >/dev/null 2>&1 || true defaults delete com.nodeterm.app >/dev/null 2>&1 || true fi diff --git a/src/main/remote-ssh/ssh-agent.ts b/src/main/remote-ssh/ssh-agent.ts index b5c319cfee..849bb2ba08 100644 --- a/src/main/remote-ssh/ssh-agent.ts +++ b/src/main/remote-ssh/ssh-agent.ts @@ -73,8 +73,9 @@ interface AgentChild { } /** NT_MULTI sandboxes carry their identity in NT_USER_DATA; every other instance derives it from - * its real userData dir. The second half matters because a bare `npm run dev` (app name - * "node-terminal") and the installed app ("nodeterm") have different userData dirs, so the + * its real userData dir. The second half matters because a bare `npm run dev` (userData + * `/node-terminal`) and another instance with its own userData (an NT_MULTI sandbox, a + * Server Edition data dir) have different userData dirs, so the * single-instance lock does not stop them running side by side - under one fixed key the second * instance's start() rmSync's the first one's LIVE socket and its quit unlinks it again, leaving * that app silently agentless (a prompt per reconnect) for the rest of its run. Guarded require: From cd69a1e9bac5335c1d14f64a95bf0debb15cd985 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:23:00 +0000 Subject: [PATCH 006/284] fix(android): give the SSH tmux client a UTF-8 locale (A03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An sshd exec channel on a stock macOS host carries no LANG, so tmux treated the phone's client as non-UTF-8 and drew every character without an ACS mapping (Claude's rounded borders, accents, CJK, emoji) as `_`. The attach script now follows the desktop's resolveLocaleLang rule: keep an inherited LC_ALL/LC_CTYPE/LANG that says UTF-8, else export LANG (en_US.UTF-8 on macOS, C.UTF-8 elsewhere so a Linux host without en_US does not warn). `-u` on the client covers an explicit non-UTF-8 LC_ALL. The SSH test harness no longer passes the JVM's LANG/LC_* to the fake sshd (that is what hid the bug); a new test prints ╭é from ASCII input and fails without the fix (mutation checked). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/protocol/ssh/SshScripts.kt | 20 ++++++++++++++++++- .../dev/nodeterm/protocol/SshTransportTest.kt | 19 +++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt index 2b2275ac16..2ff5e11e5e 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt @@ -44,6 +44,23 @@ object SshScripts { done """.trimIndent() + /** + * A UTF-8 locale for the tmux CLIENT (audit A03). An sshd exec channel on a stock macOS host + * carries no LANG, and tmux draws every character with no ACS mapping (╭, é, CJK, emoji) as `_` + * for a client it does not believe is UTF-8. The rule is the desktop's `resolveLocaleLang` + * (src/core/pty-manager.ts): keep an inherited LC_ALL/LC_CTYPE/LANG that already says UTF-8, + * otherwise export LANG — `en_US.UTF-8` on macOS (always present, and what the desktop uses), + * `C.UTF-8` elsewhere (a Linux host may not have en_US generated, and shells would print setlocale + * warnings). LANG also reaches the panes of a server this attach starts; `-u` on the client makes + * the rendering UTF-8 even when an explicit non-UTF-8 LC_ALL wins over LANG. + */ + private val LOCALE = """ + case "${'$'}{LC_ALL:-${'$'}{LC_CTYPE:-${'$'}{LANG:-}}}" in + *[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) ;; + *) if [ "${'$'}(uname -s 2>/dev/null)" = Darwin ]; then LANG=en_US.UTF-8; else LANG=C.UTF-8; fi; export LANG ;; + esac + """.trimIndent() + /** * Browse: emits a meta block, then EXACTLY the `projects.list` blob shape (workspace.json · * live `nt-*` sessions · agent-status.json), so the relay and SSH paths share one parser. @@ -94,7 +111,8 @@ object SshScripts { if [ -z "${'$'}NT_TMUX" ]; then echo 'nodeterm: tmux was not found on this computer.' >&2; exit 127; fi if [ -n "${'$'}NT_UD" ] && [ -f "${'$'}NT_UD/tmux.conf" ]; then set -- -f "${'$'}NT_UD/tmux.conf"; else set --; fi TERM=xterm-256color; export TERM - exec "${'$'}NT_TMUX" -L ${TmuxNames.SOCKET} "${'$'}@" new-session -A -s ${q(target)}$cd + $LOCALE + exec "${'$'}NT_TMUX" -u -L ${TmuxNames.SOCKET} "${'$'}@" new-session -A -s ${q(target)}$cd """.trimIndent() } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index fc2a6b9206..53eb709a4a 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -54,7 +54,11 @@ class SshTransportTest { private fun tmuxAvailable() = runCatching { ProcessBuilder("tmux", "-V").start().waitFor() == 0 }.getOrDefault(false) && File("/usr/bin/script").exists() - private fun childEnv(): Map = System.getenv().filterKeys { it != "TMUX" && it != "TMUX_PANE" } + + // No locale at all, like an sshd exec channel on a stock macOS host (audit A03): passing the JVM's + // own LANG through is what hid that bug. + private fun childEnv(): Map = System.getenv().filterKeys { + it != "TMUX" && it != "TMUX_PANE" && it != "LANG" && it != "LANGUAGE" && !it.startsWith("LC_") + } + mapOf("HOME" to home.path, "TMUX_TMPDIR" to tmuxDir.path, "XDG_CONFIG_HOME" to File(home, ".config").path) private fun tmux(vararg args: String): Pair { @@ -351,6 +355,19 @@ class SshTransportTest { } } + @Test + fun `non-ASCII survives the attach even when the host sets no locale`() = runBlocking { + connect().use { conn -> + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + Thread.sleep(400) + // ╭ (no ACS mapping) and é, built from octal escapes so the INPUT is pure ASCII. + stream.write("printf 'u8:\\342\\225\\255\\303\\251:end\\n'\r") + sink.waitFor("u8:╭é:end") + stream.detach() + } + } + @Test fun `attaching an unknown node reports a cold start and creates the session`() = runBlocking { connect().use { conn -> From 16706f42119ce3536d4c9a4ddd346bebe10373d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:26:51 +0000 Subject: [PATCH 007/284] fix(approvals): never report an answer to a hold that already ended (A06/A35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook deletes .json when its 45 s hold ends, but the answer writers wrote the .answer file anyway and reported success; answerPermission then emitted the synthetic "answered" event, clearing NEEDS YOU on every surface while the prompt still waited. A phone's answer usually arrives after the hold, so this was its common case. The canvas buttons had the same gap. Desktop: - answerPendingLocal / SshProjectManager.answerPending check the request file first (fs.access; `[ -f … ] || exit 3` in the same remote command) and return sent | gone | failed. The boolean writers (canvas IPC, Server Edition) now mean "delivered to a live hold". - The synthetic answered event is emitted only on `sent`. - approvals.answer replies {answered:false, reason:"gone"|"failed"} (additive: older phones read only `answered`). Android: - ApprovalOutcome.GONE (relay reason or the SSH script's `gone`); a `failed` reason throws so the UI can offer a retry. - QuickActions re-reads the status after GONE (or an older desktop's unexplained `false`): a node still blocked is EXPIRED, which opens the session with an explanation instead of "Already handled." Tests: pending-approvals (gone/failed/sent), ssh-project (exit-code mapping, and the generated command run under a real /bin/sh), host inbox verbs (reason), SSH transport (GONE), and a relay interop test through the desktop's real verb (EXPIRED). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/InboxTab.kt | 4 ++ .../nodeterm/protocol/host/HostConnection.kt | 9 +++- .../nodeterm/protocol/host/QuickActions.kt | 11 +++- .../protocol/host/RelayHostConnection.kt | 8 ++- .../protocol/ssh/SshHostConnection.kt | 2 +- .../protocol/src/test/interop/host-fixture.ts | 6 ++- .../dev/nodeterm/protocol/RelayInteropTest.kt | 13 +++++ .../dev/nodeterm/protocol/SshTransportTest.kt | 2 +- docs/hook-reply-approvals.md | 11 ++++ src/core/agents/pending-approvals.test.ts | 47 ++++++++++++++++- src/core/agents/pending-approvals.ts | 52 +++++++++++++++---- src/main/index.ts | 31 ++++++----- src/main/remote-ssh/ssh-project.test.ts | 50 ++++++++++++++++++ src/main/remote-ssh/ssh-project.ts | 45 ++++++++++------ src/main/remote/host-inbox-verbs.test.ts | 26 +++++++--- src/main/remote/host-service.ts | 22 +++++--- 16 files changed, 278 insertions(+), 61 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index 594c3f6918..3c060ae1b3 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -87,6 +87,10 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro QuickActions.Result.SENT -> Toast.makeText(context, label, Toast.LENGTH_SHORT).show() QuickActions.Result.ALREADY_HANDLED -> Toast.makeText(context, "Already handled.", Toast.LENGTH_SHORT).show() QuickActions.Result.OPEN_SESSION -> open(nodeId) + QuickActions.Result.EXPIRED -> { + Toast.makeText(context, "The request timed out on the computer. Answer it in the session.", Toast.LENGTH_LONG).show() + open(nodeId) + } } session.refreshNow() } catch (e: Exception) { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt index 1235ca424b..a649086772 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -56,7 +56,14 @@ data class CardLabelEdit( data class LabelEditResult(val edited: Boolean, val labels: List, val cardLabelIds: List) -enum class ApprovalOutcome { SENT, ALREADY_HANDLED, UNSUPPORTED } +/** + * What answering a held approval did. [GONE]: the hook's hold had already ended (it deletes its + * request file when it times out after ~45 s, or someone else answered), so nothing was written — + * the interactive prompt may be on screen now. [ALREADY_HANDLED]: a desktop that predates the + * `reason` field said "not answered" without saying why. [FAILED] is never returned: a failed + * write throws [HostException] so the UI can offer a retry. + */ +enum class ApprovalOutcome { SENT, GONE, ALREADY_HANDLED, UNSUPPORTED } /** * One live connection to a paired computer, over either transport. Every call is honest about diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt index d4cf795655..74bc5c7d77 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt @@ -18,7 +18,11 @@ import dev.nodeterm.protocol.model.InboxKind * claude's prompt layout is known; anything else is "open the session". */ object QuickActions { - enum class Result { SENT, ALREADY_HANDLED, OPEN_SESSION } + /** + * [EXPIRED]: the hook's hold ended before the answer arrived (audit A06) and the node still + * waits, so the prompt is on screen in the session — open it (with an explanation). + */ + enum class Result { SENT, ALREADY_HANDLED, OPEN_SESSION, EXPIRED } suspend fun answerApproval(conn: HostConnection, event: InboxEvent, allow: Boolean): Result { if (event.kind != InboxKind.APPROVAL) return Result.OPEN_SESSION @@ -26,7 +30,10 @@ object QuickActions { if (event.pendingId != null && conn.capabilities.answerApprovals) { when (conn.answerApproval(event, allow)) { ApprovalOutcome.SENT -> return Result.SENT - ApprovalOutcome.ALREADY_HANDLED -> return Result.ALREADY_HANDLED + // Nothing was written. If the node STILL waits, the hold timed out and the prompt is + // on screen now: "already handled" would be false — send the user to the session. + ApprovalOutcome.GONE, ApprovalOutcome.ALREADY_HANDLED -> + return if (stillWaiting(conn, event, AgentState.BLOCKED)) Result.EXPIRED else Result.ALREADY_HANDLED ApprovalOutcome.UNSUPPORTED -> Unit } } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt index f162d46d55..75d0e9b6b8 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt @@ -252,7 +252,13 @@ class RelayHostConnection private constructor() : HostConnection, RelaySocketLis } throw e } - return if (body?.b("answered") == true) ApprovalOutcome.SENT else ApprovalOutcome.ALREADY_HANDLED + if (body?.b("answered") == true) return ApprovalOutcome.SENT + // `reason` (additive, audit A35): `gone` = the hold ended; `failed` = the host could not write. + return when (body?.s("reason")) { + "gone" -> ApprovalOutcome.GONE + "failed" -> throw HostException("The computer couldn't write the answer. Try again, or open the session.") + else -> ApprovalOutcome.ALREADY_HANDLED + } } override suspend fun ackRead(nodeId: String, eventId: String?) { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index b0798f5a25..cfdc5d2cf6 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -352,7 +352,7 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos if (!SshScripts.PENDING_ID.matches(pendingId)) return@withContext ApprovalOutcome.UNSUPPORTED when (run(SshScripts.answerApproval(pendingId, allow)).second.trim()) { "sent" -> ApprovalOutcome.SENT - "gone" -> ApprovalOutcome.ALREADY_HANDLED + "gone" -> ApprovalOutcome.GONE else -> throw HostException("Couldn't write the answer on the computer.") } } diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index 3df74ecfd5..3966df6914 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -201,7 +201,11 @@ async function runRelay(): Promise { ) }, inbox: { - answerPermission: async (nodeId, pendingId, decision) => (emit({ event: 'answer', nodeId, pendingId, decision }), true), + // A pendingId ending in `-expired` stands for a hold that already ended (the real writer finds + // no request file and answers `gone`, audit A06). + answerPermission: async (nodeId, pendingId, decision) => ( + emit({ event: 'answer', nodeId, pendingId, decision }), pendingId.endsWith('-expired') ? 'gone' : 'sent' + ), ackRead: (nodeId) => emit({ event: 'ack', nodeId }) }, onPeerReady: (s) => { diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index 15cb36c73e..3bc5cfce89 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -260,4 +260,17 @@ class RelayInteropTest { assertNotNull(event) } } + + @Test + fun `an answer that arrives after the hold ended opens the session instead of claiming success`() = runBlocking { + // A06/A35 against the desktop's real verb: `{answered:false, reason:"gone"}`, and the node is + // still blocked, so the prompt is on screen now. + val h = start() + connect(h).connection.use { conn -> + val live = conn.listProjects().status!!.inbox!!.events.single() + val late = live.copy(id = "e-late", pendingId = "term-abc-1-1700000000000-43-expired") + assertEquals(dev.nodeterm.protocol.host.ApprovalOutcome.GONE, conn.answerApproval(late, allow = true)) + assertEquals(QuickActions.Result.EXPIRED, QuickActions.answerApproval(conn, late, allow = false)) + } + } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index 53eb709a4a..c27beef093 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -408,7 +408,7 @@ class SshTransportTest { assertEquals(ApprovalOutcome.SENT, conn.answerApproval(event, allow = false)) assertEquals("deny", File(pending, "$id.answer").readText()) File(pending, "$id.json").delete() - assertEquals(ApprovalOutcome.ALREADY_HANDLED, conn.answerApproval(event, allow = true)) + assertEquals(ApprovalOutcome.GONE, conn.answerApproval(event, allow = true)) conn.ackRead("term-a-1", "e1") assertEquals("e1", File(home, ".nodeterm/acks/term-a-1.seen").readText()) } diff --git a/docs/hook-reply-approvals.md b/docs/hook-reply-approvals.md index 11edfedcd1..7b048f9fee 100644 --- a/docs/hook-reply-approvals.md +++ b/docs/hook-reply-approvals.md @@ -43,6 +43,17 @@ must be a **no-op** when the env var is absent (user's own terminals, older node project → write via the project's ControlMaster. So desktop users are not left staring at a held prompt — they get one-click approval the moment the badge pulses. +- **Phone (relay)** — the `approvals.answer` verb (host-service.ts `HostInboxOps`), which lands + on the same main-side writer as the canvas buttons. + +**A late answer is not an answer.** The hook deletes `.json` when its hold ends (timeout +or another answerer), and nothing reads an `.answer` after that. So every writer checks the request +file first — locally with `fs.access`, over SSH with `[ -f … ] || exit 3` in the same remote +command — and reports `sent` / `gone` / `failed` (`PendingAnswerResult`). Only `sent` emits the +optimistic "answered" event; the relay verb replies `{answered:false, reason:"gone"|"failed"}`, and +a phone that still sees the node blocked after `gone` opens the session, where the prompt now is. +A hook that times out between the check and the write leaves an orphan `.answer` for the sweep. + **Event plumbing** — the hook server's raw `PermissionRequest` payload now carries `nodeterm_pending_id` (added by the script to its POST body); the mirror's approval `InboxEvent` gains `pendingId?: string`, riding the mirror (phone) and dropped from the diff --git a/src/core/agents/pending-approvals.test.ts b/src/core/agents/pending-approvals.test.ts index c067f87af0..42fafe73f3 100644 --- a/src/core/agents/pending-approvals.test.ts +++ b/src/core/agents/pending-approvals.test.ts @@ -6,6 +6,7 @@ import { isValidPendingId, pendingDir, writePendingAnswerLocal, + answerPendingLocal, sweepPendingDir, syntheticAnsweredEvent, PENDING_MAX_AGE_MS @@ -36,8 +37,15 @@ describe('isValidPendingId', () => { }) }) +/** A hook currently holding `id` (the request file the managed script writes and deletes). */ +function hold(id: string): void { + fs.mkdirSync(pendingDir(home), { recursive: true }) + fs.writeFileSync(path.join(pendingDir(home), `${id}.json`), '{}') +} + describe('writePendingAnswerLocal', () => { it('writes the one-line answer file for a valid id + decision', async () => { + hold('node-1-2') const ok = await writePendingAnswerLocal('node-1-2', 'allow', home) expect(ok).toBe(true) const file = path.join(pendingDir(home), 'node-1-2.answer') @@ -45,14 +53,16 @@ describe('writePendingAnswerLocal', () => { }) it('writes deny too', async () => { + hold('n') expect(await writePendingAnswerLocal('n', 'deny', home)).toBe(true) expect(fs.readFileSync(path.join(pendingDir(home), 'n.answer'), 'utf8')).toBe('deny') }) it('leaves no .tmp behind (atomic rename)', async () => { + hold('n') await writePendingAnswerLocal('n', 'allow', home) - const entries = fs.readdirSync(pendingDir(home)) - expect(entries).toEqual(['n.answer']) + const entries = fs.readdirSync(pendingDir(home)).sort() + expect(entries).toEqual(['n.answer', 'n.json']) }) it('refuses an invalid pendingId and writes nothing', async () => { @@ -61,9 +71,42 @@ describe('writePendingAnswerLocal', () => { }) it('refuses an out-of-contract decision', async () => { + hold('n') // @ts-expect-error — exercising the runtime guard against a bad value expect(await writePendingAnswerLocal('n', 'always', home)).toBe(false) }) + + // A06: the hook deletes .json when its 45 s hold ends. Writing the answer anyway used to + // report success (and clear NEEDS YOU everywhere) for an answer nothing would ever read. + it('reports false and writes nothing once the hold has ended', async () => { + fs.mkdirSync(pendingDir(home), { recursive: true }) + expect(await writePendingAnswerLocal('late-1', 'allow', home)).toBe(false) + expect(fs.existsSync(path.join(pendingDir(home), 'late-1.answer'))).toBe(false) + }) +}) + +describe('answerPendingLocal', () => { + it('says sent for a live hold', async () => { + hold('live-1') + expect(await answerPendingLocal('live-1', 'deny', home)).toBe('sent') + }) + + it('says gone — not failed — when the hold ended (A35)', async () => { + expect(await answerPendingLocal('late-1', 'allow', home)).toBe('gone') + fs.mkdirSync(pendingDir(home), { recursive: true }) + expect(await answerPendingLocal('late-1', 'allow', home)).toBe('gone') + }) + + it('says failed when the answer cannot be written', async () => { + hold('ro-1') + // A directory where the answer file should go makes the rename fail. + fs.mkdirSync(path.join(pendingDir(home), 'ro-1.answer')) + expect(await answerPendingLocal('ro-1', 'allow', home)).toBe('failed') + }) + + it('says failed for an invalid id', async () => { + expect(await answerPendingLocal('../x', 'allow', home)).toBe('failed') + }) }) describe('syntheticAnsweredEvent (optimistic-flip builder)', () => { diff --git a/src/core/agents/pending-approvals.ts b/src/core/agents/pending-approvals.ts index 4997defe88..6967290583 100644 --- a/src/core/agents/pending-approvals.ts +++ b/src/core/agents/pending-approvals.ts @@ -34,30 +34,60 @@ export function pendingDir(homeDir: string = os.homedir()): string { } /** - * Write the one-line answer file for a held permission hook, atomically (tmp + rename, mode 0600). - * Resolves true on success, false on an invalid pendingId or any fs error (fail-open — the hook - * simply times out to the interactive prompt). The `decision` is written verbatim as the hook - * script compares it against the literals `allow` / `deny`. + * What writing an answer did. `gone` is the hook's hold having ENDED — it deletes + * `.json` when it times out (managed-script.ts) or when another surface answered — so no + * answer can reach it any more and the interactive prompt is (or was) on screen instead. `failed` is + * a write that could not happen. The two are different facts for a caller: one says "go to the + * session", the other "try again" (audit A06/A35). */ -export async function writePendingAnswerLocal( +export type PendingAnswerResult = 'sent' | 'gone' | 'failed' + +/** + * Answer a held permission hook: check its request file still exists, then write the one-line answer + * file atomically (tmp + rename, mode 0600). The `decision` is written verbatim as the hook script + * compares it against the literals `allow` / `deny`. Never throws. + * + * The existence check is what stops a late answer (the phone's usual case: the hold is 45 s) from + * being reported — and optimistically broadcast — as delivered. A hook that times out between the + * check and the write leaves an orphan `.answer`, which the sweep removes; closing that window fully + * would need the hook to announce its timeout. + */ +export async function answerPendingLocal( pendingId: string, decision: 'allow' | 'deny', homeDir: string = os.homedir() -): Promise { - if (!isValidPendingId(pendingId)) return false - if (decision !== 'allow' && decision !== 'deny') return false +): Promise { + if (!isValidPendingId(pendingId)) return 'failed' + if (decision !== 'allow' && decision !== 'deny') return 'failed' const dir = pendingDir(homeDir) + try { + await fs.promises.access(path.join(dir, `${pendingId}.json`)) + } catch (e) { + // Only a definite ENOENT is evidence the hold ended; anything else is a failed read. + return (e as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'gone' : 'failed' + } const file = path.join(dir, `${pendingId}.answer`) try { - await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }) // writeFileAtomic: unique tmp + retrying rename (core/fs-atomic.ts); removes its temp on failure. await writeFileAtomic(file, decision, { mode: 0o600 }) - return true + return 'sent' } catch { - return false + return 'failed' } } +/** + * Boolean form of [answerPendingLocal] for callers that only report success: true ONLY when the + * answer reached a hold that still exists. + */ +export async function writePendingAnswerLocal( + pendingId: string, + decision: 'allow' | 'deny', + homeDir: string = os.homedir() +): Promise { + return (await answerPendingLocal(pendingId, decision, homeDir)) === 'sent' +} + /** * Build the synthetic "answered" agent event the managed hook's second POST would produce, so a * caller that just wrote the answer file (the desktop Approve/Deny handler) can OPTIMISTICALLY flip diff --git a/src/main/index.ts b/src/main/index.ts index 7f019e346b..266acaaa43 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -126,10 +126,11 @@ import { projectCapabilityGrantedFor } from '../shared/project-capability-consen import { askpassServer, ensureAskpassScript } from './remote-ssh/ssh-askpass' import { appSshAgent } from './remote-ssh/ssh-agent' import { - writePendingAnswerLocal, + answerPendingLocal, startPendingSweep, isValidPendingId, - syntheticAnsweredEvent + syntheticAnsweredEvent, + type PendingAnswerResult } from '../core/agents/pending-approvals' import { setMainWindow, getMainWindow, sendToMain, closeAction, createCrashReloadPolicy } from './main-window' import { @@ -2771,33 +2772,39 @@ app.whenReady().then(async () => { // ONE writer for every answerer: the canvas IPC below and the relay phone's `approvals.answer` // verb (host-service.ts `HostInboxOps`) both call `answerPermission`, so a phone answer can never // take a different route than a desktop one. + // + // The writer checks the hold still exists (its `.json`) before writing: a late answer + // (the phone's usual case — the hold is 45 s) used to be reported, and broadcast, as delivered, + // clearing NEEDS YOU while the prompt still waited on screen (audit A06). `gone` vs `failed` is + // kept apart so the phone can say "open the session" rather than "already handled" (A35). async function answerPermission( nodeId: string, pendingId: string, decision: 'allow' | 'deny' - ): Promise { - if (!isValidPendingId(pendingId)) return false - if (decision !== 'allow' && decision !== 'deny') return false + ): Promise { + if (!isValidPendingId(pendingId)) return 'failed' + if (decision !== 'allow' && decision !== 'deny') return 'failed' const sshProjectId = workspaceStore.sshProjectIdForNode(nodeId) - const ok = - sshProjectId && sshProjectManager - ? await sshProjectManager.writePendingAnswer(sshProjectId, pendingId, decision) - : await writePendingAnswerLocal(pendingId, decision, homedir()) + const result: PendingAnswerResult = sshProjectId + ? sshProjectManager + ? await sshProjectManager.answerPending(sshProjectId, pendingId, decision) + : 'failed' + : await answerPendingLocal(pendingId, decision, homedir()) // Optimistic flip: on a successful write, emit the same synthetic "answered" transition the // held hook's second POST will produce, so the NEEDS YOU badge clears instantly instead of // waiting for that POST to round-trip. The later hook POST is an idempotent duplicate (a // same-state working re-assert is a no-op). See docs/hook-reply-approvals.md. - if (ok) { + if (result === 'sent') { const ev = syntheticAnsweredEvent(nodeId, pendingId, decision) if (ev) emitAgentStatus(ev) } - return ok + return result } corePlatform.handle( IPC.agentAnswerPermission, async (payload: { nodeId: string; pendingId: string; decision: 'allow' | 'deny' }) => { const { nodeId, pendingId, decision } = payload ?? ({} as typeof payload) - return answerPermission(nodeId, pendingId, decision) + return (await answerPermission(nodeId, pendingId, decision)) === 'sent' } ) // Read-a-finished-session ack (this feature): the renderer's unread-clear funnel calls it when the diff --git a/src/main/remote-ssh/ssh-project.test.ts b/src/main/remote-ssh/ssh-project.test.ts index 41cdc2070a..5f2679a8c8 100644 --- a/src/main/remote-ssh/ssh-project.test.ts +++ b/src/main/remote-ssh/ssh-project.test.ts @@ -129,12 +129,62 @@ describe('SshProjectManager', () => { for (let i = 0; i < commands.length; i++) { expect(commands[i]).toContain('umask 077') expect(commands[i]).toContain('.nodeterm/pending') + // The hold is checked in the SAME remote command (A06). + expect(commands[i]).toContain(`[ -f ~/'${'/.nodeterm/pending/node-1-2.json'.slice(1)}' ] || exit 3;`) expect(commands[i]).toContain(`mv -f -- ${temps[i]} ~/'${'/.nodeterm/pending/node-1-2.answer'.slice(1)}'`) expect(commands[i]).toContain(`rm -f -- ${temps[i]}`) } expect(calls.map((call) => call[1])).toEqual(['allow', 'deny']) // decisions stay on stdin }) + it('answerPending tells a hold that ended (exit 3) from a failed write', async () => { + const { mgr, run } = makeMgr() + await mgr.connect('p1', conn) + run.mockImplementationOnce(async () => ({ code: 3, stdout: '' })) + expect(await mgr.answerPending('p1', 'node-1-2', 'allow')).toBe('gone') + run.mockImplementationOnce(async () => ({ code: 255, stdout: '' })) + expect(await mgr.answerPending('p1', 'node-1-2', 'allow')).toBe('failed') + run.mockImplementationOnce(async () => { + throw new Error('spawn failed') + }) + expect(await mgr.answerPending('p1', 'node-1-2', 'allow')).toBe('failed') + expect(await mgr.answerPending('p9', 'node-1-2', 'allow')).toBe('failed') + run.mockImplementationOnce(async () => ({ code: 3, stdout: '' })) + expect(await mgr.writePendingAnswer('p1', 'node-1-2', 'allow')).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'the generated answer command, run under a real /bin/sh, answers a live hold and refuses an ended one', + async () => { + // POSIX-only: it runs the command the HOST would run, which is always sh. + const { execFileSync } = await import('child_process') + const { mgr, run } = makeMgr() + await mgr.connect('p1', conn) + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-pend-')) + const pending = path.join(home, '.nodeterm', 'pending') + await fs.mkdir(pending, { recursive: true }) + const exec = (command: string, stdin: string): number => { + try { + execFileSync('/bin/sh', ['-c', command], { input: stdin, env: { HOME: home, PATH: '/usr/bin:/bin' } }) + return 0 + } catch (e) { + return (e as { status?: number }).status ?? 1 + } + } + run.mockImplementation(async (args: string[], stdin?: string) => ({ code: exec(args.at(-1)!, stdin ?? ''), stdout: '' })) + try { + expect(await mgr.answerPending('p1', 'late-1', 'allow')).toBe('gone') + expect(await fs.readdir(pending)).toEqual([]) + writeFileSync(path.join(pending, 'live-1.json'), '{}') + expect(await mgr.answerPending('p1', 'live-1', 'deny')).toBe('sent') + expect(await fs.readFile(path.join(pending, 'live-1.answer'), 'utf8')).toBe('deny') + expect((await fs.readdir(pending)).sort()).toEqual(['live-1.answer', 'live-1.json']) + } finally { + await fs.rm(home, { recursive: true, force: true }) + } + } + ) + it('pushAgentStatus gives overlapping pushes separate temps and preserves private permissions', async () => { const { mgr, run } = makeMgr() await mgr.connect('p1', conn) diff --git a/src/main/remote-ssh/ssh-project.ts b/src/main/remote-ssh/ssh-project.ts index 86662005c9..fc1893a9c7 100644 --- a/src/main/remote-ssh/ssh-project.ts +++ b/src/main/remote-ssh/ssh-project.ts @@ -72,6 +72,11 @@ import { appSshAgent } from './ssh-agent' import { probeAgentSockToPin } from '../../core/remote-ssh/agent-probe' import { sessionName } from '../../core/tmux-naming' import { remoteAtomicWrite } from '../remote-atomic-write' +import type { PendingAnswerResult } from '../../core/agents/pending-approvals' + +/** The remote answer command's exit status for "the hook's hold already ended" (no request file). + * ssh itself uses 255 and tmux/sh 1/2/126/127, so 3 cannot be mistaken for a transport failure. */ +const PENDING_GONE_EXIT = 3 import { buildCodexLauncherScript } from '../../core/codex-identity-proxy' import { ACCOUNT_ID_RE, @@ -1818,30 +1823,40 @@ export class SshProjectManager { * tmp+mv, 0600 via umask). The hook is polling `~/.nodeterm/pending/.answer` on that * host. `pendingId` is validated by the caller (main) before it reaches here; this method also * refuses anything but the safe charset as defense-in-depth, since it interpolates into a remote - * shell command. No-ops (false) when the project isn't connected or the write fails. + * shell command. + * + * The request file `.json` is checked IN THE SAME remote command (`exit 3` when it is + * missing): the hook deletes it when its hold ends, and an answer written after that is read by + * nobody — reporting it as delivered is what cleared NEEDS YOU for a prompt still waiting on the + * host (audit A06). `gone` is that case; `failed` is a disconnected project or a failed write. */ - async writePendingAnswer( + async answerPending( projectId: string, pendingId: string, decision: 'allow' | 'deny' - ): Promise { + ): Promise { const c = this.conns.get(projectId) - if (!c) return false - if (!/^[A-Za-z0-9_-]+$/.test(pendingId)) return false - if (decision !== 'allow' && decision !== 'deny') return false + if (!c) return 'failed' + if (!/^[A-Za-z0-9_-]+$/.test(pendingId)) return 'failed' + if (decision !== 'allow' && decision !== 'deny') return 'failed' const dir = c.remoteHome ? `${c.remoteHome}/.nodeterm/pending` : '~/.nodeterm/pending' const file = `${dir}/${pendingId}.answer` + const request = quoteRemotePath(`${dir}/${pendingId}.json`) + const write = remoteAtomicWrite(file, { restrictPermissions: true, makeParent: false }).command const { code } = await this.r - .run( - childArgs( - c.conn, - c.controlPath, - remoteAtomicWrite(file, { restrictPermissions: true }).command - ), - decision - ) + .run(childArgs(c.conn, c.controlPath, `[ -f ${request} ] || exit ${PENDING_GONE_EXIT}; ${write}`), decision) .catch(() => ({ code: 1, stdout: '' })) - return code === 0 + if (code === 0) return 'sent' + return code === PENDING_GONE_EXIT ? 'gone' : 'failed' + } + + /** Boolean form of [answerPending]: true only when the answer reached a hold that still exists. */ + async writePendingAnswer( + projectId: string, + pendingId: string, + decision: 'allow' | 'deny' + ): Promise { + return (await this.answerPending(projectId, pendingId, decision)) === 'sent' } /** diff --git a/src/main/remote/host-inbox-verbs.test.ts b/src/main/remote/host-inbox-verbs.test.ts index 039126efc9..2bd96ab15b 100644 --- a/src/main/remote/host-inbox-verbs.test.ts +++ b/src/main/remote/host-inbox-verbs.test.ts @@ -3,8 +3,9 @@ // - Every client-sent field is validated BEFORE the writer is asked: the pendingId becomes a file // name (`~/.nodeterm/pending/.answer`), so a traversal-shaped id must never reach it, and the // decision is exactly `allow` | `deny` — the literals the held hook script compares against. -// - A writer that answers false (hook already timed out, write failed) comes back as an ok ANSWER -// `{answered:false}`, so the phone can say "already handled" instead of showing a protocol error. +// - A writer that did not deliver comes back as an ok ANSWER `{answered:false, reason}`: `gone` (the +// hook's hold already ended — A06: the writer checks the request file first) or `failed` (the write +// could not happen), so the phone can open the session or offer a retry instead of a protocol error. // - `inbox.ack` runs the SAME ack the `~/.nodeterm/acks` sweep runs for a phone on direct SSH. import { describe, expect, it, vi } from 'vitest' import { @@ -28,7 +29,7 @@ function makeFakes(over: Partial = {}, served = true) { writeText: async () => true } const inbox: HostInboxOps = { - answerPermission: vi.fn(async () => true), + answerPermission: vi.fn(async () => 'sent' as const), ackRead: vi.fn(), ...over } @@ -55,15 +56,26 @@ describe('approvals.answer', () => { expect(responses[0]).toEqual({ id: '1', ok: true, body: { answered: true } }) }) - it('answers {answered:false} — an ANSWER, not an error — when nothing was written', async () => { - const { handlers, responses } = makeFakes({ answerPermission: async () => false }) + it('answers {answered:false, reason:"gone"} — an ANSWER, not an error — when the hold had ended', async () => { + const { handlers, responses } = makeFakes({ answerPermission: async () => 'gone' }) handlers.onRpc({ id: '2', method: 'approvals.answer', params: { nodeId: 'term-abc-1', pendingId: 'p-1', decision: 'deny' } }) await flush() - expect(responses[0]).toEqual({ id: '2', ok: true, body: { answered: false } }) + expect(responses[0]).toEqual({ id: '2', ok: true, body: { answered: false, reason: 'gone' } }) + }) + + it('tells a failed write apart from an ended hold (A35)', async () => { + const { handlers, responses } = makeFakes({ answerPermission: async () => 'failed' }) + handlers.onRpc({ + id: '2b', + method: 'approvals.answer', + params: { nodeId: 'term-abc-1', pendingId: 'p-1', decision: 'allow' } + }) + await flush() + expect(responses[0]).toEqual({ id: '2b', ok: true, body: { answered: false, reason: 'failed' } }) }) it('a writer that throws is still an answer, not a hung request', async () => { @@ -74,7 +86,7 @@ describe('approvals.answer', () => { }) handlers.onRpc({ id: '3', method: 'approvals.answer', params: { nodeId: 'n', pendingId: 'p', decision: 'allow' } }) await flush() - expect(responses[0]).toEqual({ id: '3', ok: true, body: { answered: false } }) + expect(responses[0]).toEqual({ id: '3', ok: true, body: { answered: false, reason: 'failed' } }) }) it.each([ diff --git a/src/main/remote/host-service.ts b/src/main/remote/host-service.ts index dddeec5f44..a6355521a6 100644 --- a/src/main/remote/host-service.ts +++ b/src/main/remote/host-service.ts @@ -34,7 +34,7 @@ import { PtyManager, type DetachedSinks } from '../../core/pty-manager' import * as fsOps from '../../core/fs-ops' import { TITLE_MAX, type RemoteNodeInput } from '../../core/project-node-append' import { parseCardLabelEdit, type CardLabelEdit } from '../../core/project-kanban-write' -import { isValidPendingId } from '../../core/agents/pending-approvals' +import { isValidPendingId, type PendingAnswerResult } from '../../core/agents/pending-approvals' import { getStoredEntitlement, isPremium } from '../../core/license' import { publicKeyToB64, type KeyPair } from './e2ee' import { loadOrCreateHostKeyPair, HostKeyLockedError } from './host-identity' @@ -195,8 +195,12 @@ export interface HostKanbanOps { * cannot drift from a local one. Absent ⇒ the verbs answer an honest "not served". */ export interface HostInboxOps { - /** Answer a held permission hook. Resolves false when nothing was written. */ - answerPermission(nodeId: string, pendingId: string, decision: 'allow' | 'deny'): Promise + /** + * Answer a held permission hook. `sent` = the answer reached a hold that still exists; `gone` = + * the hold had already ended (the hook timed out, or another surface answered) so nothing was + * written; `failed` = the write could not happen. See `PendingAnswerResult`. + */ + answerPermission(nodeId: string, pendingId: string, decision: 'allow' | 'deny'): Promise /** The phone READ a finished session: resolve its done event(s) and clear the desktop unread. */ ackRead(nodeId: string): void } @@ -687,8 +691,10 @@ export function createHostHandlers( * `approvals.answer {nodeId, pendingId, decision}` → `{answered}` and `inbox.ack {nodeId}` → `{}` * (see `HostInboxOps`). Every field is client-sent and validated here before it reaches a path: the * pendingId with the SAME rule the answer writer applies (`isValidPendingId` — it becomes a file - * name), the node id with the rule `node.*` applies. `answered:false` is an ANSWER (the hook already - * timed out, the host could not write), not a protocol error, so the phone can say "already handled". + * name), the node id with the rule `node.*` applies. `answered:false` is an ANSWER, not a protocol + * error, and it carries WHY in `reason`: `gone` (the hook's hold already ended — its request file is + * gone, so the interactive prompt is on screen now or someone else answered) or `failed` (the host + * could not write). `reason` is additive: a phone that predates it reads only `answered`. */ function handleInbox(req: RpcRequest): void { if (!inbox) { @@ -721,8 +727,10 @@ export function createHostHandlers( } void inbox .answerPermission(nodeId, pendingId, decision) - .then((answered) => socket.respond(req.id, true, { answered })) - .catch(() => socket.respond(req.id, true, { answered: false })) + .then((result) => + socket.respond(req.id, true, result === 'sent' ? { answered: true } : { answered: false, reason: result }) + ) + .catch(() => socket.respond(req.id, true, { answered: false, reason: 'failed' })) } function handleKill(req: RpcRequest): void { From fcda932d1a84fdabbf72c55fdeb7774a736e32f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:27:22 +0000 Subject: [PATCH 008/284] build(android): sign debug APKs with a committed public key (A10) Every CI run signed its debug APK with a fresh runner-generated key, so a newer APK could not be installed over an older one; the only way forward was uninstalling, and allowBackup=false means that wipes every pairing and the Keystore-held identities. app/debug.keystore (PKCS12, alias androiddebugkey, password android) is now committed and wired as the debug signing config, as the existing `!debug.keystore` gitignore exception intended. Trade-off, stated in the build file and both READMEs: the key is public, so anyone can sign an APK that installs as an update over a debug build and inherits its data (including the SSH key paired computers trust). Debug APKs should only come from this repo's CI or a local build; release builds need their own private key, which does not exist yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- README.md | 4 +++- android/README.md | 7 +++++++ android/app/build.gradle.kts | 20 ++++++++++++++++++++ android/app/debug.keystore | Bin 0 -> 2698 bytes 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 android/app/debug.keystore diff --git a/README.md b/README.md index 53f531d145..a95831688c 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,9 @@ detects your platform. Everything is also listed at - **iOS** — **nodeterm mobile** on the [App Store](https://apps.apple.com/app/nodeterm/id6790581233). - **Android** — build it from [`android/`](./android) (`./gradlew :app:assembleDebug`); CI also - attaches a debug APK to every run of the Android workflow. Not in a store yet. + attaches a debug APK to every run of the Android workflow. Debug builds share a public signing + key so a newer APK installs over an older one; install them only from this repository's CI or + your own build. Not in a store yet, and not yet tested on a device. **Trying it out?** Removal is one script — it stops every process nodeterm started, reverts the status-hook/skill entries it merged into your agent CLIs' config (your own hooks and diff --git a/android/README.md b/android/README.md index dd927e1de4..503fe7db07 100644 --- a/android/README.md +++ b/android/README.md @@ -44,6 +44,13 @@ Needs JDK 17+ and the Android SDK (Android Studio's, or `ANDROID_HOME`). `minSdk CI builds the debug APK on every change under `android/` (`.github/workflows/android.yml`) and attaches it to the run. +**Debug builds are signed with a public key that is committed on purpose** +(`app/debug.keystore`, password `android`). That is what lets you install a newer CI or local +debug APK over an older one without uninstalling — uninstalling wipes every pairing. The other side +of "public": anyone can sign an APK that installs as an update over a debug build and inherits its +data, including the keys your computers trust. **Install debug APKs only from this repository's CI +or your own build.** Release builds will need their own private key; none exists yet. + ## Layout - **`protocol/`** — everything that goes on the wire, as a plain Kotlin/JVM library with no Android diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d8bbe331b4..209dbe8f63 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,7 +16,27 @@ android { versionName = "0.1.0" } + signingConfigs { + // A PUBLIC debug key, committed on purpose (audit A10): without it every CI run signs with a + // fresh runner-generated key, so installing the next APK over the last one fails and the only + // way forward is uninstalling, which wipes every pairing. Its password is the Android default. + // + // Public means ANYONE can sign an APK that installs as an update over a debug build and so + // inherits its data and its Keystore-held secrets (the SSH key paired computers trust, the relay + // identity). Install debug APKs only from this repo's CI or your own build. A release build + // must never use this key; there is no release signing config yet. + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + buildTypes { + debug { + signingConfig = signingConfigs.getByName("debug") + } release { // sshj + BouncyCastle rely on reflection; shrinking needs keep rules nobody has written yet. isMinifyEnabled = false diff --git a/android/app/debug.keystore b/android/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..fde81f220a1fa2ac25ec13c487bbdfc2a9b39dc7 GIT binary patch literal 2698 zcma)8XEYoP7M)5Aqt|GIBobrHjOdYJ`9voe(c2i&qqh`gOeUg?7B!-G;nQ10h$#7V zq9i&IA&96U%G$Gg@}2#)`{SMa-o5X>pZ7cr2}S_{!59(@LqjJ<(V!f_fwaJE5=;m} zg7KfzJQxzR?Y|^w6@&yWKc`F1tAK|7KQ0Cs5R^@VDxZ^zn7h9nP&md6Blq{nfI&jo zOVLX5CDbIv1MjfsH|Gv*Rd3p+fIyfL0KqDQyw(oH?%LK`q2;>j0%MugaBQ2BPoWc8D%76@b6)sLFpPQ}hjtu@_-mi8mExt`1gHGZo~^iuv17@28A<1sw#VlC4Ju01i_lDh1d z5&~6Xnc*B`QFcF+es&o8+E6(SvIic-t7WR?aX723x4B=a_Ho`3{<9WW|yUjL=LmzR`4$O zP0b9ORy}kXIcAM{L&TNITDh5)Mi1Ltgew#^XmQ6Nh?ZZ$I7641Lk8P4k(7py%u4DP zd(r|GhbISL9wbcRi}Sa!UDNv9_^MOCfpu0B6Aag{Cz>J)%A+2nm`Y?H7);S6#yZ zM1SawKC^#Q<3-$X4OiF(?ieR;xZ8hkpm(nIeWeiLD)c(49a%_15i-9tF&Z~PTks; zeCnx7>E{Hevfw|;d&gc2bau|GtMr>H08)K!?1h8}+@&d&x9``W`KjXESHyW+Gb4}A z^~b}o;fO+7uN5@&jI7=GL~~Ws^nj60MT)YB__3s}Fj>v8#ILN#U#uCWGp*@AhlH)~ zfZ-<=@^AHyOp8oo;W=NAg3Sc0qEjO1_Zx+@BA>dyCm9**-4Yx!Tz3%MO%xfXyK#6q zZT;={WX8u4Uz3v%DvR$CRe(p`H_j9 z)_X4ceXV^Cb9uf+*sNRQ%eirckTEf7#=mZ^_VM1Mv}Rb-N$i*4trlWmsV7=A`gdD1 zkz2KX-3~I)0OvEwtZ%l7?Y>XRBMrJ?TnS!MUGb6PgSrImN54Nv^$( zx^3e<3!!>vLQhlCGp1Q1{0tHOA1GEKiA5Zqb%ol?n4DjUDWz-)+yX$4>w*{bw;3$$ z6#3$<|B>#i6g3gZl>hX_lJxR&!K0e$tg{8VXVrnc@~L*4*W6-c%w~Tw;_cynQyt=( zgYwQ;Lk#M7T+*>)5ds1LJHR~v5#SB*063o`5#RtI0Q>-s0N3*_2qX0OItP{=!islx zcR*lejzX>`BLPo<6uX9UE%6U zTt?g9ZFyr;5Xv1Njm0JHi;1k|M;s>J3~pi>2c!eF=mtV!FnPEVICCdtjL`Iq{$4$m z$@hxngGTl<6LTJmNwi|_k)`Lxy%?BCu}IuFJ?9=Iu()LLmHMf^Zxk`N-SyS;WRFzI z1FN&Usj6kUHk_5xI3*-|?|A;7R)%`@m#Ao}ZU=acpZqiKtG06QYCj&tSk&{SbJ~xy zG|-4wQ%a$2-k6ELNN7>fvE`^7Nq2_J{m{{3@hDo(Ycb9k2m3|)u+9i%d+&L3$DU6<>&Q; z7GPXi&C(y~Oj?1E4S*{*zA}#)*zCgaxl=^cW=3uNA)L_Fq+Y6R21_P7tUV0_Mn5^G z)zxG;iNCVOs3`&EGd`HlT+(OW>ZkAwq6|8(ozCc)B)rjQY25X3s=8SXN6-0GWI9bV zSFiz3bko8%^}BZqB)MDK3Li_-@9?K#BlvtbDJFSwJ#xg zfan&qJVN3mf5jWZ&z9&SDd9y75|s)g(Mn`1j9N^F-W#e^ZtU9Ga&=Q*2A_MEZ`e=6d1@7`+kFY5mgmqeUEgi{DJI zm;L&L1pL@l2&h(i0rhgJboBF1_0^F@!yO~CvG>(coUmPRP=_`!vs$Ph-adqRbNesF ziMvm})GX_YcA>`1U!WY}8H_|hbinr4^lpFuvwSO8!K_sy z?T2`r<#Vqp*ItRUqq)M?bYi)xUV@K+Rqg%acUM(n3KlF~vnl>pMidzM*#ZlXR$2M% ze|ios^VII?a Date: Sat, 26 Sep 2026 11:28:14 +0000 Subject: [PATCH 009/284] docs(android): mark WP1 fixes in the audit index, handover and design notes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 18 +++++++++++------- docs/android-handover.md | 26 ++++++++++++++++++++++---- docs/android.md | 19 +++++++++++++------ 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index 624dc3eed9..9cfd11b0c1 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -6,20 +6,24 @@ Severity is the impact judge's rating, not the auditor's claim. **BLOCK** means Prioritised plan and handover: [`android-handover.md`](android-handover.md). +**Status of fixes.** A fixed finding is marked `✅ fixed in ` in the index; its section below +keeps the original audit text (line numbers still refer to `2f58918`). Where the fix departs from +the audit's proposal, the handover's progress log says how and why. + ## Index | ID | Sev | Block | Effort | Area | Title | |---|---|---|---|---|---| -| [A01](#a01) | high | BLOCK | small | build/bug | Direct-SSH terminal writes and resizes run on the Android main thread, which breaks the SSH connection as soon as the keyboard opens | -| [A02](#a02) | high | BLOCK | small | protocol/bug | SSH browse looks for the desktop's userData under 'nodeterm', but the desktop writes it under 'node-terminal', so every direct-SSH listing comes back empty | -| [A03](#a03) | high | BLOCK | small | protocol/bug | The direct-SSH tmux client starts without a UTF-8 locale, so tmux replaces every non-ASCII character with '_' | -| [A04](#a04) | high | BLOCK | small | runtime/bug | SSH terminal writes and resizes run on the main thread, and the NetworkOnMainThreadException that runCatching swallows leaves sshj's cipher state out of sync, which drops the whole SSH connection | +| [A01](#a01) | high | BLOCK | small | build/bug | ✅ fixed in `af1f820` · Direct-SSH terminal writes and resizes run on the Android main thread, which breaks the SSH connection as soon as the keyboard opens | +| [A02](#a02) | high | BLOCK | small | protocol/bug | ✅ fixed in `e7c22eb` · SSH browse looks for the desktop's userData under 'nodeterm', but the desktop writes it under 'node-terminal', so every direct-SSH listing comes back empty | +| [A03](#a03) | high | BLOCK | small | protocol/bug | ✅ fixed in `cd69a1e` · The direct-SSH tmux client starts without a UTF-8 locale, so tmux replaces every non-ASCII character with '_' | +| [A04](#a04) | high | BLOCK | small | runtime/bug | ✅ fixed in `af1f820` · SSH terminal writes and resizes run on the main thread, and the NetworkOnMainThreadException that runCatching swallows leaves sshj's cipher state out of sync, which drops the whole SSH connection | | [A05](#a05) | high | BLOCK | small | runtime/risk | The background InboxWorker can open a never-approved relay handshake and raise the desktop's SAS approval dialog while the phone shows no code | -| [A06](#a06) | high | BLOCK | small | security/bug | approvals.answer reports success for a hold that already timed out, and clears NEEDS YOU on every surface | +| [A06](#a06) | high | BLOCK | small | security/bug | ✅ fixed in `16706f4` · approvals.answer reports success for a hold that already timed out, and clears NEEDS YOU on every surface | | [A07](#a07) | high | BLOCK | medium | critic/bug | Remote access fails the first time you are away from the computer: Auto never does the first relay handshake while the phone is at the desk, and pairing does not pin the phone's relay key | | [A08](#a08) | high | BLOCK | small | critic/bug | A cold attach over direct SSH creates the desktop's tmux session with no hook environment, so an agent resumed there never reports status, and the desktop never repairs it | | [A09](#a09) | medium | BLOCK | small | protocol/bug | Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine | -| [A10](#a10) | medium | BLOCK | small | ci-docs/risk | Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | +| [A10](#a10) | medium | BLOCK | small | ci-docs/risk | ✅ fixed in `fcda932` · Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | | [A11](#a11) | medium | | small | build/bug | Tapping an Inbox notification while the app is in the background does not open that computer | | [A12](#a12) | medium | | medium | protocol/bug | Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | | [A13](#a13) | medium | | medium | protocol/bug | Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | @@ -44,7 +48,7 @@ Prioritised plan and handover: [`android-handover.md`](android-handover.md). | [A32](#a32) | medium | | medium | critic/gap | No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm | | [A33](#a33) | medium | | medium | critic/bug | On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it | | [A34](#a34) | medium | | small | critic/bug | The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter | -| [A35](#a35) | medium | | small | critic/bug | approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.' | +| [A35](#a35) | medium | | small | critic/bug | ✅ fixed in `16706f4` · approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.' | | [A36](#a36) | medium | | small | critic/gap | After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself | | [A37](#a37) | low | | small | build/risk | proguard-rules.pro would not survive turning on minification (R8 missing-class errors) | | [A38](#a38) | low | | small | protocol/bug | Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question | diff --git a/docs/android-handover.md b/docs/android-handover.md index eb75276b0f..5da99a9e4a 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -11,14 +11,32 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr - The Android app exists (`android/`), and **CI built its debug APK successfully with AGP** (run [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730), artifact `nodeterm-android-debug`, which expires 2026-12-24). The protocol tests pass in CI too. -- **It has never been run on a phone.** A 170-agent audit found **9 distinct release blockers**. The - worst: typing in a direct-SSH terminal drops the connection (`A01`), and the direct-SSH session - list is empty on every real desktop (`A02`). Do not hand the APK to anyone until the next section - is done. +- **It has never been run on a phone.** A 170-agent audit found **9 distinct release blockers**. + WP1 (the unambiguous ones, including `A01` typing drops the SSH connection and `A02` the empty + SSH session list) is fixed on the branch; see the progress log below. Do not hand the APK to + anyone until WP2 is done and the device pass has run. - The next session should **fix the blockers**, then do a **device test pass** using the checklist below, then work down the medium findings. No PR is open, and none should be opened unless the user asks. +## Progress log + +Newest first. Each entry says what landed, how it was checked, and where the fix differs from the +audit's proposal. + +### WP1 (blockers): done on the branch, not device-verified + +| Finding | Commit | What changed | Checked by | +|---|---|---|---| +| A01 / A04 | `af1f820` | `SshStream` owns one single-thread writer for write/resize/scroll; a non-IO exception out of sshj's write path tears the transport down and fires `onClosed` instead of being swallowed. `SshHostConnection.close()` and `HostSession.disconnect()` never touch the socket on the caller's thread, and a failed `SSH_MSG_DISCONNECT` still closes the socket. | `SshTransportTest`: a socket factory that throws when used from a thread marked "main" (a JVM stand-in for StrictMode) drives resize/write/close from that thread; a poisoned transport must surface as `onClosed`. Mutation-checked. | +| A02 | `e7c22eb` | Prelude probes `…/node-terminal` first, `…/nodeterm` as a legacy fallback. A listing with no userData dir is now an explicit error, not an empty computer. `scripts/uninstall.sh` + `docs/uninstall.md` remove `node-terminal` (and legacy `nodeterm`) data, caches, logs and either Keychain spelling. | Fixture uses the real name; new `SshScriptsTest` runs the prelude under `/bin/sh` on Linux, macOS, XDG, legacy and both-present HOMEs. | +| A03 | `cd69a1e` | Attach script exports a UTF-8 LANG by the desktop's `resolveLocaleLang` rule (`en_US.UTF-8` on macOS, `C.UTF-8` elsewhere) and passes `-u`. | Harness no longer leaks the JVM's LANG; `╭é` from ASCII input must arrive intact. Mutation-checked. | +| A06 / A35 | `16706f4` | Desktop writers check `.json` before writing (`fs.access`; `[ -f … ] \|\| exit 3` in the same SSH command) and return `sent \| gone \| failed`; only `sent` emits the synthetic answered event. `approvals.answer` adds `reason` (additive). Phone: `ApprovalOutcome.GONE`; if the node is still blocked, `QuickActions` returns `EXPIRED`, which opens the session with an explanation. | vitest (pending-approvals, ssh-project incl. the remote command under a real `/bin/sh`, host inbox verbs); SSH transport test; relay interop through the desktop's real verb. | +| A10 | `fcda932` | `android/app/debug.keystore` committed and wired as the debug signing config. **Departs from the verifier's advice** (it warned that a public key lets anyone sign an update that inherits the app's data); the user chose it for sideloading. The trade-off is written in `build.gradle.kts` and both READMEs. | CI build (AGP cannot run in the sandbox). | + +The iOS-facing part of A06: `approvals.answer` now replies `{answered:false, reason:"gone"|"failed"}` +when it did not deliver. iOS can adopt `reason` unchanged; an older phone keeps reading `answered`. + ## Where things are | | | diff --git a/docs/android.md b/docs/android.md index 1f2ee77625..a1cbaf1013 100644 --- a/docs/android.md +++ b/docs/android.md @@ -41,7 +41,7 @@ describes as the future. The Android client implements what the host actually se | Wake, refresh, rename | `node.wake|refresh|rename` | — (needs the desktop app) | | Board | `projects.ensureBoard|setCardColumn|editCardLabels` | read-only | | New session | `pty.attach` of a fresh `term-…` id, launch line, then `projects.registerNode` | — | -| Answer a held approval | **`approvals.answer`** (new) | write `~/.nodeterm/pending/.answer` | +| Answer a held approval | **`approvals.answer`** (new) → `{answered}`, plus `reason: gone\|failed` when not | write `~/.nodeterm/pending/.answer` (prints `gone` when the hold ended) | | Read-ack | **`inbox.ack`** (new) | write `~/.nodeterm/acks/.seen` | `resizedFrames` is deliberately not sent on attach, matching iOS: the phone is a size *ceiling* on @@ -85,13 +85,20 @@ unchanged. The **app** module is built by CI (`.github/workflows/android.yml`) against the runner's Android SDK. The first run, [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730) on `2f58918`, built the debug APK successfully. The app has no instrumented tests and has **not been -run on a device**. An audit of the code found release blockers, all still open: -[`android-audit-2026-09.md`](android-audit-2026-09.md). The plan, the device checklist and the +run on a device**. An audit of the code found release blockers; the fixed ones are marked in its +index, and the rest are open: [`android-audit-2026-09.md`](android-audit-2026-09.md). The plan, the device checklist and the decisions still open are in [`android-handover.md`](android-handover.md). -Two test caveats. The fixtures hand-copy some desktop shapes: the mirror, the `projects.list` blob -and the `~/.nodeterm` files (audit `A64`). Because of that, one wrong path (`A02`, the desktop's -userData directory is `node-terminal`, not `nodeterm`) passed its test. +A test caveat: the fixtures hand-copy some desktop shapes — the mirror, the `projects.list` blob +and the `~/.nodeterm` files (audit `A64`). That is how a wrong userData path (`A02`: the desktop's +directory is `node-terminal`, not `nodeterm`) once passed its test; the fixture now uses the real +name, and `SshScriptsTest` runs the prelude under `/bin/sh` against both spellings. + +Since the audit, the SSH tests also cover: resize/keystrokes/close from a thread that must not do +network I/O (a JVM stand-in for Android's StrictMode, `A01`), a transport that breaks mid-write +(`A01`), and non-ASCII through an attach whose host sets no locale (`A03`). The relay interop tests +cover an approval answered after the hook's hold ended (`A06`), and the desktop tests run the +generated SSH answer command under a real `/bin/sh`. ## Known gaps From 3d36d60c8f2fe7806d149c5659c636547120c24f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:31:16 +0000 Subject: [PATCH 010/284] fix(android): no first relay handshake from the background; honour Deny (A05/A17/A23/A30) The desktop raises its SAS approval dialog as soon as a never-pinned phone completes the relay handshake. The 15-minute Inbox worker could be that first handshake, putting the dialog on an unattended desktop with no code on the phone to compare; and after Deny, the 8 s poll re-dialed and the dialog came straight back. protocol: RelayApprovalGate decides who may dial the relay leg: - BACKGROUND dials only a computer where a relay connect has succeeded (per-host persisted flag), and then with requireApproved, so a revoked pin gives up instead of waiting and the flag is cleared; - a refused (Deny/dismiss) or unanswered approval holds automatic dials with the reason until the USER asks again (Try again, Refresh, opening the computer). RelayConnector reports these as typed exceptions (Refused / Timeout / Required) and takes requireApproved. app: HostStore persists relayApproved.; HostSession threads a Trigger through refreshNow/ensureConnected/connectLocked; the worker uses BACKGROUND, the buttons and the first poll after opening use USER. Tests: RelayApprovalGateTest; relay interop through the desktop's real host session: Deny reads as a refusal (fixture gained a reject mode), and a requireApproved dial fails fast without surfacing a code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/NodetermApp.kt | 4 + .../android/conn/ConnectionManager.kt | 38 +++++-- .../dev/nodeterm/android/data/HostStore.kt | 13 ++- .../nodeterm/android/notify/InboxNotifier.kt | 5 +- .../nodeterm/protocol/host/HostConnection.kt | 2 +- .../protocol/host/RelayApprovalGate.kt | 105 ++++++++++++++++++ .../nodeterm/protocol/host/RelayConnector.kt | 23 +++- .../protocol/src/test/interop/host-fixture.ts | 3 + .../protocol/RelayApprovalGateTest.kt | 70 ++++++++++++ .../dev/nodeterm/protocol/RelayInteropTest.kt | 43 ++++++- 10 files changed, 286 insertions(+), 20 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApprovalGateTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt b/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt index 47616a3952..4f94bb79db 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt @@ -7,6 +7,7 @@ import dev.nodeterm.android.data.HostStore import dev.nodeterm.android.data.SecureStore import dev.nodeterm.android.notify.InboxNotifier import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.host.RelayApprovalGate import dev.nodeterm.protocol.pairing.SshIdentity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,6 +22,9 @@ class AppGraph(context: Context) { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val connections = ConnectionManager(this) + /** Who may dial a computer's relay, and when (never a first handshake from the background). */ + val relayGate = RelayApprovalGate(hosts::relayApproved, hosts::setRelayApproved) + /** The phone's persistent relay identity — the key every paired desktop PINS. */ val boxKeys: BoxKeyPair by lazy { BoxKeyPair.fromSecretKey(secure.getOrCreate32(SecureStore.BOX_SECRET)) } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index 608ef81273..3443e37d62 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -5,6 +5,8 @@ import dev.nodeterm.android.data.RoutePreference import dev.nodeterm.android.data.SecureStore import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.RelayApprovalGate +import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.RelayConnector import dev.nodeterm.protocol.host.TransportKind @@ -63,12 +65,12 @@ class HostSession(val hostId: String, private val graph: AppGraph) { /** A screen is showing this computer right now (so its connection is worth keeping open). */ val isWatched: Boolean get() = synchronized(this) { watchers > 0 } - suspend fun ensureConnected(): HostConnection { + suspend fun ensureConnected(trigger: Trigger = Trigger.AUTO): HostConnection { conn?.let { return it } - return mutex.withLock { conn ?: connectLocked() } + return mutex.withLock { conn ?: connectLocked(trigger) } } - private suspend fun connectLocked(): HostConnection { + private suspend fun connectLocked(trigger: Trigger): HostConnection { val host = graph.hosts.get(hostId) ?: throw HostException("This computer is no longer paired.") val route = graph.hosts.route(hostId) val errors = ArrayList() @@ -100,7 +102,11 @@ class HostSession(val hostId: String, private val graph: AppGraph) { val relay = host.relay val token = graph.secure.getString(SecureStore.relayTokenKey(host.id)) val hostKey = host.relayHostKeyB64 - if (relay != null && token != null && hostKey != null) { + val decision = if (relay != null && token != null && hostKey != null) graph.relayGate.decide(hostId, trigger) else null + if (decision is RelayApprovalGate.Decision.Skip) { + errors += "Through the relay: ${decision.reason}" + } else if (relay != null && token != null && hostKey != null) { + val requireApproved = (decision as? RelayApprovalGate.Decision.Dial)?.requireApproved == true _state.value = ConnState.Connecting("Connecting through the relay…") try { val join = RelayApi(graph.hosts.apiBase).join(token, relay.hostId) @@ -109,6 +115,7 @@ class HostSession(val hostId: String, private val graph: AppGraph) { token = join.pairingToken, deviceKeys = graph.boxKeys, hostPublicKeyB64 = hostKey, + requireApproved = requireApproved, onStatus = { st -> when (st) { is RelayConnectStatus.AwaitingApproval -> _state.value = ConnState.AwaitingApproval(st.sas) @@ -117,10 +124,14 @@ class HostSession(val hostId: String, private val graph: AppGraph) { } } ) + graph.relayGate.onConnected(hostId) _snapshot.value = connected.first adopt(connected.connection) return connected.connection + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { + graph.relayGate.onFailed(hostId, e) errors += "Through the relay: ${e.message ?: e.javaClass.simpleName}" } } else if (route == RoutePreference.RELAY_ONLY || !host.sshAvailable) { @@ -186,10 +197,14 @@ class HostSession(val hostId: String, private val graph: AppGraph) { } } - /** Re-list now. Connects first when needed; failures land in [state]/[lastError], never throw. */ - suspend fun refreshNow() { + /** + * Re-list now. Connects first when needed; failures land in [state]/[lastError], never throw. + * [trigger] says who asked: a [Trigger.USER] refresh releases a held relay approval, a + * [Trigger.BACKGROUND] one never makes a first relay handshake (see [RelayApprovalGate]). + */ + suspend fun refreshNow(trigger: Trigger = Trigger.AUTO) { try { - val c = ensureConnected() + val c = ensureConnected(trigger) _snapshot.value = c.listProjects() _lastError.value = null } catch (e: kotlinx.coroutines.CancellationException) { @@ -203,8 +218,9 @@ class HostSession(val hostId: String, private val graph: AppGraph) { } } + /** A refresh the USER asked for (Refresh, Try again). */ fun refresh() { - scope.launch { refreshNow() } + scope.launch { refreshNow(Trigger.USER) } } /** While a screen shows this computer, re-list every 8 s (the iOS foreground cadence). */ @@ -213,8 +229,11 @@ class HostSession(val hostId: String, private val graph: AppGraph) { watchers++ if (pollJob == null) { pollJob = scope.launch { + // Opening the computer is the user asking; the polls after it are the app's own. + var trigger = Trigger.USER while (isActive) { - refreshNow() + refreshNow(trigger) + trigger = Trigger.AUTO delay(POLL_MS) } } @@ -255,6 +274,7 @@ class ConnectionManager(private val graph: AppGraph) { @Synchronized fun forget(hostId: String) { + graph.relayGate.forget(hostId) sessions.remove(hostId)?.disconnect() } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt index 05e6dfbabd..08d79ae22d 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt @@ -52,7 +52,18 @@ class HostStore(context: Context) { @Synchronized fun remove(id: String) { save(_hosts.value.filterNot { it.id == id }) - prefs.edit().remove("route.$id").apply() + prefs.edit().remove("route.$id").remove("relayApproved.$id").apply() + } + + /** + * This computer serves this phone over the relay without an approval dialog: a relay connect has + * succeeded (its standing host pinned our box key), or pairing pinned it. Gates the background + * worker's relay leg (dev.nodeterm.protocol.host.RelayApprovalGate, audit A05). + */ + fun relayApproved(id: String): Boolean = prefs.getBoolean("relayApproved.$id", false) + + fun setRelayApproved(id: String, approved: Boolean) { + prefs.edit().putBoolean("relayApproved.$id", approved).apply() } fun route(id: String): RoutePreference = diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt index bfb7280ed5..5c14041f40 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -21,6 +21,7 @@ import androidx.work.WorkerParameters import dev.nodeterm.android.MainActivity import dev.nodeterm.android.NodetermApp import dev.nodeterm.android.R +import dev.nodeterm.protocol.host.RelayApprovalGate import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.PairedHost @@ -121,7 +122,9 @@ class InboxWorker(context: Context, params: WorkerParameters) : CoroutineWorker( for (host in graph.hosts.hosts.value) { val session = graph.connections.session(host.id) val watched = session.isWatched - withTimeoutOrNull(45_000) { session.refreshNow() } + // BACKGROUND: never a first relay handshake — that would raise the desktop's approval + // dialog with nobody at the phone to compare the code (audit A05). + withTimeoutOrNull(45_000) { session.refreshNow(RelayApprovalGate.Trigger.BACKGROUND) } InboxNotifier.announce(applicationContext, host, session.snapshot.value) // Don't hold a socket open in the background for a screen nobody is looking at. if (!watched && !session.isWatched) session.disconnect() diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt index a649086772..299edfadf7 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -112,4 +112,4 @@ enum class GitVerb(val wire: String) { COMMIT("git.commit"), PUSH("git.push"), PULL("git.pull"), HISTORY("git.history") } -class HostException(message: String) : Exception(message) +open class HostException(message: String) : Exception(message) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt new file mode 100644 index 0000000000..575f7b3bc1 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt @@ -0,0 +1,105 @@ +package dev.nodeterm.protocol.host + +/** The desktop has not pinned this phone, and the caller asked not to trigger its approval dialog. */ +class RelayApprovalRequiredException : + HostException("This phone is not approved on your computer yet. Open the app and connect once to approve it.") + +/** The approval dialog was dismissed (Deny) — the host closed the handshake while we waited. */ +class RelayApprovalRefusedException : + HostException("Your computer did not approve this phone (the request was declined or dismissed).") + +/** Nobody answered the approval dialog before the phone stopped waiting. */ +class RelayApprovalTimeoutException : + HostException("Nobody approved this phone on your computer in time.") + +/** + * Who may dial a computer's relay leg, and when (audit A05/A17/A23/A30). + * + * The desktop raises its SAS approval dialog the moment a never-pinned phone completes the relay + * handshake (standing-host.ts `onPeerReady`) — BEFORE the phone learns it is awaiting approval, so + * "abort on awaiting" cannot keep the dialog off an unattended desktop. The only prevention is not + * dialing. Two rules: + * + * 1. **Background never makes a first handshake.** A background check (the Inbox worker) dials the + * relay only for a computer on which a FOREGROUND relay connect has already succeeded — its key + * is pinned there, so the host approves silently — and even then asks the connector to give up + * rather than wait if approval turns out to be needed (the pin was revoked), and forgets the + * approval so it does not try again. + * 2. **A refused or unanswered approval is not retried on its own.** After Deny, or nobody + * answering, the automatic dials (the 8 s poll, reconnect-after-drop) skip the relay leg with the + * reason, until the user asks again (Try again / opening the computer). Before this, the phone + * re-dialed about 8 s after Deny and the dialog came straight back. + * + * Pure (state in memory + the two injected accessors for the persisted flag), so it is JVM-tested. + */ +class RelayApprovalGate( + private val isApproved: (hostId: String) -> Boolean, + private val setApproved: (hostId: String, approved: Boolean) -> Unit +) { + /** Why a dial is happening. */ + enum class Trigger { + /** The user asked (Try again, Refresh, opening the computer): releases a hold. */ + USER, + /** The app on its own, in the foreground (the poll, a reconnect after a drop). */ + AUTO, + /** Nobody is looking (the Inbox worker). */ + BACKGROUND + } + + sealed interface Decision { + /** Dial. With [requireApproved], abort instead of waiting for an approval dialog. */ + data class Dial(val requireApproved: Boolean) : Decision + data class Skip(val reason: String) : Decision + } + + private val holds = HashMap() + + @Synchronized + fun decide(hostId: String, trigger: Trigger): Decision { + if (trigger == Trigger.USER) holds.remove(hostId) + holds[hostId]?.let { return Decision.Skip(it) } + if (trigger == Trigger.BACKGROUND) { + return if (isApproved(hostId)) Decision.Dial(requireApproved = true) + else Decision.Skip(NOT_APPROVED_IN_BACKGROUND) + } + return Decision.Dial(requireApproved = false) + } + + /** A relay connect succeeded: the host serves us, so our key is pinned there. */ + @Synchronized + fun onConnected(hostId: String) { + holds.remove(hostId) + setApproved(hostId, true) + } + + /** A relay connect failed with [error]. */ + @Synchronized + fun onFailed(hostId: String, error: Throwable) { + when (error) { + is RelayApprovalRefusedException -> { + holds[hostId] = error.message + " Tap Try again to ask again." + setApproved(hostId, false) + } + is RelayApprovalTimeoutException -> { + holds[hostId] = error.message + " Tap Try again when you are at the computer." + setApproved(hostId, false) + } + is RelayApprovalRequiredException -> setApproved(hostId, false) + else -> Unit // network, relay, backend: ordinary failures, retried as before + } + } + + /** The current hold for [hostId], if automatic dials are suspended. */ + @Synchronized + fun hold(hostId: String): String? = holds[hostId] + + @Synchronized + fun forget(hostId: String) { + holds.remove(hostId) + } + + companion object { + const val NOT_APPROVED_IN_BACKGROUND = + "Not checked in the background: this phone has not been approved on the computer through the relay yet." + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt index 385196c647..2232dcd6d7 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt @@ -40,7 +40,10 @@ object RelayConnector { transport: RelayTransportFactory = OkHttpRelayTransport.factory(), handshakeTimeoutMs: Long = 20_000, approvalTimeoutMs: Long = 5 * 60_000, - approvalPollMs: Long = 1_500 + approvalPollMs: Long = 1_500, + /** Give up with [RelayApprovalRequiredException] instead of waiting for an approval dialog + * (a background check: nobody is there to compare the code). See [RelayApprovalGate]. */ + requireApproved: Boolean = false ): Connected { onStatus(RelayConnectStatus.Handshaking) val ready = CompletableDeferred() @@ -67,19 +70,31 @@ object RelayConnector { val deadline = System.currentTimeMillis() + approvalTimeoutMs var announced = false while (true) { - if (closed.isCompleted) throw HostException("The connection closed while waiting for approval.") + // The standing host drops the handshake when the human presses Deny (or dismisses + // the dialog): once we have shown the code, a close IS the refusal. + if (closed.isCompleted) { + if (announced) throw RelayApprovalRefusedException() + throw HostException("The connection closed while waiting for approval.") + } try { val snapshot = conn.listProjects() conn.setOnClosed(null) return Connected(conn, sas, snapshot) } catch (e: HostException) { - if (e.message != AWAITING_APPROVAL_MESSAGE) throw e + if (e.message != AWAITING_APPROVAL_MESSAGE) { + // A request cut short by the close IS the refusal too (same reasoning). + if (announced && withTimeoutOrNull(1_000) { closed.await() } != null || announced && closed.isCompleted) { + throw RelayApprovalRefusedException() + } + throw e + } + if (requireApproved) throw RelayApprovalRequiredException() if (!announced) { announced = true onStatus(RelayConnectStatus.AwaitingApproval(sas)) } if (System.currentTimeMillis() > deadline) { - throw HostException("Nobody approved this phone on your computer in time.") + throw RelayApprovalTimeoutException() } delay(approvalPollMs) } diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index 3966df6914..8d5fabb0c4 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -88,6 +88,8 @@ async function runRelay(): Promise { const port = await startBroker() const keys = genKeyPair() const approveAfter = Number(process.env.FIXTURE_APPROVE_AFTER_MS ?? '0') + // "Deny" on the desktop: standing-host.ts removes the pooled session, which closes it. + const rejectAfter = Number(process.env.FIXTURE_REJECT_AFTER_MS ?? '-1') // A snapshot over the 256 KB chunk size, made of 3-byte code points so a chunk boundary splits // one: the reassembler must join BYTES before decoding. const bigSnapshot = 'SNAP-' + '€'.repeat(100_000) + '-END' @@ -211,6 +213,7 @@ async function runRelay(): Promise { onPeerReady: (s) => { emit({ event: 'peer-ready', sas: s.sas(), pub: s.peerPublicKeyB64() }) if (approveAfter >= 0) setTimeout(approveNow, approveAfter) + if (rejectAfter >= 0) setTimeout(() => (emit({ event: 'rejected' }), session?.close()), rejectAfter) }, onClose: () => emit({ event: 'host-close' }) }) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApprovalGateTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApprovalGateTest.kt new file mode 100644 index 0000000000..0cdd28ce7f --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApprovalGateTest.kt @@ -0,0 +1,70 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.RelayApprovalGate +import dev.nodeterm.protocol.host.RelayApprovalGate.Decision +import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger +import dev.nodeterm.protocol.host.RelayApprovalRefusedException +import dev.nodeterm.protocol.host.RelayApprovalRequiredException +import dev.nodeterm.protocol.host.RelayApprovalTimeoutException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Audit A05/A17/A23/A30: who may dial the relay, and when. */ +class RelayApprovalGateTest { + private val approved = HashMap() + private val gate = RelayApprovalGate({ approved[it] == true }, { id, v -> approved[id] = v }) + + @Test + fun `the background worker never makes a first relay handshake`() { + assertIs(gate.decide("h", Trigger.BACKGROUND)) + // The foreground may: that is where the code is shown. + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.AUTO)) + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.USER)) + } + + @Test + fun `after a foreground connect succeeds the worker may dial, but never waits for approval`() { + gate.onConnected("h") + assertTrue(approved["h"]!!) + assertEquals(Decision.Dial(requireApproved = true), gate.decide("h", Trigger.BACKGROUND)) + } + + @Test + fun `a revoked pin found in the background is forgotten so the worker stops trying`() { + gate.onConnected("h") + gate.onFailed("h", RelayApprovalRequiredException()) + assertFalse(approved["h"]!!) + assertIs(gate.decide("h", Trigger.BACKGROUND)) + } + + @Test + fun `Deny holds automatic dials until the user asks again`() { + gate.onFailed("h", RelayApprovalRefusedException()) + val auto = gate.decide("h", Trigger.AUTO) + assertIs(auto) + assertTrue(auto.reason.contains("declined")) + assertIs(gate.decide("h", Trigger.BACKGROUND)) + // Only the user releases it. + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.USER)) + assertNull(gate.hold("h")) + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.AUTO)) + } + + @Test + fun `an unanswered approval holds too, and holds are per computer`() { + gate.onFailed("h", RelayApprovalTimeoutException()) + assertIs(gate.decide("h", Trigger.AUTO)) + assertEquals(Decision.Dial(requireApproved = false), gate.decide("other", Trigger.AUTO)) + } + + @Test + fun `ordinary failures are retried as before`() { + gate.onFailed("h", HostException("relay unreachable")) + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.AUTO)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index 3bc5cfce89..e47147e57a 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -7,6 +7,9 @@ import dev.nodeterm.protocol.host.NewNode import dev.nodeterm.protocol.host.QuickActions import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.RelayConnector +import dev.nodeterm.protocol.host.RelayApprovalRefusedException +import dev.nodeterm.protocol.host.RelayApprovalRequiredException +import dev.nodeterm.protocol.host.RelayApprovalTimeoutException import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind @@ -39,15 +42,25 @@ class RelayInteropTest { @AfterTest fun tearDown() = harnesses.forEach { it.close() } - private fun start(approveAfterMs: Long = 0): InteropHarness = - InteropHarness.start("relay", mapOf("FIXTURE_APPROVE_AFTER_MS" to approveAfterMs.toString())).also { harnesses += it } + private fun start(approveAfterMs: Long = 0, rejectAfterMs: Long = -1): InteropHarness = + InteropHarness.start( + "relay", + mapOf("FIXTURE_APPROVE_AFTER_MS" to approveAfterMs.toString(), "FIXTURE_REJECT_AFTER_MS" to rejectAfterMs.toString()) + ).also { harnesses += it } private fun InteropHarness.str(key: String) = ready[key]!!.jsonPrimitive.content private fun JsonObject.str(key: String) = this[key]!!.jsonPrimitive.content - private fun connect(h: InteropHarness, keys: BoxKeyPair = BoxKeyPair.generate(), statuses: MutableList? = null, approvalTimeoutMs: Long = 20_000) = + private fun connect( + h: InteropHarness, + keys: BoxKeyPair = BoxKeyPair.generate(), + statuses: MutableList? = null, + approvalTimeoutMs: Long = 20_000, + requireApproved: Boolean = false + ) = runBlocking { RelayConnector.connect( + requireApproved = requireApproved, relayUrl = h.str("relayUrl"), token = h.str("clientToken"), deviceKeys = keys, @@ -111,10 +124,32 @@ class RelayInteropTest { @Test fun `an unapproved phone is told so and never served`() { val h = start(approveAfterMs = -1) - val e = assertFailsWith { connect(h, approvalTimeoutMs = 1_200) } + val e = assertFailsWith { connect(h, approvalTimeoutMs = 1_200) } assertTrue(e.message!!.contains("approved"), e.message) } + @Test + fun `pressing Deny on the desktop reads as a refusal, not a network error`() { + // A30: the refusal has to be recognisable, or the phone re-dials and the dialog comes back. + val h = start(approveAfterMs = -1, rejectAfterMs = 600) + val statuses = ArrayList() + assertFailsWith { connect(h, statuses = statuses, approvalTimeoutMs = 10_000) } + assertTrue(statuses.any { it is RelayConnectStatus.AwaitingApproval }) + h.awaitEvent("rejected") + } + + @Test + fun `a background dial gives up instead of waiting on an approval dialog`() { + val h = start(approveAfterMs = -1) + val statuses = ArrayList() + val t0 = System.currentTimeMillis() + assertFailsWith { + connect(h, statuses = statuses, approvalTimeoutMs = 20_000, requireApproved = true) + } + assertTrue(System.currentTimeMillis() - t0 < 10_000, "it did not wait out the approval window") + assertTrue(statuses.none { it is RelayConnectStatus.AwaitingApproval }, "no code shown for a dial nobody watches") + } + @Test fun `projects list parses the desktop blob`() { val h = start() From 0fa06467bc2dcad4591751634d1af6bb6c5cf1ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:34:22 +0000 Subject: [PATCH 011/284] feat(pairing): pin the phone's relay key at pairing (A07) Pin-once relay approval used to happen on the phone's first relay connect. With the Auto route that connect only happens away from the desk, so it waited 5 minutes on a dialog nobody saw and failed, while PairScreen had promised "Reachable from anywhere". The scan is already the human's approval: the one-time token on screen authorizes installing an SSH key with full shell access. So: Desktop (additive): - /pair accepts `boxPublicKey` ONLY inside the sealed body (a plaintext body could be rewritten on the LAN), validated as 32 bytes of base64. - When the relay leg was minted, it is pinned through the injected pinRelayKey dep (the same updateApprovedDevices/pinDevice a SAS approval writes) and the answer says `relayPinned: true`. - The device entry records `relayBoxKey`; revoking the device unpins it unless another pairing of the same phone is still listed. (A relay session open at that moment is not cut by this path.) - A failed pin never fails the pairing; an older phone pairs unchanged. Android: - PairingClient sends the persistent box key in the sealed body and reads relayPinned; PairScreen marks the host relay-approved (so the background worker may use the relay), says "Remote access is on" rather than "Reachable from anywhere", and tells the user after pairing whether remote access still needs one approval at the computer. Tests: pairing-service (sealed pin + record + unpin on revoke, re-pair keeps the pin, plaintext never pins, malformed/absent key, failed mint); pairing interop through the desktop's real createPairingService. iOS can adopt `boxPublicKey` unchanged (@eneskirca). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/PairScreen.kt | 22 +++- .../protocol/pairing/PairingClient.kt | 14 ++- .../protocol/pairing/PairingPayload.kt | 5 +- .../protocol/src/test/interop/host-fixture.ts | 4 +- .../nodeterm/protocol/PairingInteropTest.kt | 22 ++++ src/main/index.ts | 8 +- src/main/pairing-core.ts | 13 ++ src/main/pairing-service.test.ts | 119 +++++++++++++++++- src/main/pairing-service.ts | 50 +++++++- 9 files changed, 242 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt index f8e522f5bb..bb2b397176 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt @@ -1,5 +1,6 @@ package dev.nodeterm.android.ui +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -57,7 +58,8 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun PairScreen(nav: Navigator, initialCode: String? = null) { - val graph = NodetermApp.graph(LocalContext.current) + val context = LocalContext.current + val graph = NodetermApp.graph(context) val scope = rememberCoroutineScope() var raw by remember { mutableStateOf("") } var payload by remember { mutableStateOf(null) } @@ -120,7 +122,10 @@ fun PairScreen(nav: Navigator, initialCode: String? = null) { Text("${p.user}@${p.host}", color = MaterialTheme.colorScheme.onSurfaceVariant) Text(if (p.hostKey != null) "✓ The pairing exchange is end-to-end encrypted" else "⚠ This computer's nodeterm is too old to encrypt pairing") Text(if (p.sshAvailable) "✓ Direct connection on your network (SSH)" else "Relay-only computer (no SSH)") - Text(if (p.relay != null) "✓ Reachable from anywhere (remote access is on)" else "Remote access is off — this phone will only reach the computer on your network") + // Not "reachable from anywhere": that is only true once the computer has + // approved this phone for the relay (at the scan on a current desktop, or on + // the first relay connect on an older one) — audit A07. + Text(if (p.relay != null) "Remote access is on" else "Remote access is off — this phone will only reach the computer on your network") } } if (!p.sshAvailable && p.relay == null) { @@ -144,7 +149,8 @@ fun PairScreen(nav: Navigator, initialCode: String? = null) { sshPublicKeyLine = graph.sshIdentity.authorizedKeysLine("nodeterm-android"), deviceName = graph.hosts.deviceName, deviceId = graph.hosts.deviceId, - priorDeviceToken = prior + priorDeviceToken = prior, + boxPublicKeyB64 = graph.boxKeys.publicKeyB64 ) val host = PairedHost.from(p, result) result.relayDeviceToken?.let { graph.secure.putString(SecureStore.relayTokenKey(host.id), it) } @@ -154,6 +160,16 @@ fun PairScreen(nav: Navigator, initialCode: String? = null) { graph.hosts.remove(it.id) } graph.hosts.upsert(host) + if (result.relayPinned) graph.hosts.setRelayApproved(host.id, true) + Toast.makeText( + context, + when { + result.relayDeviceToken == null -> "Paired. This phone reaches the computer on your network." + result.relayPinned -> "Paired, and approved for remote access." + else -> "Paired. The first time you connect from outside your network, approve this phone on the computer." + }, + Toast.LENGTH_LONG + ).show() nav.replaceAll(Route.Hosts) nav.push(Route.Host(host.id)) } catch (e: Exception) { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt index f44db0a4d5..1d563207c1 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt @@ -22,7 +22,10 @@ class PairingException(message: String) : Exception(message) /** * The phone side of `src/main/pairing-service.ts`'s `/pair` listener. * - * Request body `{token, publicKey, deviceName, deviceId, priorDeviceToken?}`. When the QR carried a + * Request body `{token, publicKey, deviceName, deviceId, priorDeviceToken?, boxPublicKey?}`. + * `boxPublicKey` (the phone's persistent relay identity) is sent ONLY inside the sealed body; a + * desktop that knows it pins it on its standing host and answers `relayPinned: true`, so the first + * relay connect needs no approval at the desk (audit A07). An older desktop ignores it. When the QR carried a * `hostKey`, the whole body is sealed to it — `{epk: , box: base64(nonce ‖ * secretbox)}` under `box.before(hostKey, ephemeralSecret)` — and the answer comes back sealed the * same way, so the relay device token never crosses the LAN in the clear. @@ -39,7 +42,8 @@ class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readT sshPublicKeyLine: String, deviceName: String, deviceId: String, - priorDeviceToken: String? = null + priorDeviceToken: String? = null, + boxPublicKeyB64: String? = null ): PairingResult = withContext(Dispatchers.IO) { val inner = buildJsonObject { put("token", payload.token) @@ -47,6 +51,9 @@ class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readT put("deviceName", deviceName) put("deviceId", deviceId) priorDeviceToken?.let { put("priorDeviceToken", it) } + // Only ever sealed: a plaintext body could be rewritten on the LAN, and the desktop + // ignores the field there anyway. + if (payload.hostKey != null) boxPublicKeyB64?.let { put("boxPublicKey", it) } }.toString() var shared: ByteArray? = null @@ -83,7 +90,8 @@ class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readT deviceId = obj.s("deviceId") ?: throw PairingException("The computer did not assign a device id."), agentToken = obj.s("agentToken") ?: "", relay = obj.o("relay")?.let(PairingPayload::parseRelayBlock), - relayDeviceToken = obj.s("relayDeviceToken") + relayDeviceToken = obj.s("relayDeviceToken"), + relayPinned = obj.b("relayPinned") == true ) } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt index 3d9bafce5e..330e709986 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt @@ -111,5 +111,8 @@ data class PairingResult( val deviceId: String, val agentToken: String, val relay: RelayBlock?, - val relayDeviceToken: String? + val relayDeviceToken: String?, + /** The computer pinned this phone's relay key at pairing (audit A07): no SAS on first relay use. + * False from a desktop that predates it — the phone is approved on its first relay connect. */ + val relayPinned: Boolean = false ) diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index 8d5fabb0c4..4301d5e75a 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -248,7 +248,9 @@ async function runPair(): Promise { loadHostKeyPair: async () => keys, relayEndpoint: 'wss://relay.example.test', apiBase: `http://127.0.0.1:${apiPort}`, - relayAllowed: () => withRelay + relayAllowed: () => withRelay, + pinRelayKey: async (pub) => emit({ event: 'pin', pub }), + unpinRelayKey: async (pub) => emit({ event: 'unpin', pub }) }, { timeoutMs: 60_000 } ) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt index 35ce2cfb86..eeeb475487 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt @@ -94,4 +94,26 @@ class PairingInteropTest { assertTrue(e.message!!.contains("bad token"), e.message) assertTrue(!File(home, ".ssh/authorized_keys").exists()) } + + @Test + fun `the phone's relay key rides the sealed body and the desktop pins it`() = runBlocking { + // A07: approving at the scan, so the first remote connect needs nobody at the desk. + val (h, _) = start(withRelay = true) + val box = dev.nodeterm.protocol.crypto.BoxKeyPair.generate() + val result = PairingClient().pair( + payloadOf(h), SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-3", boxPublicKeyB64 = box.publicKeyB64 + ) + assertTrue(result.relayPinned) + assertEquals(box.publicKeyB64, h.awaitEvent("pin")["pub"]!!.jsonPrimitive.content) + } + + @Test + fun `without a relay leg nothing is pinned and the answer says so`() = runBlocking { + val (h, _) = start(withRelay = false) + val box = dev.nodeterm.protocol.crypto.BoxKeyPair.generate() + val result = PairingClient().pair( + payloadOf(h), SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-4", boxPublicKeyB64 = box.publicKeyB64 + ) + assertTrue(!result.relayPinned) + } } diff --git a/src/main/index.ts b/src/main/index.ts index 266acaaa43..d5e1888c88 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -304,6 +304,7 @@ import { killRelayHostsByPeerKey } from './remote/relay-host' import { initRelayHost } from './remote/relay-host-service' import { createRevoker } from './remote/revocation' import { loadApprovedDevices, saveApprovedDevices, updateApprovedDevices } from './remote/approved-devices' +import { pinDevice, unpinDevice } from './remote/approved-devices-core' import { publicKeyToB64 } from './remote/e2ee' import { connectRelayClient, type RelayClientSession } from './remote/relay-client' import { decodeOffer } from './remote/pairing' @@ -1655,7 +1656,12 @@ app.whenReady().then(async () => { loadHostKeyPair: loadOrCreateKeyPair, relayEndpoint: RELAY_URL, apiBase: RELAY_API_BASE, - relayAllowed + relayAllowed, + // A phone that sends its relay key in the sealed /pair body is approved by the scan itself + // (audit A07): the same pin a SAS approval writes, so its first remote connect needs nobody at + // the desk. Revoking the device takes the pin away again. + pinRelayKey: (pub) => updateApprovedDevices((store) => pinDevice(store, pub)), + unpinRelayKey: (pub) => updateApprovedDevices((store) => unpinDevice(store, pub)) }) ipcMain.handle(IPC.pairingStart, () => pairingService.start((result) => { diff --git a/src/main/pairing-core.ts b/src/main/pairing-core.ts index 5ba786c28f..695ee87173 100644 --- a/src/main/pairing-core.ts +++ b/src/main/pairing-core.ts @@ -153,6 +153,12 @@ export interface DeviceEntry { * its presence names a row the server MAY hold, not one it certainly does. */ relayDeviceId?: string + /** + * The phone's relay (NaCl box) public key, base64, when it sent one inside the SEALED `/pair` + * body. Pairing pins it on the standing host (audit A07), so revoking the device unpins it again. + * Absent for pairings made before this field existed and for phones that do not send it. + */ + relayBoxKey?: string /** * `false` = no SSH key was installed for this device (a Windows pairing: the phone reaches this * host through the relay only). Absent on every pairing that did install one, including all @@ -188,6 +194,13 @@ export function upsertDevice(devices: DeviceEntry[], entry: DeviceEntry): Device return [...devices.filter((d) => d.id !== entry.id), entry] } +/** A NaCl box public key as base64: exactly 32 bytes. */ +export function isValidBoxPublicKeyB64(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 64) return false + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false + return Buffer.from(value, 'base64').length === 32 +} + /** Drop the device with the given id (no-op if absent). */ export function removeDevice(devices: DeviceEntry[], id: string): DeviceEntry[] { return devices.filter((d) => d.id !== id) diff --git a/src/main/pairing-service.test.ts b/src/main/pairing-service.test.ts index 5ca650742e..391e157b0b 100644 --- a/src/main/pairing-service.test.ts +++ b/src/main/pairing-service.test.ts @@ -45,7 +45,7 @@ vi.mock('../core/device-id', () => ({ getDeviceId: () => 'test-host-device-id' } import os from 'os' import { createPairingService, type PairingRelayDeps } from './pairing-service' import { rewriteKeyComment, type DeviceEntry } from './pairing-core' -import { genKeyPair, publicKeyToB64 } from './remote/e2ee' +import { decrypt, deriveSharedKey, encrypt, genKeyPair, publicKeyToB64 } from './remote/e2ee' import type { Settings } from '../shared/types' const HOME = os.homedir() @@ -412,3 +412,120 @@ describe('pairing remembers the phone’s relay device id', () => { } }) }) + +describe('pairing pins the phone relay key it sent sealed (audit A07)', () => { + // Pin-once used to mean "approve on the first relay connect", which the Auto route makes happen + // away from the desk. The scan is already the human's approval — the same one-time token that + // installs an SSH key — so the phone's box key, sent INSIDE the sealed body, is pinned here. + const hostKeys = genKeyPair() + const phoneBox = publicKeyToB64(genKeyPair().publicKey) + const pins: string[] = [] + const unpins: string[] = [] + const relayDeps = (withPin = true): PairingRelayDeps => ({ + getSettings: () => ({ phoneAccessEnabled: true }) as unknown as Settings, + getEntitlement: () => null, + loadHostKeyPair: async () => hostKeys, + relayEndpoint: 'wss://relay.example/ws', + apiBase: 'https://api.example', + relayAllowed: () => true, + ...(withPin + ? { + pinRelayKey: async (pub: string) => void pins.push(pub), + unpinRelayKey: async (pub: string) => void unpins.push(pub) + } + : {}) + }) + + /** POST /pair SEALED, the way the Android/iOS app does: `{epk, box}` to the QR's host key. */ + async function postSealed(port: number, body: unknown): Promise> { + const eph = genKeyPair() + const shared = deriveSharedKey(publicKeyToB64(hostKeys.publicKey), eph.secretKey) + const box = encrypt(Uint8Array.from(Buffer.from(JSON.stringify(body), 'utf8')), shared) + const text = await post(port, { epk: publicKeyToB64(eph.publicKey), box: Buffer.from(box).toString('base64') }) + const outer = JSON.parse(text) as { box: string } + const plain = decrypt(Uint8Array.from(Buffer.from(outer.box, 'base64')), shared) + return JSON.parse(Buffer.from(plain!).toString('utf8')) + } + + beforeEach(() => { + pins.length = 0 + unpins.length = 0 + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ deviceToken: 'device-token', hostId: 'host-id', exp: 0 }) + } as unknown as Response) + }) + + it('pins the sealed box key, says so, records it, and unpins it on revoke', async () => { + const service = createPairingService(relayDeps()) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = await postSealed(pairPort, { token, publicKey: freshEd25519Line(), deviceId: 'p1', boxPublicKey: phoneBox }) + expect(resp.relayPinned).toBe(true) + expect(pins).toEqual([phoneBox]) + const entry = ((agentJson().devices as DeviceEntry[]) ?? []).find((d) => d.id === resp.deviceId)! + expect(entry.relayBoxKey).toBe(phoneBox) + await service.revokeDevice(String(resp.deviceId)) + expect(unpins).toEqual([phoneBox]) + } finally { + service.stop() + } + }) + + it('keeps the pin while another pairing of the same phone is still listed', async () => { + const service = createPairingService(relayDeps()) + try { + const first = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const a = await postSealed(first.pairPort, { token: first.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + const second = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + await postSealed(second.pairPort, { token: second.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + await service.revokeDevice(String(a.deviceId)) + expect(unpins).toEqual([]) + } finally { + service.stop() + } + }) + + it('never pins a key from the PLAINTEXT body (it could be rewritten on the LAN)', async () => { + const service = createPairingService(relayDeps()) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = JSON.parse(await post(pairPort, { token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox })) + expect(resp.ok).toBe(true) + expect(resp.relayPinned).toBeUndefined() + expect(pins).toEqual([]) + } finally { + service.stop() + } + }) + + it('ignores a malformed key, and an older phone that sends none still pairs unchanged', async () => { + const service = createPairingService(relayDeps()) + try { + const s1 = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const bad = await postSealed(s1.pairPort, { token: s1.token, publicKey: freshEd25519Line(), boxPublicKey: 'not-a-key' }) + expect(bad.ok).toBe(true) + expect(bad.relayPinned).toBeUndefined() + const s2 = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const old = await postSealed(s2.pairPort, { token: s2.token, publicKey: freshEd25519Line() }) + expect(old.ok).toBe(true) + expect(old.relayPinned).toBeUndefined() + expect(pins).toEqual([]) + } finally { + service.stop() + } + }) + + it('does not pin when the relay leg failed (nothing to connect through yet)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, status: 503, json: async () => ({}) } as unknown as Response) + const service = createPairingService(relayDeps()) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = await postSealed(pairPort, { token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + expect(resp.relayPinned).toBeUndefined() + expect(pins).toEqual([]) + } finally { + service.stop() + } + }) +}) diff --git a/src/main/pairing-service.ts b/src/main/pairing-service.ts index f81a2be4f0..09623e7e4c 100644 --- a/src/main/pairing-service.ts +++ b/src/main/pairing-service.ts @@ -25,6 +25,7 @@ import path from 'path' import { buildPairingPayload, filterAuthorizedKeys, + isValidBoxPublicKeyB64, isValidEd25519PublicKey, normalizeAuthorizedKeysLine, normalizeDeviceName, @@ -65,6 +66,13 @@ export interface PairingRelayDeps { apiBase: string /** Dev gate: never hit the prod relay/API from an unpackaged build (mirrors host-service). */ relayAllowed(): boolean + /** + * Pin a phone's relay (box) public key on the standing host, as its SAS approval would (audit + * A07). Optional: absent ⇒ the phone approves on its first relay connect, as before. + */ + pinRelayKey?(boxPublicKeyB64: string): Promise + /** Undo [pinRelayKey] when the device is revoked. */ + unpinRelayKey?(boxPublicKeyB64: string): Promise } interface RelayDeviceResponse { @@ -694,6 +702,7 @@ export function createPairingService( deviceName?: unknown deviceId?: unknown priorDeviceToken?: unknown + boxPublicKey?: unknown } let sealed: Uint8Array | null = null // the shared key, set only on the encrypted path if (typeof outer.epk === 'string') { @@ -728,6 +737,7 @@ export function createPairingService( deviceName?: unknown deviceId?: unknown priorDeviceToken?: unknown + boxPublicKey?: unknown } } if (body.token !== token) { @@ -750,6 +760,22 @@ export function createPairingService( // unchanged, and the mint still reads this same value. const phoneDeviceId = typeof body.deviceId === 'string' && body.deviceId.trim() ? body.deviceId.trim() : deviceId + // The phone's relay identity (audit A07). Taken ONLY from the sealed body: that body is + // sealed to the QR's host key and authorized by the one-time token on this screen — the same + // authority that installs an SSH key with full shell access — so pinning it here is the + // human's approval, given by scanning. A plaintext body could be rewritten on the LAN. + const relayBoxKey = sealed && isValidBoxPublicKeyB64(body.boxPublicKey) ? body.boxPublicKey : undefined + let relayPinned = false + const pinRelay = async (): Promise => { + if (!relayBoxKey || !relayFields.relayDeviceToken || !relayDeps?.pinRelayKey) return + try { + await relayDeps.pinRelayKey(relayBoxKey) + relayPinned = true + } catch (err) { + // Not fatal: the phone approves on its first relay connect instead, as before. + console.warn('[pairing] could not pin the phone relay key:', err) + } + } let relayFields: { relay?: RelayPairingBlock; relayDeviceToken?: string } = {} const mint = async (): Promise => { if (!relayCtx) return @@ -780,12 +806,14 @@ export function createPairingService( token: agentToken, pairedAt: Date.now(), lastSeenAt: 0, - relayDeviceId: phoneDeviceId + relayDeviceId: phoneDeviceId, + ...(relayBoxKey ? { relayBoxKey } : {}) }) }) // Provision relay access for the phone when enabled. Any failure ⇒ LAN-only: we never // fail the pairing over a relay hiccup (the phone still got its SSH key installed). await mint() + await pinRelay() } else { // Relay-only (Windows). The relay IS the connection, so there is nothing to fall back // to: no relay leg ⇒ nothing is paired, and both ends are told so. Minted BEFORE the @@ -818,13 +846,16 @@ export function createPairingService( pairedAt: Date.now(), lastSeenAt: 0, relayDeviceId: phoneDeviceId, + ...(relayBoxKey ? { relayBoxKey } : {}), ssh: false }) ) + await pinRelay() } // Build the response exactly as before; wrap it in the box only when the request was // encrypted (same shared key), so the relay device token never crosses the LAN in cleartext. - const responseObj = { ok: true, deviceId, agentToken, ...relayFields } + // `relayPinned` (additive): the phone may treat the relay as approved — no SAS on first use. + const responseObj = { ok: true, deviceId, agentToken, ...relayFields, ...(relayPinned ? { relayPinned: true } : {}) } if (sealed) { const respBox = encrypt( Uint8Array.from(Buffer.from(JSON.stringify(responseObj), 'utf8')), @@ -886,9 +917,10 @@ export function createPairingService( // entitlement is what authorizes the server leg, not the local write's success, and a phone the // user asked to remove should stop being minted Pro either way. const revokeDevice = async (id: string): Promise => { - const { local, relayId, found } = await serialize(async () => { + const { local, relayId, found, unpin } = await serialize(async () => { const entry = readDevices(await readAgentJson()).find((d) => d.id === id) const relayId = entry?.relayDeviceId + const boxKey = entry?.relayBoxKey const found = !!entry try { await removeAuthorizedKeysForDevice(id) @@ -896,14 +928,22 @@ export function createPairingService( const obj = await readAgentJson() const devices = removeDevice(readDevices(obj), id) await writeAgentJson({ ...obj, devices }) - return { local: true, relayId, found } + // The relay pin made at pairing goes with the device — unless another pairing of the same + // phone (a re-pair keeps its box key) is still listed. + const unpin = boxKey && !devices.some((d) => d.relayBoxKey === boxKey) ? boxKey : undefined + return { local: true, relayId, found, unpin } } catch (err) { // Reported, not thrown: `local:false` is what the UI turns into "try again", and the // server leg below is still worth running. The detail belongs in the log. console.warn('[pairing] local revoke failed:', err) - return { local: false, relayId, found } + return { local: false, relayId, found, unpin: undefined } } }) + if (unpin && relayDeps?.unpinRelayKey) { + // Refuses the NEXT relay handshake from that key; a relay session open right now is cut by + // the standing host's own revocation path, not here. + await relayDeps.unpinRelayKey(unpin).catch((err) => console.warn('[pairing] could not unpin the phone relay key:', err)) + } // A device paired before `relayDeviceId` was recorded still falls back to OUR id — which is // not a guess. `id` is the per-pairing `randomUUID()` above, and when the phone sent no id of // its own the mint sent exactly this value as the row's `deviceId` (see `phoneDeviceId`), so From 726271ad9c06802fee772ab7a3a35854e777f13b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:40:43 +0000 Subject: [PATCH 012/284] fix(relay): attach SSH-project nodes over their host, never locally (A09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay pty.attach of a node that belongs to one of the desktop's SSH projects attached it to the desktop's LOCAL tmux: a phantom nt- here, fresh=true, and the phone offered to resume the agent on the wrong machine — against the rule that a remote node is never spawned locally. - HostRemoteNodes (injected; absent = everything local, as before, e.g. the Server Edition) resolves a node's owning SSH project. - handleAttach attaches such a node over the project's ControlMaster with requireRemote, using the host's own freshness (the coalesced list-sessions index) and a visible-pane snapshot; when the project is not connected (or its setup chain is still running) it refuses, naming the host, and reserves nothing. - PtyManager.sessionExistsOver / captureSnapshotOver, and remoteCaptureScreenArgs (visible pane only, like the local snapshot). - SshProjectManager.sshRemoteFor builds the same sshRemote the renderer assembles from a `connected` status; main wires the resolver from workspaceStore.sshProjectIdForNode. Tests: remote-security (over-the-master attach with requireRemote, the refusal, no fallback without remote support, local unchanged). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- src/core/pty-manager.ts | 32 ++++++++++ src/core/remote-ssh/control-master.ts | 5 ++ src/main/index.ts | 12 ++++ src/main/remote-ssh/ssh-project.ts | 21 +++++++ src/main/remote/host-service.ts | 63 +++++++++++++++++-- src/main/remote/remote-security.test.ts | 81 ++++++++++++++++++++++++- src/main/remote/standing-host.ts | 1 + 7 files changed, 209 insertions(+), 6 deletions(-) diff --git a/src/core/pty-manager.ts b/src/core/pty-manager.ts index 3ededa0fa0..39f4560d22 100644 --- a/src/core/pty-manager.ts +++ b/src/core/pty-manager.ts @@ -32,6 +32,7 @@ import { type RemoteSessionEnv, remotePasteDelivery, remoteCapturePaneArgs, + remoteCaptureScreenArgs, remotePaneCommandArgs, remoteSessionAgeArgs, parseSessionAge, @@ -2482,6 +2483,37 @@ export class PtyManager { ) } + /** + * `sessionExists` for a node on an SSH project's host — the relay host's `pty.attach` asks it + * before attaching over the master (audit A09). Same fold as `create()`: only tmux's own "no such + * session" is absence; an unreadable host answers "exists", so nothing cold-restores a live pane. + */ + async sessionExistsOver( + persistKey: string, + sshRemote: NonNullable + ): Promise { + return this.remoteSessionExists(sshRemote, sessionName(persistKey)) + } + + /** `captureSnapshot` for a node on an SSH project's host: its visible pane, with colours. */ + async captureSnapshotOver( + persistKey: string, + sshRemote: NonNullable + ): Promise { + const ssh = findSsh() + if (!ssh) return '' + try { + const { stdout } = await runAsync( + ssh, + remoteCaptureScreenArgs(sshRemote.conn, sshRemote.controlPath, sessionName(persistKey)), + { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, timeout: PROBE_TIMEOUT_MS } + ) + return stdout + } catch { + return '' + } + } + /** One coalesced remote `tmux list-sessions` per ControlMaster (see remote-session-index.ts). * `list` carries the SAME classification the per-node probe had: tmux's own exit 1 ("no server * running") is the only evidence of absence; ssh 255 / 127 / a timeout answer `unknown`, which diff --git a/src/core/remote-ssh/control-master.ts b/src/core/remote-ssh/control-master.ts index 1a2f398957..aac8e3e73f 100644 --- a/src/core/remote-ssh/control-master.ts +++ b/src/core/remote-ssh/control-master.ts @@ -475,6 +475,11 @@ export function remoteCapturePaneArgs(conn: SshConnection, controlPath: string, tmuxCmd(`tmux -L ${RMT_TMUX_SOCKET} capture-pane -p -e -t ${sessionId} -S ${full ? '-' : '-200'}`) ) } +/** The VISIBLE pane of a remote session, with colours — the relay attach snapshot (no history: + * the client paints it as the current screen before live output). */ +export function remoteCaptureScreenArgs(conn: SshConnection, controlPath: string, sessionId: string): string[] { + return childArgs(conn, controlPath, tmuxCmd(`tmux -L ${RMT_TMUX_SOCKET} capture-pane -p -e -t ${sessionId}`)) +} /** * Ask the REMOTE tmux when a node's session was created, AND what the host's clock says now — in * one round trip, because the caller wants an AGE and the two clocks are not the same clock. diff --git a/src/main/index.ts b/src/main/index.ts index d5e1888c88..66e3870861 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4033,6 +4033,18 @@ app.whenReady().then(async () => { // canvas Approve/Deny button uses, and the SAME ack pair the `~/.nodeterm/acks` sweep runs for a // phone on direct SSH — `ackDone` (resolve the done event, dismiss other phones' activities) // plus the desktop unread clear, WITHOUT a re-ack (the external-clear channel). + // A phone opening a node of an SSH project reaches THAT host's tmux over the project's master, + // or is told why not — never a phantom local session (audit A09). + remoteNodes: { + resolve: (nodeId: string) => { + const projectId = workspaceStore.sshProjectIdForNode(nodeId) + if (!projectId) return null + const ssh = workspaceStore.projectTargetInfo(projectId)?.ssh + const where = ssh?.server ? sshHostKey(ssh.server) : 'another computer' + const sshRemote = sshProjectManager?.sshRemoteFor(projectId, ssh?.remoteCwd) + return sshRemote ? { where, sshRemote } : { where } + } + }, inbox: { answerPermission, ackRead: (nodeId: string) => { diff --git a/src/main/remote-ssh/ssh-project.ts b/src/main/remote-ssh/ssh-project.ts index fc1893a9c7..14e6f06eca 100644 --- a/src/main/remote-ssh/ssh-project.ts +++ b/src/main/remote-ssh/ssh-project.ts @@ -4,6 +4,7 @@ import { createHash, randomUUID } from 'crypto' import { spawn, execFile, execFileSync } from 'child_process' import { app, ipcMain } from 'electron' import { IPC } from '../../shared/ipc' +import type { PtyCreateOptions } from '../../shared/types' import { getMainWindow, sendToMain } from '../main-window' import { parseLsDirs, @@ -1475,6 +1476,26 @@ export class SshProjectManager { return c ? { conn: c.conn, controlPath: c.controlPath, remoteCwd: c.remoteCwd } : undefined } + /** + * The full `sshRemote` a pty needs to run a node of this project on its host — the same fields + * the renderer assembles from a `connected` status. `undefined` while no master exists or while + * the connect's setup chain is still running (a session created before the hook tunnel and the + * remote tmux.conf are in place would have neither — see `connectOnce`). Used by the relay host so + * a phone's attach of an SSH-project node runs on the host, never locally (audit A09). + */ + sshRemoteFor(projectId: string, fallbackRemoteCwd?: string): NonNullable | undefined { + const c = this.conns.get(projectId) + if (!c || this.inFlight.has(projectId)) return undefined + return { + controlPath: c.controlPath, + conn: c.conn, + remoteCwd: c.remoteCwd ?? fallbackRemoteCwd ?? '~', + ...(c.hookEndpointPath ? { hookEndpointPath: c.hookEndpointPath } : {}), + ...(c.tmuxConfPath ? { tmuxConfPath: c.tmuxConfPath } : {}), + ...(c.remoteHome ? { remoteHome: c.remoteHome } : {}) + } + } + /** Does this project already have a master, or an attempt in flight? The pre-warm's gate — it * must never start a second attempt beside the user's own connect (see `prewarm`). */ isBusy(projectId: string): boolean { diff --git a/src/main/remote/host-service.ts b/src/main/remote/host-service.ts index a6355521a6..f8102bd31b 100644 --- a/src/main/remote/host-service.ts +++ b/src/main/remote/host-service.ts @@ -75,6 +75,10 @@ export interface HostPtyManager { /** Does a tmux session for this node id exist RIGHT NOW? Asked before `attachDetached`, which * CREATES one when it doesn't — so the client can tell a warm join from a cold start. */ sessionExists(persistKey: string): Promise + /** `sessionExists` / `captureSnapshot` for a node on an SSH project's host (see HostRemoteNodes). + * Optional: absent ⇒ remote nodes are refused rather than attached locally. */ + sessionExistsOver?(persistKey: string, sshRemote: NonNullable): Promise + captureSnapshotOver?(persistKey: string, sshRemote: NonNullable): Promise /** `clientId` identifies WHO typed (the bridged phone's presence peer), so the keystroke can be * attributed to it — null when this session has no peer, which just means it is not badged. */ write(clientId: number | null, sessionId: string, data: string): void @@ -205,6 +209,21 @@ export interface HostInboxOps { ackRead(nodeId: string): void } +/** + * Nodes of the desktop's SSH projects live on ANOTHER machine: their tmux session is on that host, + * reached over the project's ControlMaster. A relay `pty.attach` used to attach them to the + * desktop's LOCAL tmux, which created an empty phantom `nt-` here and had the phone offer to + * resume the agent on the wrong machine (audit A09) — against the rule that a remote node is never + * spawned locally. With this injected, such a node is attached over its master or refused. + */ +export interface HostRemoteNodes { + /** + * `null` = a local node. Otherwise the node's host (`user@host`, for the refusal text) and, when + * the project's ControlMaster is connected and its setup finished, the `sshRemote` to attach over. + */ + resolve(nodeId: string): { where: string; sshRemote?: NonNullable } | null +} + interface Stream { sessionId: string /** The node id (tmux persistKey) this stream attached to. The ONLY tmux target a client can @@ -291,7 +310,10 @@ export function createHostHandlers( // Absent ⇒ the verbs answer an honest "not served". kanban?: HostKanbanOps, // Inbox actions (`approvals.answer` / `inbox.ack`). Absent ⇒ the verbs answer "not served". - inbox?: HostInboxOps + inbox?: HostInboxOps, + // Which nodes belong to an SSH project, and how to reach their host (see HostRemoteNodes). + // Absent ⇒ every node is attached locally, as before (the Server Edition has no SSH projects). + remoteNodes?: HostRemoteNodes ): HostHandlers { // streamId -> Stream. PTY callbacks close over their own `streamId` directly, so no // reverse (sessionId -> streamId) index is needed. @@ -393,6 +415,27 @@ export function createHostHandlers( const cols = Math.max(1, num(p.cols, 80)) const rows = Math.max(1, num(p.rows, 24)) + // A node of an SSH project: attach it over that project's ControlMaster, or refuse — never a + // local session (audit A09). Resolved before a stream is reserved, so a refusal leaves nothing. + let remote: NonNullable | undefined + let owner: ReturnType = null + try { + owner = remoteNodes?.resolve(nodeId) ?? null + } catch { + owner = null + } + if (owner) { + if (!owner.sshRemote || !pty.sessionExistsOver || !pty.captureSnapshotOver) { + socket.respond(req.id, false, { + message: + `This session runs on ${owner.where}, and your computer is not connected to it right now. ` + + 'Open that project in nodeterm on the computer, then try again.' + }) + return + } + remote = owner.sshRemote + } + const streamId = ++streamCounter const stream: Stream = { sessionId: '', persistKey: nodeId, seq: 0, paused: false } const sinks = makeSinks(streamId, stream, p.resizedFrames === true) @@ -421,11 +464,11 @@ export function createHostHandlers( // the 6 s probe timeout when tmux itself is wedged. void (async () => { const existed = await Promise.race([ - pty.sessionExists(nodeId).catch(() => true), + (remote ? pty.sessionExistsOver!(nodeId, remote) : pty.sessionExists(nodeId)).catch(() => true), new Promise((r) => setTimeout(() => r(true), FRESH_PROBE_BUDGET_MS)) ]) socket.respond(req.id, true, { streamId, fresh: !existed }) - return pty.captureSnapshot(nodeId).catch(() => '') + return (remote ? pty.captureSnapshotOver!(nodeId, remote) : pty.captureSnapshot(nodeId)).catch(() => '') })() .then((snapshot) => { // The stream may have been killed/closed while the capture was in flight. @@ -433,7 +476,11 @@ export function createHostHandlers( // Snapshot first (current screen) — then live output begins on attach. sendSnapshot(streamId, stream, snapshot) try { - stream.sessionId = pty.attachDetached(nodeId, sinks, { cols, rows }) + // `requireRemote`: if the master died since `resolve`, spawn NOTHING rather than fall + // through to a local session (PtyCreateOptions.requireRemote). + stream.sessionId = remote + ? pty.attachDetached(nodeId, sinks, { cols, rows, sshRemote: remote, requireRemote: true }) + : pty.attachDetached(nodeId, sinks, { cols, rows }) } catch { // Attach failed (e.g. tmux unavailable) — surface as an exit so the client tears down. socket.sendFrame( @@ -1157,6 +1204,8 @@ export interface HostSessionOptions { /** Inbox actions for a relay phone (`approvals.answer` / `inbox.ack`). Optional: absent ⇒ the * verbs answer an honest "not served". */ inbox?: HostInboxOps + /** SSH-project nodes: attach over their master, never locally. Optional (see HostRemoteNodes). */ + remoteNodes?: HostRemoteNodes /** Extra fs/git jail roots beyond the shared canvas's node cwds — production passes the * workspace's local project cwds: the phone browses EVERY project over `projects.list`, so a * canvas-only jail denied whichever project the desktop didn't happen to have focused. */ @@ -1282,7 +1331,8 @@ export function connectHostSession(opts: HostSessionOptions): HostSession { opts.remoteViewer, opts.nodeActions, opts.kanban, - opts.inbox + opts.inbox, + opts.remoteNodes ) canvasSync = createHostCanvasSync(socket, opts.applyMutation) unsubCanvas = opts.subscribeCanvas(() => scheduleBroadcast()) @@ -1315,6 +1365,8 @@ export interface HostBridgeDeps { kanban?: HostKanbanOps /** Inbox actions for a relay phone — the same answer writer / read-ack the desktop itself uses. */ inbox?: HostInboxOps + /** SSH-project nodes attach over their project's ControlMaster, or are refused (audit A09). */ + remoteNodes?: HostRemoteNodes /** Workspace-level jail roots (local project cwds) merged with the canvas node cwds. */ workspaceRoots?: () => string[] } @@ -1390,6 +1442,7 @@ export function initRemoteHost( nodeActions: bridge.nodeActions, kanban: bridge.kanban, inbox: bridge.inbox, + remoteNodes: bridge.remoteNodes, extraRoots: bridge.workspaceRoots, // Typing attribution: this session's input frames are this phone's keystrokes. getClientId: () => phone.id(), diff --git a/src/main/remote/remote-security.test.ts b/src/main/remote/remote-security.test.ts index 9176f113fe..4e3b41e731 100644 --- a/src/main/remote/remote-security.test.ts +++ b/src/main/remote/remote-security.test.ts @@ -5,7 +5,7 @@ // R4 — killing a stream forgets it in the SAME synchronous turn, so a late Input frame for that // streamId can never be written into a session that is already released. import { describe, expect, it, vi } from 'vitest' -import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket } from './host-service' +import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket, type HostRemoteNodes } from './host-service' import { genKeyPair, deriveSharedKey, sasFromSharedKey, publicKeyToB64 } from './e2ee' import { connectRelay, type RelaySocket, type RelayTransport } from './relay-socket' import { OP, type Frame } from './framing' @@ -622,3 +622,82 @@ describe('remoteViewer presence reporting', () => { expect(base.responses.at(-1)).toMatchObject({ id: 'k', ok: true }) }) }) + +// Audit A09: a node of an SSH project lives on ANOTHER host. The relay used to attach it to the +// desktop's LOCAL tmux — creating a phantom `nt-` here and offering to resume the agent on the +// wrong machine. It is now attached over the project's ControlMaster, or refused. +describe('pty.attach of an SSH-project node never runs locally', () => { + const sshRemote = { + controlPath: '/tmp/cm.sock', + conn: { host: 'box', user: 'me' }, + remoteCwd: '~/repo', + hookEndpointPath: '/home/me/.nodeterm/hook.env' + } + const handlersWith = ( + pty: HostPtyManager, + socket: HostRelaySocket, + fs: HostFsOps, + resolve: HostRemoteNodes['resolve'] + ): ReturnType => + createHostHandlers( + pty, socket, fs, () => ['/work'], + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + { resolve } + ) + const addRemote = (pty: HostPtyManager): HostPtyManager => + Object.assign(pty, { + sessionExistsOver: vi.fn(async () => false), + captureSnapshotOver: vi.fn(async () => 'REMOTE SCREEN') + }) + + it('attaches over the master, requireRemote, with the host freshness and snapshot', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + addRemote(pty) + const handlers = handlersWith(pty, socket, fs, (id) => (id === 'node-r' ? { where: 'me@box', sshRemote } : null)) + handlers.onRpc({ id: 'r', method: 'pty.attach', params: { nodeId: 'node-r', cols: 90, rows: 30 } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(responses[0]).toMatchObject({ ok: true, body: { fresh: true } }) + expect(pty.sessionExists).not.toHaveBeenCalled() + expect(pty.captureSnapshot).not.toHaveBeenCalled() + expect(pty.sessionExistsOver).toHaveBeenCalledWith('node-r', sshRemote) + expect(pty.captureSnapshotOver).toHaveBeenCalledWith('node-r', sshRemote) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ + cols: 90, + rows: 30, + sshRemote, + requireRemote: true + }) + }) + + it('refuses, naming the host, when the project is not connected — nothing is attached', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + addRemote(pty) + const handlers = handlersWith(pty, socket, fs, () => ({ where: 'me@box' })) + handlers.onRpc({ id: 'r', method: 'pty.attach', params: { nodeId: 'node-r', cols: 80, rows: 24 } }) + await vi.waitFor(() => expect(responses.length).toBe(1)) + expect(responses[0]).toMatchObject({ ok: false, body: { message: expect.stringContaining('me@box') } }) + await new Promise((r) => setTimeout(r, 10)) + expect(pty.attachDetached).not.toHaveBeenCalled() + expect(pty.sessionExists).not.toHaveBeenCalled() + }) + + it('refuses rather than falls back when the pty manager cannot reach remote hosts', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + const handlers = handlersWith(pty, socket, fs, () => ({ where: 'me@box', sshRemote })) + handlers.onRpc({ id: 'r', method: 'pty.attach', params: { nodeId: 'node-r', cols: 80, rows: 24 } }) + await vi.waitFor(() => expect(responses.length).toBe(1)) + expect(responses[0].ok).toBe(false) + expect(pty.attachDetached).not.toHaveBeenCalled() + }) + + it('a local node is attached exactly as before', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + addRemote(pty) + const handlers = handlersWith(pty, socket, fs, () => null) + handlers.onRpc({ id: 'l', method: 'pty.attach', params: { nodeId: 'node-l', cols: 80, rows: 24 } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(responses[0].ok).toBe(true) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + expect(pty.sessionExistsOver).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/remote/standing-host.ts b/src/main/remote/standing-host.ts index cae310c91f..2f712e810f 100644 --- a/src/main/remote/standing-host.ts +++ b/src/main/remote/standing-host.ts @@ -313,6 +313,7 @@ export function initStandingHost( nodeActions: bridge.nodeActions, kanban: bridge.kanban, inbox: bridge.inbox, + remoteNodes: bridge.remoteNodes, extraRoots: bridge.workspaceRoots, // Typing attribution: this pooled session's input frames are ITS phone's keystrokes. getClientId: () => pooled.presence.id(), From 1cdd2f05b7f895935d16d9953cb74a2f672cc042 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:40:43 +0000 Subject: [PATCH 013/284] fix(android): never create or misroute a session over direct SSH (A08/A09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A08: the SSH attach ran `tmux new-session -A`, so a session that was not running (a reboot) was CREATED over SSH with no hook environment: an agent resumed there never reported status, the desktop never repaired it (tmux reads -e only at creation), or it inherited another node's NODETERM_NODE_ID from the server's global env. A09: nodes of the desktop's SSH projects live on another host, but SSH attached, typed keys into, answered approvals for and acked them on the desktop. protocol: - The attach script uses `attach-session -t =nt-…` after a has-session check (exit 3), never new-session; SshHostConnection.attach throws the new NeedsRelayException for a session that is not running. - The listing remembers which nodes belong to SSH projects; attach, sendKeys, answerApproval and killSession refuse them with NeedsRelayException, and ackRead writes nothing on the wrong machine. app: - HostSession.viaRelay() opens a relay connection next to a direct-SSH one (user-initiated, through the approval gate), closed on disconnect. - The terminal shows "Open through the relay" for a NeedsRelayException (or explains to open it on the computer when there is no relay leg), shows the approval code if this is the first relay connect, and stays on the relay for reattaches. Inbox answers and End session retry through the relay the same way. Tests: SSH transport against MINA + tmux — a missing session is not created (by the transport, and by the script itself), and an SSH-project node is refused for attach/keys/approvals with no ack and no phantom session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../android/conn/ConnectionManager.kt | 100 +++++++++++++++--- .../dev/nodeterm/android/ui/InboxTab.kt | 20 +++- .../dev/nodeterm/android/ui/SessionsTab.kt | 20 ++-- .../nodeterm/android/ui/TerminalController.kt | 35 +++++- .../dev/nodeterm/android/ui/TerminalScreen.kt | 20 ++++ .../nodeterm/protocol/host/HostConnection.kt | 8 ++ .../protocol/ssh/SshHostConnection.kt | 50 +++++++-- .../dev/nodeterm/protocol/ssh/SshScripts.kt | 21 ++-- .../dev/nodeterm/protocol/SshTransportTest.kt | 52 +++++++-- 9 files changed, 268 insertions(+), 58 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index 3443e37d62..1cf20fc39f 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -109,21 +109,13 @@ class HostSession(val hostId: String, private val graph: AppGraph) { val requireApproved = (decision as? RelayApprovalGate.Decision.Dial)?.requireApproved == true _state.value = ConnState.Connecting("Connecting through the relay…") try { - val join = RelayApi(graph.hosts.apiBase).join(token, relay.hostId) - val connected = RelayConnector.connect( - relayUrl = relay.relayEndpoint, - token = join.pairingToken, - deviceKeys = graph.boxKeys, - hostPublicKeyB64 = hostKey, - requireApproved = requireApproved, - onStatus = { st -> - when (st) { - is RelayConnectStatus.AwaitingApproval -> _state.value = ConnState.AwaitingApproval(st.sas) - RelayConnectStatus.Handshaking -> _state.value = ConnState.Connecting("Verifying your computer…") - RelayConnectStatus.MintingToken -> Unit - } + val connected = dialRelay(relay, token, hostKey, requireApproved) { st -> + when (st) { + is RelayConnectStatus.AwaitingApproval -> _state.value = ConnState.AwaitingApproval(st.sas) + RelayConnectStatus.Handshaking -> _state.value = ConnState.Connecting("Verifying your computer…") + RelayConnectStatus.MintingToken -> Unit } - ) + } graph.relayGate.onConnected(hostId) _snapshot.value = connected.first adopt(connected.connection) @@ -145,6 +137,79 @@ class HostSession(val hostId: String, private val graph: AppGraph) { throw HostException(msg) } + private suspend fun dialRelay( + relay: RelayBlock, + token: String, + hostKey: String, + requireApproved: Boolean, + onStatus: (RelayConnectStatus) -> Unit + ): RelayConnector.Connected { + val join = RelayApi(graph.hosts.apiBase).join(token, relay.hostId) + return RelayConnector.connect( + relayUrl = relay.relayEndpoint, + token = join.pairingToken, + deviceKeys = graph.boxKeys, + hostPublicKeyB64 = hostKey, + requireApproved = requireApproved, + onStatus = onStatus + ) + } + + /** A relay connection held NEXT TO a direct-SSH one, for what SSH must not do (see [viaRelay]). */ + @Volatile private var sideRelay: HostConnection? = null + private val sideMutex = Mutex() + + /** This computer can be reached through the relay at all (a relay leg and its device token). */ + val hasRelay: Boolean + get() { + val host = graph.hosts.get(hostId) ?: return false + return host.relay != null && host.relayHostKeyB64 != null && + graph.secure.getString(SecureStore.relayTokenKey(host.id)) != null && + graph.hosts.route(hostId) != RoutePreference.SSH_ONLY + } + + /** + * A relay connection for one action the direct-SSH transport refuses + * ([dev.nodeterm.protocol.host.NeedsRelayException]): a session that is not running (creating it + * over SSH would leave it without its hook environment — audit A08), or a node of the desktop's + * SSH projects, which the desktop reaches on its host (audit A09). The primary connection when + * it already IS the relay; otherwise one opened next to it and kept until [disconnect]. The user + * asked (they tapped "Open through the relay"), so a held approval is released and, the first + * time, the desktop's approval code is reported through [onStatus]. + */ + suspend fun viaRelay(onStatus: (RelayConnectStatus) -> Unit = {}): HostConnection { + conn?.takeIf { it.kind == TransportKind.RELAY }?.let { return it } + sideRelay?.let { return it } + return sideMutex.withLock { + sideRelay ?: run { + val host = graph.hosts.get(hostId) ?: throw HostException("This computer is no longer paired.") + val relay = host.relay + val token = graph.secure.getString(SecureStore.relayTokenKey(host.id)) + val hostKey = host.relayHostKeyB64 + if (relay == null || token == null || hostKey == null || graph.hosts.route(hostId) == RoutePreference.SSH_ONLY) { + throw HostException("Remote access isn't set up for this computer. Open the session in nodeterm on the computer instead.") + } + when (val d = graph.relayGate.decide(hostId, Trigger.USER)) { + is RelayApprovalGate.Decision.Skip -> throw HostException(d.reason) + is RelayApprovalGate.Decision.Dial -> Unit + } + val connected = try { + dialRelay(relay, token, hostKey, requireApproved = false, onStatus = onStatus) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + graph.relayGate.onFailed(hostId, e) + throw e + } + graph.relayGate.onConnected(hostId) + val c = connected.connection + c.setOnClosed { if (sideRelay === c) sideRelay = null } + sideRelay = c + c + } + } + } + private fun adopt(c: HostConnection) { conn = c _lastError.value = null @@ -257,7 +322,12 @@ class HostSession(val hostId: String, private val graph: AppGraph) { fun disconnect() { val c = conn conn = null - if (c != null) scope.launch(Dispatchers.IO) { runCatching { c.close() } } + val side = sideRelay + sideRelay = null + if (c != null || side != null) scope.launch(Dispatchers.IO) { + runCatching { c?.close() } + runCatching { side?.close() } + } _state.value = ConnState.Idle } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index 3c060ae1b3..17d0fa5a00 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -41,6 +41,8 @@ import dev.nodeterm.android.Navigator import dev.nodeterm.android.NodetermApp import dev.nodeterm.android.Route import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.QuickActions import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.AgentState @@ -80,10 +82,18 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro fun open(nodeId: String) = nav.push(Route.Terminal(hostId, nodeId, titleOf(nodeId))) - fun run(label: String, block: suspend () -> QuickActions.Result, nodeId: String) { + fun run(label: String, block: suspend (HostConnection) -> QuickActions.Result, nodeId: String) { scope.launch { try { - when (block()) { + val result = try { + block(session.ensureConnected()) + } catch (e: NeedsRelayException) { + // Direct SSH reaches only this computer; a node of one of its SSH projects is + // answered where it lives, through the relay (audit A09). + if (!session.hasRelay) throw e + block(session.viaRelay()) + } + when (result) { QuickActions.Result.SENT -> Toast.makeText(context, label, Toast.LENGTH_SHORT).show() QuickActions.Result.ALREADY_HANDLED -> Toast.makeText(context, "Already handled.", Toast.LENGTH_SHORT).show() QuickActions.Result.OPEN_SESSION -> open(nodeId) @@ -112,10 +122,10 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro if (ev.kind == InboxKind.APPROVAL) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { - run("Approved.", { QuickActions.answerApproval(session.ensureConnected(), ev, allow = true) }, ev.nodeId) + run("Approved.", { c -> QuickActions.answerApproval(c, ev, allow = true) }, ev.nodeId) }) { Text("Approve") } OutlinedButton(onClick = { - run("Denied.", { QuickActions.answerApproval(session.ensureConnected(), ev, allow = false) }, ev.nodeId) + run("Denied.", { c -> QuickActions.answerApproval(c, ev, allow = false) }, ev.nodeId) }) { Text("Deny") } TextButton(onClick = { open(ev.nodeId) }) { Text("Open") } } @@ -123,7 +133,7 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ev.options.forEachIndexed { i, opt -> OutlinedButton(onClick = { - run("Answered.", { QuickActions.answerQuestion(session.ensureConnected(), ev, i) }, ev.nodeId) + run("Answered.", { c -> QuickActions.answerQuestion(c, ev, i) }, ev.nodeId) }, modifier = Modifier.fillMaxWidth()) { Text("${i + 1}. $opt", maxLines = 2) } } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt index 0c237c6e55..fa74809653 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp import dev.nodeterm.android.Navigator import dev.nodeterm.android.Route import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TransportKind import dev.nodeterm.protocol.model.Agent @@ -209,14 +210,21 @@ fun SessionsTab(nav: Navigator, hostId: String, session: HostSession, snapshot: ending = null val c = conn ?: return@TextButton act("End session") { + val quiet = object : TerminalSink { + override fun onPaint(text: String) {} + override fun onOutput(bytes: ByteArray) {} + override fun onExit(code: Int?) {} + } if (c is SshHostConnection) { - c.killSession(node.id) - } else { - val quiet = object : TerminalSink { - override fun onPaint(text: String) {} - override fun onOutput(bytes: ByteArray) {} - override fun onExit(code: Int?) {} + try { + c.killSession(node.id) + } catch (e: NeedsRelayException) { + // A node of an SSH project lives on its host: end it through the + // relay, where the desktop reaches that host (audit A09). + if (!session.hasRelay) throw e + session.viaRelay().attach(node.id, 80, 24, quiet).endSession() } + } else { c.attach(node.id, 80, 24, quiet).endSession() } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 24c5a1a01b..b222b8ccb5 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -20,6 +20,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import dev.nodeterm.android.AppGraph import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.NewNode import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TerminalStream @@ -36,6 +39,11 @@ sealed interface TermState { data object Connecting : TermState data object Attached : TermState data class Ended(val message: String) : TermState + /** Direct SSH refused this session (not running, or on another host — audit A08/A09); the + * relay can open it. Shown with an "Open through the relay" button. */ + data class RelayOffer(val message: String) : TermState + /** Opening through the relay for the first time: the computer shows this code to approve. */ + data class AwaitingApproval(val sas: String) : TermState } /** @@ -196,13 +204,27 @@ class TerminalController( wv.evaluateJavascript(code, null) } + /** Set once the user chose "Open through the relay": later reattaches stay on the relay. */ + private var useRelay = false + + fun openThroughRelay() { + useRelay = true + attach() + } + fun attach() { if (disposed) return state = TermState.Connecting resumeOffer = null attachJob = graph.scope.launch { try { - val conn = session.ensureConnected() + val conn = if (useRelay) { + session.viaRelay { st -> + if (st is RelayConnectStatus.AwaitingApproval) main.post { state = TermState.AwaitingApproval(st.sas) } + } + } else { + session.ensureConnected() + } val c = if (cols > 0) cols else 80 val r = if (rows > 0) rows else 24 val s = conn.attach(nodeId, c, r, sink) @@ -215,7 +237,13 @@ class TerminalController( state = TermState.Attached if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) } - afterAttach(s) + afterAttach(s, conn) + } catch (e: NeedsRelayException) { + val msg = e.message ?: "This session opens through the relay." + main.post { + state = if (session.hasRelay) TermState.RelayOffer(msg) + else TermState.Ended("$msg Remote access isn't set up for this computer, so open it in nodeterm on the computer.") + } } catch (e: Exception) { main.post { state = TermState.Ended(e.message ?: "Couldn't open the terminal.") } } finally { @@ -224,8 +252,7 @@ class TerminalController( } } - private suspend fun afterAttach(s: TerminalStream) { - val conn = session.connection ?: return + private suspend fun afterAttach(s: TerminalStream, conn: HostConnection) { val launch = PendingLaunches.take(nodeId) if (launch != null) { // A session this phone just started: type its launch line once the shell has settled diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index ec620a6a1c..0e34de6568 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -94,6 +94,26 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String Text(st.message) Button(onClick = { controller.attach() }) { Text("Reattach") } } + is TermState.RelayOffer -> Column( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.openThroughRelay() }) { Text("Open through the relay") } + } + is TermState.AwaitingApproval -> Column( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Approve this phone on your computer") + Text(st.sas, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.headlineMedium, color = NtColors.accent) + Text( + "Approve only if the code on the computer matches this one.", + style = MaterialTheme.typography.bodySmall + ) + } TermState.Attached -> Unit } controller.resumeOffer?.let { (agent, cmd) -> diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt index 299edfadf7..3ce6151ad3 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -113,3 +113,11 @@ enum class GitVerb(val wire: String) { } open class HostException(message: String) : Exception(message) + +/** + * The direct-SSH transport will not do this for [nodeId], and the relay should: the node's tmux + * session is not running (creating it over SSH would give it no hook environment — audit A08), or + * the node belongs to one of the desktop's SSH projects and lives on another host (audit A09). The + * app offers to open it through the relay, where the desktop attaches it properly. + */ +class NeedsRelayException(val nodeId: String, message: String) : HostException(message) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index cfdc5d2cf6..8a920af6ec 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -7,6 +7,7 @@ import dev.nodeterm.protocol.host.HostCapabilities import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.HostException import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.NewNode import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TerminalStream @@ -121,7 +122,7 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } else { base } - rememberCwds(snapshot) + rememberRemoteNodes(snapshot) snapshot } @@ -178,28 +179,48 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink): TerminalStream = withContext(Dispatchers.IO) { - val exists = run(SshScripts.hasSession(nodeId)).second.trim() == "yes" + refuseRemoteNode(nodeId) + if (run(SshScripts.hasSession(nodeId)).second.trim() != "yes") throw notRunning(nodeId) val session = client.startSession() try { session.allocatePTY("xterm-256color", cols, rows, 0, 0, emptyMap()) - val cwd = if (!exists) projectCwdFor(nodeId) else null - val cmd = session.exec("/bin/sh -c " + SshScripts.q(SshScripts.attach(nodeId, cwd))) - SshStream(session, cmd, !exists, sink).also { it.start() } + val cmd = session.exec("/bin/sh -c " + SshScripts.q(SshScripts.attach(nodeId))) + SshStream(session, cmd, fresh = false, sink = sink).also { it.start() } } catch (e: Exception) { runCatching { session.close() } throw HostException("Couldn't open the terminal over SSH: ${e.message}") } } - @Volatile private var lastCwds: Map = emptyMap() + /** Node ids of the desktop's SSH projects → `user@host`, from the latest listing. */ + @Volatile private var remoteNodes: Map = emptyMap() - private fun projectCwdFor(nodeId: String): String? = lastCwds[nodeId] + /** Remember which nodes belong to the desktop's SSH projects (their tmux is on another host). */ + fun rememberRemoteNodes(snapshot: ProjectsSnapshot) { + remoteNodes = snapshot.projects.filter { it.sshTarget != null } + .flatMap { p -> p.nodes.map { it.id to p.sshTarget!! } }.toMap() + } - /** Remember node cwds from the latest listing so a cold attach opens where the node lives. */ - fun rememberCwds(snapshot: ProjectsSnapshot) { - lastCwds = snapshot.projects.flatMap { p -> p.nodes.mapNotNull { n -> (n.cwd ?: p.cwd)?.let { n.id to it } } }.toMap() + /** + * A node of one of the desktop's SSH projects lives on ANOTHER host: its tmux session, its + * pending approvals and its read-acks are all there, not on this computer. Over direct SSH we can + * only reach this computer, so every node-scoped action refuses (audit A09) — attaching would + * create a phantom local session and offer to resume the agent on the wrong machine. + */ + private fun refuseRemoteNode(nodeId: String) { + val where = remoteNodes[nodeId] ?: return + throw NeedsRelayException( + nodeId, + "This session runs on $where, which the phone reaches through your computer: it opens through the relay, not over your network." + ) } + private fun notRunning(nodeId: String) = NeedsRelayException( + nodeId, + "This session isn't running on the computer right now. Starting it over your network would leave it " + + "without status reporting, so it opens through the relay instead (or open it in nodeterm on the computer)." + ) + /** * The terminal stream over one exec'd pty channel. * @@ -348,6 +369,7 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos throw HostException("$what needs the relay connection (turn on remote access in nodeterm → Settings → Phone).") override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome = withContext(Dispatchers.IO) { + refuseRemoteNode(event.nodeId) val pendingId = event.pendingId ?: return@withContext ApprovalOutcome.UNSUPPORTED if (!SshScripts.PENDING_ID.matches(pendingId)) return@withContext ApprovalOutcome.UNSUPPORTED when (run(SshScripts.answerApproval(pendingId, allow)).second.trim()) { @@ -359,18 +381,24 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos /** The phone→host read-ack (src/core/ack-sweep.ts): `~/.nodeterm/acks/.seen`. */ override suspend fun ackRead(nodeId: String, eventId: String?) { + // A remote node's read-ack belongs on ITS host; writing it here would ack nothing. + if (remoteNodes.containsKey(nodeId)) return withContext(Dispatchers.IO) { runCatching { run(SshScripts.ackRead(nodeId, eventId ?: "")) } } } override suspend fun sendKeys(nodeId: String, keys: String) { withContext(Dispatchers.IO) { + refuseRemoteNode(nodeId) val (code, _) = run(SshScripts.sendKeys(nodeId, keys)) if (code != null && code != 0) throw HostException("Couldn't type into the session (tmux exited $code).") } } /** Kill the node's tmux session (the node stays on the canvas; the desktop shows it as ended). */ - suspend fun killSession(nodeId: String) = withContext(Dispatchers.IO) { run(SshScripts.killSession(nodeId)) } + suspend fun killSession(nodeId: String) = withContext(Dispatchers.IO) { + refuseRemoteNode(nodeId) + run(SshScripts.killSession(nodeId)) + } /** `~/.nodeterm/relay.json`, when the computer advertises its relay identity (late adoption). */ suspend fun readRelayAdvertisement(): JsonObject? = withContext(Dispatchers.IO) { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt index 2ff5e11e5e..ceebfa08b5 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt @@ -98,24 +98,29 @@ object SshScripts { } /** - * Attach a pty to the node's session, creating it when absent — `new-session -A` WITHOUT `-D`: - * the desktop's own client must stay attached (`-D` is what "[detached]" dead terminals are made - * of). `-f` only when the config exists (a missing file is a tmux error, not a default), and it - * only matters when this attach STARTS the server. `-c` only applies to a created session. + * Attach a pty to the node's EXISTING session — `attach-session`, never `new-session`: a session + * created over SSH gets none of the hook environment the desktop gives it (`NODETERM_NODE_ID`, + * the hook endpoint), so an agent resumed there never reports status, and the desktop never + * repairs it because tmux reads `-e` only at creation — or it inherits ANOTHER node's id from the + * tmux server's global env (audit A08). A missing session exits [NO_SESSION_EXIT] instead, and + * the app offers the relay, where the desktop creates it properly. Without `-d`: the desktop's + * own client must stay attached (`-d` is what "[detached]" dead terminals are made of). */ - fun attach(nodeId: String, cwd: String?): String { + fun attach(nodeId: String): String { val target = target(nodeId) - val cd = cwd?.takeIf { it.startsWith("/") }?.let { " -c ${q(it)}" } ?: "" return """ $PRELUDE if [ -z "${'$'}NT_TMUX" ]; then echo 'nodeterm: tmux was not found on this computer.' >&2; exit 127; fi - if [ -n "${'$'}NT_UD" ] && [ -f "${'$'}NT_UD/tmux.conf" ]; then set -- -f "${'$'}NT_UD/tmux.conf"; else set --; fi + "${'$'}NT_TMUX" -L ${TmuxNames.SOCKET} has-session -t ${q("=$target")} 2>/dev/null || exit $NO_SESSION_EXIT TERM=xterm-256color; export TERM $LOCALE - exec "${'$'}NT_TMUX" -u -L ${TmuxNames.SOCKET} "${'$'}@" new-session -A -s ${q(target)}$cd + exec "${'$'}NT_TMUX" -u -L ${TmuxNames.SOCKET} attach-session -t ${q("=$target")} """.trimIndent() } + /** [attach]'s exit status when the node's session is not running. */ + const val NO_SESSION_EXIT = 3 + fun killSession(nodeId: String): String { val target = target(nodeId) return """ diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index c27beef093..fcce5e05c9 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -2,6 +2,7 @@ package dev.nodeterm.protocol import dev.nodeterm.protocol.host.ApprovalOutcome import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxEvent @@ -10,6 +11,7 @@ import dev.nodeterm.protocol.pairing.SshIdentity import dev.nodeterm.protocol.ssh.HostKeyChangedException import dev.nodeterm.protocol.ssh.HostKeyPin import dev.nodeterm.protocol.ssh.SshHostConnection +import dev.nodeterm.protocol.ssh.SshScripts import kotlinx.coroutines.runBlocking import org.apache.sshd.server.Environment import org.apache.sshd.server.ExitCallback @@ -369,16 +371,48 @@ class SshTransportTest { } @Test - fun `attaching an unknown node reports a cold start and creates the session`() = runBlocking { + fun `a session that is not running is never created over SSH`() = runBlocking { + // A08: `new-session -A` over SSH created the desktop's session with no hook env. Now the + // phone is told to use the relay, and nothing appears on the computer's tmux. connect().use { conn -> - val sink = Sink() - val stream = conn.attach("term-z-9", 80, 24, sink) - assertTrue(stream.fresh) - Thread.sleep(600) - assertEquals(0, tmux("has-session", "-t", "=nt-term-z-9").first) - stream.detach() - conn.killSession("term-z-9") - assertEquals(1, tmux("has-session", "-t", "=nt-term-z-9").first) + conn.listProjects() + val e = assertFailsWith { conn.attach("term-z-9", 80, 24, Sink()) } + assertEquals("term-z-9", e.nodeId) + Thread.sleep(300) + assertEquals(1, tmux("has-session", "-t", "=nt-term-z-9").first, "no session was created") + } + } + + @Test + fun `the attach script itself refuses to create a missing session`() { + // Belt and braces for the race where the session ends between the check and the attach. + val (code, _) = run { + val pb = ProcessBuilder("script", "-qfec", SshScripts.attach("term-y-8"), "/dev/null").directory(home) + pb.environment().clear() + pb.environment().putAll(childEnv()) + val p = pb.start() + p.outputStream.close() + p.waitFor(10, TimeUnit.SECONDS) + p.exitValue() to p.inputStream.bufferedReader().readText() + } + assertEquals(SshScripts.NO_SESSION_EXIT, code) + assertEquals(1, tmux("has-session", "-t", "=nt-term-y-8").first) + } + + @Test + fun `nodes of the desktop's SSH projects are never reached on the desktop's own tmux`() = runBlocking { + // A09: term-b-2 belongs to the ssh project "Server" (me@box). Its session, approvals and acks + // live on that host; over direct SSH the phone refuses instead of acting on the wrong machine. + connect().use { conn -> + conn.listProjects() + val e = assertFailsWith { conn.attach("term-b-2", 80, 24, Sink()) } + assertTrue(e.message!!.contains("me@box"), e.message) + assertFailsWith { conn.sendKeys("term-b-2", "1") } + val ev = InboxEvent("e2", 1, "term-b-2", "claude", null, InboxKind.APPROVAL, "Approve", null, false, false, emptyList(), false, "term-b-2-1-1") + assertFailsWith { conn.answerApproval(ev, allow = true) } + conn.ackRead("term-b-2", "e2") + assertFalse(File(home, ".nodeterm/acks/term-b-2.seen").exists(), "no ack written on the wrong machine") + assertEquals(1, tmux("has-session", "-t", "=nt-term-b-2").first, "no phantom session") } } From f62772d0f5891b99a62c8151a6dc0c7bb00399e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:41:14 +0000 Subject: [PATCH 014/284] docs(android): WP2 fixes in the audit index, handover, design notes and README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 11 +++++++++-- docs/android-audit-2026-09.md | 16 ++++++++-------- docs/android-handover.md | 19 ++++++++++++++++--- docs/android.md | 14 +++++++++++++- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/android/README.md b/android/README.md index 503fe7db07..3cc9b9843a 100644 --- a/android/README.md +++ b/android/README.md @@ -19,11 +19,12 @@ it is talking to. |---|---|---| | Pair by QR (or pasted code, or a `nodeterm://pair` link) | ✓ | E2EE-sealed `/pair`, Ed25519 key made on the phone | | Direct connection on your network (SSH + tmux) | ✓ | TOFU-pinned host key; the desktop's own tmux socket | -| From anywhere (relay, E2EE, SAS approval) | ✓ | Pin-once: approve the phone on the computer the first time | +| From anywhere (relay, E2EE, SAS approval) | ✓ | A current desktop approves the phone at the scan (its relay key rides the sealed `/pair` body); an older one shows a code on the first relay connect | | Late relay adoption (paired while remote access was off) | ✓ | Reads `~/.nodeterm/relay.json` over SSH, mints its own device token | | Sessions, grouped like the desktop sidebar | ✓ | Needs you / Running / Sleeping, activity + context % | | Terminal (co-attach to the live tmux session) | ✓ | xterm.js renderer, native input bar, special keys, swipe = tmux scroll, OSC 52 copy | -| Cold-start resume offer (the computer rebooted) | ✓ | Offers the agent's own `--resume `; never types it unasked | +| Cold-start resume offer (the computer rebooted) | relay | Offers the agent's own `--resume `; never types it unasked. Over SSH a session that is not running is never created (it would lack its hook environment): the phone offers to open it through the relay | +| Sessions of the computer's SSH projects | relay | They run on another host; the computer attaches them over its SSH connection. Over direct SSH the phone offers the relay instead | | New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches | | Wake / refresh / rename / end session | relay (end: both) | `node.*` verbs; over SSH "end" stops the tmux session only | | Kanban board, move cards, labels | relay (read-only on SSH) | `projects.ensureBoard/setCardColumn/editCardLabels` | @@ -65,6 +66,12 @@ or your own build.** Release builds will need their own private key; none exists ## Notifications +The background check never makes a first relay connection (that would put the approval dialog on +an unattended computer): it uses the relay only for a computer that has already approved this +phone. After Deny, or an approval nobody answered, the app stops re-dialing the relay until you tap +Try again or open that computer. + + The iOS app is woken by APNs pushes the nodeterm backend sends. That backend has no Android (FCM) leg, so this app checks each computer's Inbox periodically in the background (WorkManager's floor is ~15 minutes) and live every 8 seconds while a computer is open. Real-time push on Android needs diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index 9cfd11b0c1..74c5eae62d 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -18,11 +18,11 @@ the audit's proposal, the handover's progress log says how and why. | [A02](#a02) | high | BLOCK | small | protocol/bug | ✅ fixed in `e7c22eb` · SSH browse looks for the desktop's userData under 'nodeterm', but the desktop writes it under 'node-terminal', so every direct-SSH listing comes back empty | | [A03](#a03) | high | BLOCK | small | protocol/bug | ✅ fixed in `cd69a1e` · The direct-SSH tmux client starts without a UTF-8 locale, so tmux replaces every non-ASCII character with '_' | | [A04](#a04) | high | BLOCK | small | runtime/bug | ✅ fixed in `af1f820` · SSH terminal writes and resizes run on the main thread, and the NetworkOnMainThreadException that runCatching swallows leaves sshj's cipher state out of sync, which drops the whole SSH connection | -| [A05](#a05) | high | BLOCK | small | runtime/risk | The background InboxWorker can open a never-approved relay handshake and raise the desktop's SAS approval dialog while the phone shows no code | +| [A05](#a05) | high | BLOCK | small | runtime/risk | ✅ fixed in `3d36d60` · The background InboxWorker can open a never-approved relay handshake and raise the desktop's SAS approval dialog while the phone shows no code | | [A06](#a06) | high | BLOCK | small | security/bug | ✅ fixed in `16706f4` · approvals.answer reports success for a hold that already timed out, and clears NEEDS YOU on every surface | -| [A07](#a07) | high | BLOCK | medium | critic/bug | Remote access fails the first time you are away from the computer: Auto never does the first relay handshake while the phone is at the desk, and pairing does not pin the phone's relay key | -| [A08](#a08) | high | BLOCK | small | critic/bug | A cold attach over direct SSH creates the desktop's tmux session with no hook environment, so an agent resumed there never reports status, and the desktop never repairs it | -| [A09](#a09) | medium | BLOCK | small | protocol/bug | Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine | +| [A07](#a07) | high | BLOCK | medium | critic/bug | ✅ fixed in `0fa0646` · Remote access fails the first time you are away from the computer: Auto never does the first relay handshake while the phone is at the desk, and pairing does not pin the phone's relay key | +| [A08](#a08) | high | BLOCK | small | critic/bug | ✅ fixed in `1cdd2f0` · A cold attach over direct SSH creates the desktop's tmux session with no hook environment, so an agent resumed there never reports status, and the desktop never repairs it | +| [A09](#a09) | medium | BLOCK | small | protocol/bug | ✅ fixed in `726271a,1cdd2f0` · Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine | | [A10](#a10) | medium | BLOCK | small | ci-docs/risk | ✅ fixed in `fcda932` · Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | | [A11](#a11) | medium | | small | build/bug | Tapping an Inbox notification while the app is in the background does not open that computer | | [A12](#a12) | medium | | medium | protocol/bug | Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | @@ -30,20 +30,20 @@ the audit's proposal, the handover's progress log says how and why. | [A14](#a14) | medium | | small | protocol/bug | New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned | | [A15](#a15) | medium | | small | protocol/bug | The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found' | | [A16](#a16) | medium | | small | protocol/gap | The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | -| [A17](#a17) | medium | | small | protocol/risk | The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against | +| [A17](#a17) | medium | | small | protocol/risk | ✅ fixed in `3d36d60` · The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against | | [A18](#a18) | medium | | small | runtime/bug | No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely | | [A19](#a19) | medium | | small | runtime/bug | Tapping an inbox notification while the activity is alive ignores the target computer | | [A20](#a20) | medium | | small | runtime/bug | Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure | | [A21](#a21) | medium | | small | runtime/bug | A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On | | [A22](#a22) | medium | | small | runtime/bug | The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied | -| [A23](#a23) | medium | | small | security/risk | Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows | +| [A23](#a23) | medium | | small | security/risk | ✅ fixed in `3d36d60` · Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows | | [A24](#a24) | medium | | small | security/bug | SecureStore treats ANY decrypt error as 'absent', so getOrCreate32 permanently overwrites the phone's identity | | [A25](#a25) | medium | | medium | parity/gap | No real push notifications: 15-minute background polling, no notification actions, no Live-Activity equivalent, and the desktop's phone-push switches are ignored | | [A26](#a26) | medium | | medium | parity/gap | New session and board edits are unavailable on the LAN (direct-SSH) connection that Auto picks first; iOS does both over SSH | | [A27](#a27) | medium | | large | parity/gap | Cannot connect straight to a Linux dev host or a headless Server Edition (iOS's "phone SSHes into the host" setup) | -| [A28](#a28) | medium | | small | parity/gap | SSH-project sessions over direct SSH are attached, approved and resumed on the wrong machine | +| [A28](#a28) | medium | | small | parity/gap | ✅ fixed in `726271a,1cdd2f0` · SSH-project sessions over direct SSH are attached, approved and resumed on the wrong machine | | [A29](#a29) | medium | | medium | parity/gap | No source-control screen, although the protocol layer already implements the git verbs iOS uses | -| [A30](#a30) | medium | | small | critic/bug | Pressing Deny on the desktop is not respected: the phone re-dials about 8 s later and the SAS approval dialog reappears | +| [A30](#a30) | medium | | small | critic/bug | ✅ fixed in `3d36d60` · Pressing Deny on the desktop is not respected: the phone re-dials about 8 s later and the SAS approval dialog reappears | | [A31](#a31) | medium | | small | critic/bug | A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown | | [A32](#a32) | medium | | medium | critic/gap | No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm | | [A33](#a33) | medium | | medium | critic/bug | On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it | diff --git a/docs/android-handover.md b/docs/android-handover.md index 5da99a9e4a..29f7e96390 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -12,9 +12,8 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730), artifact `nodeterm-android-debug`, which expires 2026-12-24). The protocol tests pass in CI too. - **It has never been run on a phone.** A 170-agent audit found **9 distinct release blockers**. - WP1 (the unambiguous ones, including `A01` typing drops the SSH connection and `A02` the empty - SSH session list) is fixed on the branch; see the progress log below. Do not hand the APK to - anyone until WP2 is done and the device pass has run. + WP1 and WP2 (all nine) are fixed on the branch; see the progress log below. Do not hand the APK + to anyone until the device pass (WP3) has run. - The next session should **fix the blockers**, then do a **device test pass** using the checklist below, then work down the medium findings. No PR is open, and none should be opened unless the user asks. @@ -24,6 +23,20 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr Newest first. Each entry says what landed, how it was checked, and where the fix differs from the audit's proposal. +### WP2 (blockers with decisions): done on the branch, not device-verified + +| Finding | Commit | What changed | Checked by | +|---|---|---|---| +| A05 / A17 / A23 / A30 | `3d36d60` | `RelayApprovalGate` (protocol): the background worker dials the relay only for a computer where a relay connect has succeeded (persisted `relayApproved.`), and then with `requireApproved`, so a revoked pin gives up instead of waiting and clears the flag. A refused (Deny) or unanswered approval holds automatic dials until the user taps Try again / Refresh or opens the computer. `RelayConnector` reports typed `RelayApprovalRefused/Timeout/RequiredException`. | `RelayApprovalGateTest`; relay interop through the desktop's real host session: Deny reads as a refusal (fixture gained a reject mode), and a `requireApproved` dial fails fast without showing a code. | +| A07 | `0fa0646` | Option (b), additive. The phone sends its box key inside the SEALED `/pair` body; the desktop pins it (same `updateApprovedDevices`/`pinDevice` a SAS approval writes) when the relay leg was minted, answers `relayPinned: true`, records `relayBoxKey` on the device, and unpins it on revoke unless another pairing of the same phone remains. A plaintext body never pins. PairScreen says "Remote access is on" and tells the user after pairing whether one approval is still owed. | vitest `pairing-service.test.ts` (5 new cases); pairing interop through the real `createPairingService`. | +| A08 | `1cdd2f0` | Chose "refuse and route to the relay". SSH attach runs `attach-session` after a `has-session` check (exit 3), never `new-session`; a session that is not running raises `NeedsRelayException`, and the terminal offers "Open through the relay" (`HostSession.viaRelay`, a relay connection held next to the SSH one), where the desktop creates it with its hook env. | SSH transport tests: nothing is created by the transport or by the script itself. | +| A09 / A28 | `726271a` (desktop), `1cdd2f0` (phone) | **Desktop:** the relay `pty.attach` of an SSH-project node now attaches over that project's ControlMaster (`requireRemote`, host-side freshness and snapshot) or refuses with the host's name — never locally. **Phone:** over direct SSH, attach/keys/approvals/kill for those nodes raise `NeedsRelayException` and read-acks are skipped; the terminal, the Inbox and End session retry through the relay. | vitest `remote-security.test.ts` (4 new cases); SSH transport test for the refusals. | + +Not done here, noted for later: late relay adoption (`adoptRelayIfAdvertised`) does not pin, so a phone +adopted that way still approves on its first relay connect; revoking a device unpins its key but +does not cut a relay session that is open at that moment (the standing host's revocation path does +that). iOS can adopt `boxPublicKey`/`relayPinned` unchanged. + ### WP1 (blockers): done on the branch, not device-verified | Finding | Commit | What changed | Checked by | diff --git a/docs/android.md b/docs/android.md index a1cbaf1013..0291dafa97 100644 --- a/docs/android.md +++ b/docs/android.md @@ -25,6 +25,18 @@ A phone reaches a paired computer one of two ways, and the app tries them in the `Auto` tries SSH with a 4 s budget and falls back to the relay; a changed SSH host key is a hard stop, never a fallback. Per-computer overrides live in Settings. +**Relay approval.** The standing host raises its SAS dialog as soon as an unpinned phone completes +the handshake, so the phone decides *before dialing* (`RelayApprovalGate`): the background worker +dials only a computer that has approved this phone, and a refused or unanswered approval suspends +automatic dials until the user asks again. A current desktop pins the phone's relay key at pairing +(the phone sends `boxPublicKey` inside the sealed `/pair` body; the answer says `relayPinned`), so +most phones never see the dialog. The pin is dropped when the device is revoked. + +**What direct SSH will not do.** It never creates a tmux session (the desktop injects the hook +environment at creation, which the phone cannot reproduce) and never touches nodes of the desktop's +SSH projects (they live on another host). Both surface as `NeedsRelayException`, and the app opens +the session through a relay connection held next to the SSH one (`HostSession.viaRelay`). + ## Protocol mapping The standing phone host still speaks the **legacy relay dialect** (`host-service.ts` @@ -34,7 +46,7 @@ describes as the future. The Android client implements what the host actually se | Phone action | Relay (host-service.ts) | Direct SSH | |---|---|---| | List projects/sessions/status | `projects.list` → the `--NT-PROJECTS-SPLIT--` blob | same blob, from `workspace.json` + `tmux ls` + `agent-status.json`; the v3 index is resolved like `WorkspaceStore` (folder refs → `.nodeterm/project.json`, SSH refs → `cache`, data refs → `inline-projects/.json`) | -| Open a terminal | `pty.attach` → `{streamId, fresh}`, Snapshot frames, Output frames | `has-session` (fresh) then a pty exec of `tmux new-session -A` | +| Open a terminal | `pty.attach` → `{streamId, fresh}`, Snapshot frames, Output frames; a node of an SSH project is attached over that project's ControlMaster (`requireRemote`) or refused | `has-session`, then a pty exec of `tmux attach-session` — never `new-session`: a session that is not running, or a node of an SSH project, is refused with `NeedsRelayException` and the app offers the relay | | Type / resize | `OP.Input` / `OP.Resize` frames | channel stdin / window-change | | Scroll | `pty.scroll` (host writes SGR wheel events) | the phone writes the same SGR wheel events | | Detach / end | `pty.kill` / `pty.destroy` | close channel / `kill-session` | From 8e304db9a02fc8e5b0342647f86dea46b966ae43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:42:38 +0000 Subject: [PATCH 015/284] fix(android): never overwrite the phone's identity on a transient keystore error (A24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecureStore turned ANY decrypt error into "absent", and getOrCreate32 then wrote fresh random bytes over the relay box key and the SSH seed. Every paired computer then had to approve the phone again (someone at the desk) and direct SSH failed until a re-pair. Separately, a keystore that briefly returned null for its key made us generate a NEW key under the same alias, which made every stored secret unreadable. - protocol: SecretStoreCore owns the rule. A value is replaced only on positive evidence it is gone: nothing stored, a malformed blob, or AEADBadTagException (the sealing key no longer exists). Any other failure is SecretUnavailableException and nothing is written. The first identity write is durable (commit, not apply). - app: SecureStore is only the Keystore AES-GCM cipher + preferences glue. It generates the Keystore key only when the alias is absent, and throws when the keystore holds the alias but does not return it. The stored format is unchanged (base64 of nonce ‖ ciphertext). Tests: SecretStoreCoreTest with real AES-GCM: a transient ProviderException or IllegalStateException leaves the stored identity untouched and it reads back afterwards; a wiped key and malformed blobs are replaced; the first write is durable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/data/SecureStore.kt | 116 +++++++++++------- .../protocol/secure/SecretStoreCore.kt | 95 ++++++++++++++ .../nodeterm/protocol/SecretStoreCoreTest.kt | 104 ++++++++++++++++ 3 files changed, 271 insertions(+), 44 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretStoreCoreTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt index 37b2e23f0d..7ed296ffa5 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt @@ -3,7 +3,10 @@ package dev.nodeterm.android.data import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import android.util.Base64 +import dev.nodeterm.protocol.secure.SecretStorage +import dev.nodeterm.protocol.secure.SecretStoreCore +import dev.nodeterm.protocol.secure.SecretUnavailableException +import dev.nodeterm.protocol.secure.Sealer import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator @@ -26,60 +29,85 @@ import javax.crypto.spec.GCMParameterSpec class SecureStore(context: Context) { private val prefs = context.getSharedPreferences("nodeterm.secure", Context.MODE_PRIVATE) + /** + * The Keystore key. Generated ONLY when the alias is absent: a keystore that briefly answers + * null for a key it holds must not make us generate a new one under the same alias, which would + * make every stored secret unreadable (audit A24, verifier note (b)). + */ private val key: SecretKey by lazy { val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - (ks.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - .apply { - init( - KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - .build() - ) - } - .generateKey() - } - - @Synchronized - fun putBytes(name: String, value: ByteArray) { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, key) - val sealed = cipher.iv + cipher.doFinal(value) - prefs.edit().putString(name, Base64.encodeToString(sealed, Base64.NO_WRAP)).apply() + if (ks.containsAlias(ALIAS)) { + ks.getKey(ALIAS, null) as? SecretKey + ?: throw java.security.KeyStoreException("The Keystore holds $ALIAS but did not return it.") + } else { + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + .apply { + init( + KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + } + .generateKey() + } } - @Synchronized - fun getBytes(name: String): ByteArray? { - val raw = prefs.getString(name, null) ?: return null - return try { - val sealed = Base64.decode(raw, Base64.NO_WRAP) - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, IV_BYTES)) - cipher.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES) - } catch (_: Exception) { - // A value we cannot open (keystore wiped, restored onto another device) is not a crash: - // the caller regenerates / asks the user to re-pair. - null + /** + * The rules (what counts as "gone", what is only "unreadable right now") live in the tested + * [SecretStoreCore]; this class is only the Keystore cipher and the preferences file. + */ + private val core = SecretStoreCore( + storage = object : SecretStorage { + override fun get(name: String): String? = prefs.getString(name, null) + override fun put(name: String, value: String, durable: Boolean) { + val edit = prefs.edit().putString(name, value) + if (durable) edit.commit() else edit.apply() + } + override fun remove(name: String) { + prefs.edit().remove(name).apply() + } + }, + sealer = object : Sealer { + override fun seal(plain: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + return cipher.iv + cipher.doFinal(plain) + } + override fun open(sealed: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, IV_BYTES)) + return cipher.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES) + } } + ) + + fun putBytes(name: String, value: ByteArray) = core.putBytes(name, value) + + /** + * The value, or null when absent or provably lost. A value that cannot be read RIGHT NOW (a + * keystore hiccup) also reads as null here — callers of this form only read (relay tokens) and + * overwrite nothing; the identity secrets go through [getOrCreate32], which never overwrites on + * such an error. + */ + fun getBytes(name: String): ByteArray? = try { + core.getBytes(name) + } catch (_: SecretUnavailableException) { + null } fun putString(name: String, value: String) = putBytes(name, value.toByteArray(Charsets.UTF_8)) fun getString(name: String): String? = getBytes(name)?.toString(Charsets.UTF_8) - @Synchronized - fun remove(name: String) { - prefs.edit().remove(name).apply() - } + fun remove(name: String) = core.remove(name) - /** Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). */ - @Synchronized - fun getOrCreate32(name: String): ByteArray { - getBytes(name)?.takeIf { it.size == 32 }?.let { return it } - val fresh = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) } - putBytes(name, fresh) - return fresh - } + /** + * Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). Replaces a stored + * value only when it is provably gone; throws [SecretUnavailableException] when it merely cannot + * be read right now, so the phone's identity is never overwritten by a transient error. + */ + fun getOrCreate32(name: String): ByteArray = core.getOrCreate32(name) companion object { private const val ANDROID_KEYSTORE = "AndroidKeyStore" diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt new file mode 100644 index 0000000000..72a638d6aa --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt @@ -0,0 +1,95 @@ +package dev.nodeterm.protocol.secure + +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.AEADBadTagException + +/** Where sealed values are kept (app-private preferences on Android). */ +interface SecretStorage { + fun get(name: String): String? + /** [durable] = do not return before the value is on disk (the phone's identity secrets). */ + fun put(name: String, value: String, durable: Boolean) + fun remove(name: String) +} + +/** Seals and opens values (AES-GCM under an Android Keystore key). [open] throws on failure. */ +interface Sealer { + fun seal(plain: ByteArray): ByteArray + fun open(sealed: ByteArray): ByteArray +} + +/** + * A stored secret cannot be read RIGHT NOW (the keystore is busy, locked, or threw something we do + * not recognise). It is not evidence the value is gone, so nothing is overwritten; try again later. + */ +class SecretUnavailableException(name: String, cause: Throwable) : + Exception("A stored secret ($name) can't be read right now: ${cause.message ?: cause.javaClass.simpleName}", cause) + +/** + * The phone's secrets at rest, with one rule the first version got wrong (audit A24): a value is + * replaced only on POSITIVE evidence it can never be read again. That version turned ANY decrypt + * error into "absent", and `getOrCreate32` then wrote fresh random bytes over the phone's relay and + * SSH identities — after which every paired computer had to approve the phone again (someone at + * the desk) and direct SSH failed until a re-pair. + * + * Positive evidence is: nothing stored; a stored blob that is malformed (not base64, or too short to + * hold a nonce and a tag); or the cipher's own authentication failing (`AEADBadTagException` — the + * key that sealed it is gone). Anything else is [SecretUnavailableException], and nothing is written. + */ +class SecretStoreCore(private val storage: SecretStorage, private val sealer: Sealer) { + sealed interface Read { + data object Absent : Read + class Value(val bytes: ByteArray) : Read + /** Stored, but provably never readable again. */ + data class Lost(val reason: String) : Read + } + + @Synchronized + fun read(name: String): Read { + val raw = storage.get(name) ?: return Read.Absent + val sealed = try { + Base64.getDecoder().decode(raw) + } catch (_: IllegalArgumentException) { + return Read.Lost("not base64") + } + if (sealed.size < MIN_SEALED_BYTES) return Read.Lost("too short") + return try { + Read.Value(sealer.open(sealed)) + } catch (_: AEADBadTagException) { + Read.Lost("authentication failed") + } catch (e: Exception) { + throw SecretUnavailableException(name, e) + } + } + + /** The value, or null when absent or lost. Throws [SecretUnavailableException] when unreadable NOW. */ + fun getBytes(name: String): ByteArray? = (read(name) as? Read.Value)?.bytes + + @Synchronized + fun putBytes(name: String, value: ByteArray, durable: Boolean = false) { + storage.put(name, Base64.getEncoder().encodeToString(sealer.seal(value)), durable) + } + + @Synchronized + fun remove(name: String) = storage.remove(name) + + /** + * Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). Creates only when + * nothing is stored or the stored value is provably lost; the first write is durable. + */ + @Synchronized + fun getOrCreate32(name: String): ByteArray { + when (val r = read(name)) { + is Read.Value -> if (r.bytes.size == 32) return r.bytes + Read.Absent, is Read.Lost -> Unit + } + val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) } + putBytes(name, fresh, durable = true) + return fresh + } + + companion object { + /** A 12-byte GCM nonce plus a 16-byte tag: anything shorter was never a sealed value. */ + const val MIN_SEALED_BYTES = 12 + 16 + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretStoreCoreTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretStoreCoreTest.kt new file mode 100644 index 0000000000..f3a50eddc6 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretStoreCoreTest.kt @@ -0,0 +1,104 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.secure.SecretStorage +import dev.nodeterm.protocol.secure.SecretStoreCore +import dev.nodeterm.protocol.secure.SecretUnavailableException +import dev.nodeterm.protocol.secure.Sealer +import java.security.ProviderException +import java.security.SecureRandom +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotEquals + +/** Audit A24: the phone's identity is replaced only on positive evidence it is gone. */ +class SecretStoreCoreTest { + private class MemStorage : SecretStorage { + val map = HashMap() + val durableWrites = ArrayList() + override fun get(name: String) = map[name] + override fun put(name: String, value: String, durable: Boolean) { + map[name] = value + if (durable) durableWrites += name + } + override fun remove(name: String) { + map.remove(name) + } + } + + /** Real AES-GCM (the Keystore does the same), plus a switch to fail like a busy keystore. */ + private class GcmSealer(var key: SecretKey = newKey()) : Sealer { + var failOpenWith: Exception? = null + override fun seal(plain: ByteArray): ByteArray { + val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) + return iv + c.doFinal(plain) + } + override fun open(sealed: ByteArray): ByteArray { + failOpenWith?.let { throw it } + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, 12)) + return c.doFinal(sealed, 12, sealed.size - 12) + } + companion object { + fun newKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + } + } + + private val storage = MemStorage() + private val sealer = GcmSealer() + private val store = SecretStoreCore(storage, sealer) + + @Test + fun `the identity is created once, durably, and then kept`() { + val a = store.getOrCreate32("box.secret") + assertEquals(listOf("box.secret"), storage.durableWrites) + assertContentEquals(a, store.getOrCreate32("box.secret")) + } + + @Test + fun `a transient keystore failure never overwrites the identity`() { + val a = store.getOrCreate32("box.secret") + val before = storage.map["box.secret"] + sealer.failOpenWith = ProviderException("Keystore operation failed") + assertFailsWith { store.getOrCreate32("box.secret") } + sealer.failOpenWith = IllegalStateException("keystore locked") + assertFailsWith { store.getBytes("box.secret") } + assertEquals(before, storage.map["box.secret"], "the stored identity was not touched") + sealer.failOpenWith = null + assertContentEquals(a, store.getOrCreate32("box.secret"), "and it reads back once the keystore answers") + } + + @Test + fun `a value sealed under a key that is gone is replaced (positive evidence)`() { + val a = store.getOrCreate32("ssh.seed") + sealer.key = GcmSealer.newKey() // the Keystore key was wiped and regenerated + assertIs(store.read("ssh.seed")) + assertNotEquals(a.toList(), store.getOrCreate32("ssh.seed").toList()) + } + + @Test + fun `a malformed blob is lost, not unavailable`() { + storage.map["x"] = "%%%not base64%%%" + assertIs(store.read("x")) + storage.map["y"] = java.util.Base64.getEncoder().encodeToString(ByteArray(5)) + assertIs(store.read("y")) + assertEquals(32, store.getOrCreate32("x").size) + } + + @Test + fun `strings round-trip and absence is absence`() { + assertIs(store.read("relay.token.h")) + store.putBytes("relay.token.h", "tok".toByteArray()) + assertEquals("tok", store.getBytes("relay.token.h")!!.toString(Charsets.UTF_8)) + assertIs(runCatching { sealer.key = GcmSealer.newKey(); sealer.open(java.util.Base64.getDecoder().decode(storage.map["relay.token.h"])) }.exceptionOrNull()) + } +} From a96bc0dfe69c5d99c29f598108403ce91c2277e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:46:08 +0000 Subject: [PATCH 016/284] docs(android): mark A24 fixed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 2 +- docs/android-handover.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index 74c5eae62d..ada224e498 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -37,7 +37,7 @@ the audit's proposal, the handover's progress log says how and why. | [A21](#a21) | medium | | small | runtime/bug | A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On | | [A22](#a22) | medium | | small | runtime/bug | The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied | | [A23](#a23) | medium | | small | security/risk | ✅ fixed in `3d36d60` · Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows | -| [A24](#a24) | medium | | small | security/bug | SecureStore treats ANY decrypt error as 'absent', so getOrCreate32 permanently overwrites the phone's identity | +| [A24](#a24) | medium | | small | security/bug | ✅ fixed in `8e304db` · SecureStore treats ANY decrypt error as 'absent', so getOrCreate32 permanently overwrites the phone's identity | | [A25](#a25) | medium | | medium | parity/gap | No real push notifications: 15-minute background polling, no notification actions, no Live-Activity equivalent, and the desktop's phone-push switches are ignored | | [A26](#a26) | medium | | medium | parity/gap | New session and board edits are unavailable on the LAN (direct-SSH) connection that Auto picks first; iOS does both over SSH | | [A27](#a27) | medium | | large | parity/gap | Cannot connect straight to a Linux dev host or a headless Server Edition (iOS's "phone SSHes into the host" setup) | diff --git a/docs/android-handover.md b/docs/android-handover.md index 29f7e96390..f8d26efd87 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -23,6 +23,12 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr Newest first. Each entry says what landed, how it was checked, and where the fix differs from the audit's proposal. +### WP4 (medium bugs): in progress + +| Finding | Commit | What changed | Checked by | +|---|---|---|---| +| A24 | `8e304db` | `SecretStoreCore` (protocol) replaces a stored secret only on positive evidence it is gone (nothing stored, malformed blob, `AEADBadTagException`); anything else is `SecretUnavailableException` and nothing is written. The identity's first write is durable. `SecureStore` generates the Keystore key only when the alias is absent. | `SecretStoreCoreTest` with real AES-GCM. | + ### WP2 (blockers with decisions): done on the branch, not device-verified | Finding | Commit | What changed | Checked by | From 0094c0219d84ae359e24b63c394a41661b2c48e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:48:56 +0000 Subject: [PATCH 017/284] test(android): wait for the socket close after a broken-transport report sshj's own disconnect listener can report the drop before breakTransport has closed the socket; the assertion now waits for it (up to 5 s). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index fcce5e05c9..4cbaf6a893 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -350,6 +350,9 @@ class SshTransportTest { guard.poisonAll = true stream.write("x") assertTrue(closed.await(10, TimeUnit.SECONDS), "the broken transport was reported through onClosed") + // sshj's own disconnect listener may report first; the socket close lands right after. + val end = System.currentTimeMillis() + 5_000 + while (conn.isConnected && System.currentTimeMillis() < end) Thread.sleep(20) assertFalse(conn.isConnected) } finally { guard.poisonAll = false From d199f039be79d4d7d34f65423cbde2ff6535d132 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:48:56 +0000 Subject: [PATCH 018/284] fix(android): a cancelled connect neither leaks its SSH session nor reads as offline (A20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectLocked caught CancellationException as an ordinary failure: the blocking SSH dial finished after the poll job was cancelled (every Host→Terminal hand-off, and backing out of a computer), withContext dropped the connection it returned, and nothing closed it; the host then showed "offline — StandaloneCoroutine was cancelled". Both legs now rethrow cancellation, set the state back to Idle instead of Failed, and the SSH leg closes a connection that finished dialing after the caller was cancelled. Not unit-tested: HostSession is Android-bound; checked by the offline type-check and CI's AGP build only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/conn/ConnectionManager.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index 1cf20fc39f..b942a40899 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -77,16 +77,24 @@ class HostSession(val hostId: String, private val graph: AppGraph) { if (route != RoutePreference.RELAY_ONLY && host.sshAvailable) { _state.value = ConnState.Connecting("Connecting on your network…") + // The blocking dial finishes even when this coroutine is cancelled meanwhile (the poll + // job stops when a screen goes away), and withContext then drops its result: close it, + // or it stays open, keep-alive and all, for the life of the process (audit A20). + var dialed: SshHostConnection? = null try { val ssh = withContext(Dispatchers.IO) { SshHostConnection.connect( host.host, host.port, host.user, graph.sshIdentity, pinFor(host), connectTimeoutMs = if (route == RoutePreference.AUTO) 4_000 else 10_000 - ) + ).also { dialed = it } } adopt(ssh) scope.launch { runCatching { adoptRelayIfAdvertised(ssh, host) } } return ssh + } catch (e: kotlinx.coroutines.CancellationException) { + dialed?.let { c -> scope.launch(Dispatchers.IO) { runCatching { c.close() } } } + _state.value = ConnState.Idle + throw e } catch (e: HostKeyChangedException) { // A changed host key is a security signal, not a network hiccup: say so, and do not // quietly route around it. @@ -121,6 +129,8 @@ class HostSession(val hostId: String, private val graph: AppGraph) { adopt(connected.connection) return connected.connection } catch (e: kotlinx.coroutines.CancellationException) { + // A cancelled dial is not a failed one: no "offline", no error text (A20). + _state.value = ConnState.Idle throw e } catch (e: Exception) { graph.relayGate.onFailed(hostId, e) From de1edede3512f25324dcd6e558b254c65606f116 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:48:56 +0000 Subject: [PATCH 019/284] fix(android): a notification tap opens that computer's Inbox while the app runs (A11/A19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainActivity is singleTask, so a notification tapped while the app was in the background arrived in onNewIntent, which only looked for pairing links; the host id was read once, in onCreate. onNewIntent now calls setIntent and records the host id; a LaunchedEffect validates it and opens Hosts → that computer on the Inbox tab. The Host destination is keyed on its route, so a screen already showing another computer (or the same one on another tab) does not keep its saved tab. Not unit-tested (Activity + Compose); type-checked offline, built by CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/MainActivity.kt | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt index 2037e92590..71aa682af5 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -12,6 +12,7 @@ import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateListOf @@ -64,9 +65,19 @@ class MainActivity : ComponentActivity() { } } + /** A notification tap naming a computer (at launch or while running) that awaits the UI. */ + private var incomingHost by mutableStateOf(null) + + /** + * A live activity gets later intents HERE, not in onCreate (it is `singleTask`): a notification + * tapped while the app sat in the background used to open whatever screen was last showing + * instead of that computer's Inbox (audit A11/A19). + */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) takePairLink(intent) + intent.getStringExtra(EXTRA_HOST_ID)?.let { incomingHost = it } } private val notificationPermission = @@ -89,6 +100,16 @@ class MainActivity : ComponentActivity() { if (openHost != null && graph.hosts.get(openHost) != null) n.push(Route.Host(openHost, tab = 2)) } } + val hostTap = incomingHost + LaunchedEffect(hostTap) { + if (hostTap != null) { + incomingHost = null + if (graph.hosts.get(hostTap) != null) { + nav.replaceAll(Route.Hosts) + nav.push(Route.Host(hostTap, tab = 2)) + } + } + } val code = incomingPairCode LaunchedEffect(code) { if (code != null) { @@ -113,7 +134,9 @@ private fun AppContent(nav: Navigator) { Route.Hosts -> HostsScreen(nav) is Route.PairHost -> PairScreen(nav, r.code) Route.Settings -> SettingsScreen(nav) - is Route.Host -> HostScreen(nav, r.hostId, r.tab) + // Keyed on the route: a Host screen for another computer, or for the same one opened on a + // different tab (a notification → Inbox), must not inherit the showing screen's saved state. + is Route.Host -> key(r) { HostScreen(nav, r.hostId, r.tab) } is Route.Terminal -> TerminalScreen(nav, r.hostId, r.nodeId, r.title) } } From 2a273a7776151751ce56bf31a698788e6af060a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:48:56 +0000 Subject: [PATCH 020/284] fix(android): Ctrl applies to the input bar; reattach after a dropped connection (A34/A36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A34: the input bar sends through xterm's bracketed paste, so the Ctrl chip (applied per keystroke) never saw a single character: arming Ctrl and sending `z` submitted a literal `z` plus Enter. submit() now applies an armed Ctrl itself — one character with a control byte is sent as that byte alone, without Enter — and otherwise disarms the chip. The mapping is Keys.ctrl in protocol (adds `?` → DEL), shared with typed keystrokes. A36: a null exit is the connection going away, not the pane ending. The terminal now follows the host connection back into the session: it re-lists (which drops a dead SSH transport), waits for Connected, and reattaches — at most three times per stretch of flapping (a stream that lived a minute resets the count), only while the screen is showing, and with a Reconnecting… message meanwhile. Tests: KeysTest. The controller changes are type-checked offline and built by CI; they have not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/android/ui/TerminalController.kt | 56 ++++++++++++++++++- .../dev/nodeterm/protocol/model/Keys.kt | 18 ++++++ .../kotlin/dev/nodeterm/protocol/KeysTest.kt | 27 +++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Keys.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeysTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index b222b8ccb5..7877b84cca 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -1,5 +1,8 @@ package dev.nodeterm.android.ui +import dev.nodeterm.android.conn.ConnState +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.first import android.annotation.SuppressLint import android.content.ClipData import android.content.ClipboardManager @@ -29,6 +32,7 @@ import dev.nodeterm.protocol.host.TerminalStream import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.Keys import dev.nodeterm.protocol.model.Launch import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -113,7 +117,11 @@ class TerminalController( override fun onExit(code: Int?) { main.post { stream = null - state = TermState.Ended(if (code == null) "Disconnected." else "The session ended (exit $code).") + if (code == null && !disposed && autoReattach()) { + state = TermState.Ended("Disconnected. Reconnecting…") + } else { + state = TermState.Ended(if (code == null) "Disconnected." else "The session ended (exit $code).") + } } } } @@ -144,8 +152,7 @@ class TerminalController( fun onInput(data: String) { var out = data if (ctrlArmed && data.length == 1) { - val ch = data[0].uppercaseChar() - if (ch in '@'..'_') out = (ch.code - 64).toChar().toString() + Keys.ctrl(data)?.let { out = it } main.post { ctrlArmed = false } } stream?.write(out) @@ -204,6 +211,34 @@ class TerminalController( wv.evaluateJavascript(code, null) } + private var attachedAt = 0L + private var autoReattaches = 0 + + /** + * A null exit is the CONNECTION going away, not the pane ending. The host connection reconnects + * on its own; this follows it back into the session instead of leaving "Disconnected" until the + * user taps Reattach (audit A36). Bounded: three tries per stretch of flapping (a stream that + * lived a minute resets the count), and only while this screen is showing. Returns false when + * out of tries. + */ + private fun autoReattach(): Boolean { + if (System.currentTimeMillis() - attachedAt > 60_000) autoReattaches = 0 + if (autoReattaches >= 3) return false + autoReattaches++ + graph.scope.launch { + delay(1_500L * autoReattaches) + // A re-list notices a dead SSH transport (and drops it) before we ask for a connection, + // so the attach below does not get the stale one back. + if (!useRelay) session.refreshNow() + val up = if (useRelay) true else withTimeoutOrNull(120_000) { session.state.first { it is ConnState.Connected } } != null + main.post { + if (disposed || stream != null || attachJob != null) return@post + if (up) attach() else state = TermState.Ended("Disconnected.") + } + } + return true + } + /** Set once the user chose "Open through the relay": later reattaches stay on the relay. */ private var useRelay = false @@ -234,6 +269,7 @@ class TerminalController( } main.post { stream = s + attachedAt = System.currentTimeMillis() state = TermState.Attached if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) } @@ -307,7 +343,21 @@ class TerminalController( sizedElsewhere = null } + /** + * Send the input bar's text. An armed Ctrl applies to it (audit A34): the bar goes through + * xterm's bracketed paste, so the per-keystroke Ctrl in [Bridge.onInput] never saw a single + * character — arming Ctrl and sending `z` used to submit a literal `z` plus Enter. One character + * with a control byte is sent as that byte alone, with no Enter (^Z then Enter is not ^Z); + * anything else just disarms the chip and is sent as typed. + */ fun submit(text: String, enter: Boolean) { + if (ctrlArmed) { + ctrlArmed = false + Keys.ctrl(text)?.let { + raw(it) + return + } + } js("nt.submit('${b64(text.toByteArray(Charsets.UTF_8))}', $enter)") } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Keys.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Keys.kt new file mode 100644 index 0000000000..b4e73ead84 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Keys.kt @@ -0,0 +1,18 @@ +package dev.nodeterm.protocol.model + +/** Terminal key encodings the phone produces itself. */ +object Keys { + /** + * The control byte for Ctrl + [text], when [text] is ONE character that has one: `@`..`_` in + * either case (Ctrl-A = 0x01, Ctrl-[ = ESC, …) and `?` (DEL). Null otherwise. + */ + fun ctrl(text: String): String? { + if (text.length != 1) return null + val ch = text[0].uppercaseChar() + return when { + ch in '@'..'_' -> (ch.code - 64).toChar().toString() + ch == '?' -> "\u007f" + else -> null + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeysTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeysTest.kt new file mode 100644 index 0000000000..5cfecc5fe5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeysTest.kt @@ -0,0 +1,27 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Keys +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** A34: the Ctrl chip's byte, shared by typed keystrokes and the input bar. */ +class KeysTest { + @Test + fun `single characters map to their control bytes in either case`() { + assertEquals("\u001a", Keys.ctrl("z")) + assertEquals("\u001a", Keys.ctrl("Z")) + assertEquals("\u0003", Keys.ctrl("c")) + assertEquals("\u001b", Keys.ctrl("[")) + assertEquals("\u0000", Keys.ctrl("@")) + assertEquals("\u007f", Keys.ctrl("?")) + } + + @Test + fun `anything else has no control byte`() { + assertNull(Keys.ctrl("")) + assertNull(Keys.ctrl("zz")) + assertNull(Keys.ctrl("1")) + assertNull(Keys.ctrl("é")) + } +} From aab2f010b031f841a49947699098888c02fac48d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:49:03 +0000 Subject: [PATCH 021/284] docs(android): mark A11/A19, A20, A34, A36 fixed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index ada224e498..4ff987efe3 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -24,7 +24,7 @@ the audit's proposal, the handover's progress log says how and why. | [A08](#a08) | high | BLOCK | small | critic/bug | ✅ fixed in `1cdd2f0` · A cold attach over direct SSH creates the desktop's tmux session with no hook environment, so an agent resumed there never reports status, and the desktop never repairs it | | [A09](#a09) | medium | BLOCK | small | protocol/bug | ✅ fixed in `726271a,1cdd2f0` · Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine | | [A10](#a10) | medium | BLOCK | small | ci-docs/risk | ✅ fixed in `fcda932` · Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | -| [A11](#a11) | medium | | small | build/bug | Tapping an Inbox notification while the app is in the background does not open that computer | +| [A11](#a11) | medium | | small | build/bug | ✅ fixed in `de1eded` · Tapping an Inbox notification while the app is in the background does not open that computer | | [A12](#a12) | medium | | medium | protocol/bug | Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | | [A13](#a13) | medium | | medium | protocol/bug | Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | | [A14](#a14) | medium | | small | protocol/bug | New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned | @@ -32,8 +32,8 @@ the audit's proposal, the handover's progress log says how and why. | [A16](#a16) | medium | | small | protocol/gap | The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | | [A17](#a17) | medium | | small | protocol/risk | ✅ fixed in `3d36d60` · The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against | | [A18](#a18) | medium | | small | runtime/bug | No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely | -| [A19](#a19) | medium | | small | runtime/bug | Tapping an inbox notification while the activity is alive ignores the target computer | -| [A20](#a20) | medium | | small | runtime/bug | Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure | +| [A19](#a19) | medium | | small | runtime/bug | ✅ fixed in `de1eded` · Tapping an inbox notification while the activity is alive ignores the target computer | +| [A20](#a20) | medium | | small | runtime/bug | ✅ fixed in `d199f03` · Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure | | [A21](#a21) | medium | | small | runtime/bug | A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On | | [A22](#a22) | medium | | small | runtime/bug | The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied | | [A23](#a23) | medium | | small | security/risk | ✅ fixed in `3d36d60` · Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows | @@ -47,9 +47,9 @@ the audit's proposal, the handover's progress log says how and why. | [A31](#a31) | medium | | small | critic/bug | A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown | | [A32](#a32) | medium | | medium | critic/gap | No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm | | [A33](#a33) | medium | | medium | critic/bug | On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it | -| [A34](#a34) | medium | | small | critic/bug | The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter | +| [A34](#a34) | medium | | small | critic/bug | ✅ fixed in `2a273a7` · The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter | | [A35](#a35) | medium | | small | critic/bug | ✅ fixed in `16706f4` · approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.' | -| [A36](#a36) | medium | | small | critic/gap | After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself | +| [A36](#a36) | medium | | small | critic/gap | ✅ fixed in `2a273a7` · After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself | | [A37](#a37) | low | | small | build/risk | proguard-rules.pro would not survive turning on minification (R8 missing-class errors) | | [A38](#a38) | low | | small | protocol/bug | Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question | | [A39](#a39) | low | | small | protocol/bug | The account chip reads `account.label`, which the mirror never writes, so it falls back to the raw account UUID | From c58ad65167e6b27449e167743e96ae1530e03734 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:50:51 +0000 Subject: [PATCH 022/284] fix(android): launches and resumes follow the project's mode, account and folder (A14/A15/A16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A16: the phone used only the global permission mode and always defaulted to the System account. ProjectInfo now carries defaultPermissionMode and defaultAccountId; Launch.permissionMode resolves project → global the way the desktop's resolvePermissionMode does (both re-validated; claude-only, with the host's auto gate), and the New-session dialog preselects the project's default account only while the host still has it. A15: the cold-attach resume offer was a bare `claude --resume `. A relay-created pane starts in $HOME under the default config dir, so a managed account's transcript was never found. Launch.resumeLine builds it like the desktop's cold restore: `cd &&`, CLAUDE_CONFIG_DIR for the node's managed account, and the permission-mode flag. Portable `./` node cwds resolve against the project folder (ProjectInfo.absoluteCwdOf). A14: the dialog offered cwd-less (inline) projects, which the desktop refuses to register in, and the refusal was swallowed. Those projects are no longer offered (dialog and FAB), and a refused registration now shows a notice saying the session is running but not on the canvas. Tests: ModelTest (mode resolution, stale default account, resume line, portable cwds). The dialog and notice are type-checked offline only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/HostScreen.kt | 2 +- .../dev/nodeterm/android/ui/SessionsTab.kt | 14 +++-- .../nodeterm/android/ui/TerminalController.kt | 19 +++++- .../dev/nodeterm/android/ui/TerminalScreen.kt | 9 +++ .../dev/nodeterm/protocol/model/Agents.kt | 63 +++++++++++++++++-- .../dev/nodeterm/protocol/model/Models.kt | 22 ++++++- .../nodeterm/protocol/model/ProjectsParser.kt | 4 +- .../kotlin/dev/nodeterm/protocol/ModelTest.kt | 47 ++++++++++++++ 8 files changed, 165 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index 88c4cda3bb..fe1d46fdc1 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -69,7 +69,7 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { val needsYou = snapshot.status?.inbox?.events?.count { it.actionable } ?: 0 val canCreate = (state as? ConnState.Connected)?.kind == TransportKind.RELAY && - snapshot.projects.any { !it.closed && it.sshTarget == null } + snapshot.projects.any { !it.closed && it.sshTarget == null && it.cwd != null } Scaffold( topBar = { diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt index fa74809653..bf5dc6219f 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -294,11 +294,17 @@ private fun SessionRow(node: NodeInfo, snapshot: ProjectsSnapshot, onClick: () - /** New session: a project on THIS computer, an agent (or a plain shell), and for Claude an account. */ @Composable fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate: (LaunchRequest) -> Unit) { - val projects = snapshot.openProjects().filter { it.sshTarget == null } + // Only projects the desktop can register a node in: on this computer (not an SSH project) AND + // with a folder — it refuses cwd-less (inline) canvases, which would orphan the session (A14). + val projects = snapshot.openProjects().filter { it.sshTarget == null && it.cwd != null } var projectId by remember { mutableStateOf(projects.firstOrNull()?.id) } var agent by remember { mutableStateOf(Agent.CLAUDE) } - val accounts = snapshot.status?.settings?.claudeAccounts.orEmpty() - var accountId by remember { mutableStateOf(null) } + val settings = snapshot.status?.settings + val accounts = settings?.claudeAccounts.orEmpty() + // The project's own default account, when this host still has it (A16). + var accountId by remember(projectId) { + mutableStateOf(Launch.defaultAccount(settings, projects.firstOrNull { it.id == projectId })) + } AlertDialog( onDismissRequest = onDismiss, @@ -335,7 +341,7 @@ fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate val p = projects.first { it.id == projectId } val a = agent val acct = if (a == Agent.CLAUDE) accountId else null - val cmd = if (a != null) Launch.launchCommand(a, snapshot.status?.settings, acct, p.cwd) + val cmd = if (a != null) Launch.launchCommand(a, settings, acct, p.cwd, p.defaultPermissionMode) else p.cwd?.let { if (Regex("^/[^'\\u0000-\\u001f]*$").matches(it)) "cd '$it'" else null } onCreate( LaunchRequest( diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 7877b84cca..0fbee8fdd8 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -70,6 +70,9 @@ class TerminalController( private set var ctrlArmed by mutableStateOf(false) + /** A one-line message over the terminal (dismissable), e.g. a session the desktop refused to add. */ + var notice by mutableStateOf(null) + private val main = Handler(Looper.getMainLooper()) private var webView: WebView? = null private var pageReady = false @@ -297,9 +300,12 @@ class TerminalController( delay(900) launch.command?.let { s.write(it + "\r") } if (conn.capabilities.registerNode) { - runCatching { + // A refusal is an ANSWER (host-service: the session stays open, just unregistered): + // say so, instead of leaving a session no canvas shows (audit A14). + val registered = runCatching { conn.registerNode(launch.projectId, NewNode(nodeId, launch.title, launch.agentId, launch.accountId)) - } + }.getOrDefault(false) + if (!registered) main.post { notice = UNREGISTERED_NOTICE } } session.refreshNow() return @@ -310,10 +316,14 @@ class TerminalController( if (s.fresh) { // The computer's tmux session was gone (a reboot): the conversation is on disk, not in the // pane. Offer the agent's own resume — never type it unasked into a pane we cannot see. + // Built like the desktop's cold restore: the node's directory, its managed account, the + // project's permission mode (audit A15/A16) — a relay-created pane starts in $HOME. val agent = Agent.of(node?.agentId ?: status?.agentId) val sid = status?.sessionId ?: node?.agentSessionId + val project = snap.findNode(nodeId)?.first if (agent != null && sid != null) { - Launch.resumeCommand(agent, sid)?.let { cmd -> main.post { resumeOffer = agent.label to cmd } } + Launch.resumeLine(agent, sid, snap.status?.settings, node?.accountId, project?.absoluteCwdOf(node), project?.defaultPermissionMode) + ?.let { cmd -> main.post { resumeOffer = agent.label to cmd } } } } // Reading a finished session on the phone is a READ: tell the computer (unread clears there, @@ -388,6 +398,9 @@ class TerminalController( } companion object { + const val UNREGISTERED_NOTICE = + "The computer didn't add this session to the project, so it won't appear on the canvas. It keeps running; " + + "end it here when you are done, or find it in nodeterm's session list (the RAM pill) on the computer." private const val CHUNK = 192 * 1024 private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 0e34de6568..115c9ad10a 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -129,6 +129,15 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String } } } + controller.notice?.let { msg -> + Row( + Modifier.align(Alignment.TopCenter).fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(msg, Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { controller.notice = null }) { Text("OK") } + } + } controller.sizedElsewhere?.let { (c, r) -> Row( Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 4.dp), diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt index ab81e29b78..1c420489ed 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt @@ -39,24 +39,77 @@ object Launch { } } + /** + * The permission mode a Claude session starts in on this host: the project's own override when + * it names a valid mode, else the global setting — the desktop's `resolvePermissionMode(project, + * settings)` (audit A16). Both values come from hand-editable files, so both are re-validated. + * No mirror settings at all = `manual` (no flag): the phone never invents one. + */ + fun permissionMode(settings: MirrorSettings?, projectMode: String? = null): String = + projectMode?.takeIf { it in PERMISSION_MODES } + ?: settings?.claudePermissionMode?.takeIf { it in PERMISSION_MODES } + ?: "manual" + + /** + * The managed Claude account a new session in [project] starts under by default: the project's + * default, but only when this host still has that account (a stale id falls back to System, + * exactly as the desktop validates an id before stamping it on a node). + */ + fun defaultAccount(settings: MirrorSettings?, project: ProjectInfo?): String? { + val id = project?.defaultAccountId ?: return null + return id.takeIf { settings?.claudeAccounts?.any { it.id == id } == true } + } + /** * A first launch of [agent] on a host whose mirror advertised [settings]. Only CLAUDE gets a * permission-mode flag, and only as the desktop would emit it: `manual` = no flag, `auto` only * when the HOST's claude supports it (`autoSupported` answers for claude alone), and an unknown * value = no flag (the value came from a file; re-validated here like `permissionModeFlag`). - * Every other agent launches bare — its own default — because the phone does not have the - * per-agent approval table's host facts (codex's vocabulary moved between releases, #785). + * [projectMode] is the project's own override (see [permissionMode]). Every other agent + * launches bare — its own default — because the phone does not have the per-agent approval + * table's host facts (codex's vocabulary moved between releases, #785). */ - fun launchCommand(agent: Agent, settings: MirrorSettings?, accountId: String?, cwd: String?): String { + fun launchCommand( + agent: Agent, + settings: MirrorSettings?, + accountId: String?, + cwd: String?, + projectMode: String? = null + ): String = compose(agent, agent.launchCmd, settings, accountId, cwd, projectMode) + + /** + * The resume line for a cold attach, built like the desktop's cold restore (audit A15): in the + * node's own directory, under the node's managed Claude account, with the permission mode. A + * bare `claude --resume ` typed into a relay-created pane in `$HOME`, under the default + * config dir, finds no transcript for a managed account (and, by the transcript path's encoded + * cwd, likely none at all). Null when the agent or id cannot be resumed. + */ + fun resumeLine( + agent: Agent, + sessionId: String, + settings: MirrorSettings?, + accountId: String?, + cwd: String?, + projectMode: String? = null + ): String? = resumeCommand(agent, sessionId)?.let { compose(agent, it, settings, accountId, cwd, projectMode) } + + private fun compose( + agent: Agent, + command: String, + settings: MirrorSettings?, + accountId: String?, + cwd: String?, + projectMode: String? + ): String { val parts = ArrayList() if (!cwd.isNullOrBlank() && SAFE_DIR.matches(cwd)) parts += "cd '${cwd}' &&" val account = accountId?.let { id -> settings?.claudeAccounts?.firstOrNull { it.id == id } } if (agent == Agent.CLAUDE && account != null && SAFE_DIR.matches(account.dir)) { parts += "CLAUDE_CONFIG_DIR='${account.dir}'" } - parts += agent.launchCmd + parts += command if (agent == Agent.CLAUDE) { - val mode = settings?.claudePermissionMode?.takeIf { it in PERMISSION_MODES } ?: "manual" + val mode = permissionMode(settings, projectMode) val emit = when (mode) { "manual" -> false "auto" -> settings?.autoSupported == true diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt index f9fcc3fba0..a45cbd4dfb 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt @@ -38,10 +38,30 @@ data class ProjectInfo( val closed: Boolean, val nodes: List, /** The persisted board, or null when the project has never had one (the desktop's lazy default). */ - val board: KanbanBoard? + val board: KanbanBoard?, + /** The project's own permission mode (project.json `defaultPermissionMode`), overriding the + * global one exactly as the desktop's `resolvePermissionMode` does. Unvalidated here. */ + val defaultPermissionMode: String? = null, + /** The machine-local default Claude account for new sessions in this project. */ + val defaultAccountId: String? = null ) { /** Session nodes — what the phone lists and can attach to. */ val sessions: List get() = nodes.filter { it.kind == NodeKind.TERMINAL } + + /** + * Where [node] runs, as an absolute path: its own cwd, resolved against the project folder when + * it is stored portable (`./sub`, the git-shared form), else the project folder. Null when + * neither is absolute. + */ + fun absoluteCwdOf(node: NodeInfo?): String? { + val own = node?.cwd + val root = cwd?.takeIf { it.startsWith("/") } + return when { + own != null && own.startsWith("/") -> own + own != null && (own == "." || own.startsWith("./")) -> root?.let { it.trimEnd('/') + own.removePrefix(".") } + else -> root + } + } } enum class NodeKind(val wire: String) { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt index 46e2b994fe..d9b27b2f71 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt @@ -57,7 +57,9 @@ object ProjectsParser { sshTarget = sshTarget, closed = p.b("closed") == true, nodes = p.objects("nodes").mapNotNull(::parseNode), - board = p.o("kanban")?.let(::parseBoard) + board = p.o("kanban")?.let(::parseBoard), + defaultPermissionMode = p.s("defaultPermissionMode"), + defaultAccountId = p.s("defaultAccountId") ) } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt index e92bb00c4d..eb9a828185 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt @@ -4,6 +4,9 @@ import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.Launch import dev.nodeterm.protocol.model.ManagedAccount import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.NodeInfo +import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.ProjectInfo import dev.nodeterm.protocol.model.PairedHost import dev.nodeterm.protocol.model.ProjectsParser import dev.nodeterm.protocol.model.SessionBucket @@ -112,4 +115,48 @@ class ModelTest { assertEquals("gemini", Launch.launchCommand(Agent.GEMINI, s, null, "/it's/unsafe"), "a quote-bearing cwd is dropped, not escaped") assertTrue(Regex("^term-[a-z0-9]+-[a-z0-9]{1,16}$").matches(Launch.newNodeId())) } + + private val acctSettings = MirrorSettings("acceptEdits", true, listOf(ManagedAccount("acct-1", "/Users/me/Library/Application Support/node-terminal/claude-accounts/acct-1")), emptyList()) + + @Test + fun `the project's own permission mode wins over the global one, both re-validated (A16)`() { + assertEquals("plan", Launch.permissionMode(acctSettings, "plan")) + assertEquals("acceptEdits", Launch.permissionMode(acctSettings, null)) + assertEquals("acceptEdits", Launch.permissionMode(acctSettings, "constructor"), "an unknown project value falls through") + assertEquals("manual", Launch.permissionMode(null, null)) + assertEquals("claude --permission-mode plan", Launch.launchCommand(Agent.CLAUDE, acctSettings, null, null, "plan")) + // manual in the project = the bare command, even when the global is looser. + assertEquals("claude", Launch.launchCommand(Agent.CLAUDE, acctSettings, null, null, "manual")) + assertEquals("codex", Launch.launchCommand(Agent.CODEX, acctSettings, null, null, "plan"), "only claude gets the flag") + } + + @Test + fun `the project's default account is preselected only while the host still has it (A16)`() { + val p = ProjectInfo("p", "P", null, "/repo", null, false, emptyList(), null, defaultAccountId = "acct-1") + assertEquals("acct-1", Launch.defaultAccount(acctSettings, p)) + assertNull(Launch.defaultAccount(acctSettings, p.copy(defaultAccountId = "gone"))) + assertNull(Launch.defaultAccount(null, p)) + } + + @Test + fun `the resume line runs where the desktop's cold restore would (A15)`() { + assertEquals( + "cd '/repo/sub' && CLAUDE_CONFIG_DIR='/Users/me/Library/Application Support/node-terminal/claude-accounts/acct-1' " + + "claude --resume abc-1 --permission-mode plan", + Launch.resumeLine(Agent.CLAUDE, "abc-1", acctSettings, "acct-1", "/repo/sub", "plan") + ) + assertEquals("cd '/repo' && codex resume abc", Launch.resumeLine(Agent.CODEX, "abc", acctSettings, "acct-1", "/repo")) + assertNull(Launch.resumeLine(Agent.CLAUDE, "abc; rm -rf ~", acctSettings, null, "/repo")) + } + + @Test + fun `portable node cwds resolve against the project folder`() { + val p = ProjectInfo("p", "P", null, "/repo/", null, false, emptyList(), null) + fun n(cwd: String?) = NodeInfo("n", NodeKind.TERMINAL, "t", null, null, null, cwd, null, null, null, null) + assertEquals("/repo/sub", p.absoluteCwdOf(n("./sub"))) + assertEquals("/repo", p.absoluteCwdOf(n("."))) + assertEquals("/abs", p.absoluteCwdOf(n("/abs"))) + assertEquals("/repo/", p.absoluteCwdOf(n(null))) + assertNull(p.copy(cwd = null).absoluteCwdOf(n("./sub"))) + } } From ab1335cf9c804aa58d06b1d8a14875f0f6d02fa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:53:23 +0000 Subject: [PATCH 023/284] fix(relay): type quick answers through the node's session, not a throwaway client (A12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay sendKeys (question digits, the legacy approve/deny keys) attached a new 80x24 tmux client, wrote, and killed it at once. Input that beat the desktop's async attach hit no session and was dropped; input that did not could die with the client before tmux read it — a lone ESC (Deny) almost always, inside tmux's escape-time — while the phone reported success. Desktop (additive): `node.sendKeys {nodeId, keys}` → `{sent}`, served by HostNodeActions.sendKeys, which main wires to PtyManager.backgroundWrite (the painter pty or a tmux control client; nothing is attached). Keys are limited to a short answer (≤16 printable chars, ESC, Enter). A node of an SSH project answers sent:false (background writes do not reach its host). Android: RelayHostConnection.sendKeys uses the verb; sent:false throws so QuickActions opens the session instead of claiming "sent". Against an older desktop it keeps attach-and-write, but only after the pane painted and with the stream held open 400 ms. Tests: host-node-actions (verb, ESC/Enter, sent:false, refusals, not served) and relay interop through the desktop's real handler, including the older-desktop fallback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/protocol/host/QuickActions.kt | 17 ++++-- .../protocol/host/RelayHostConnection.kt | 38 ++++++++++++- .../protocol/src/test/interop/host-fixture.ts | 7 ++- .../dev/nodeterm/protocol/RelayInteropTest.kt | 27 +++++++++- docs/android.md | 1 + src/main/index.ts | 16 ++++-- src/main/remote/host-node-actions.test.ts | 54 +++++++++++++++++++ src/main/remote/host-service.ts | 47 ++++++++++++++++ 8 files changed, 193 insertions(+), 14 deletions(-) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt index 74bc5c7d77..d8a503ddd8 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt @@ -41,8 +41,7 @@ object QuickActions { // while the hook holds it, so keys are wrong. Send the user to the session instead. if (event.pendingId != null) return Result.OPEN_SESSION if (event.agentId != "claude") return Result.OPEN_SESSION - conn.sendKeys(event.nodeId, if (allow) "1" else "\u001b") - return Result.SENT + return typeOrOpen(conn, event, if (allow) "1" else "\u001b") } /** AskUserQuestion: choices are digits on screen (a hook cannot inject an answer value). */ @@ -50,8 +49,18 @@ object QuickActions { if (event.kind != InboxKind.QUESTION || event.multiSelect) return Result.OPEN_SESSION if (optionIndex !in event.options.indices || optionIndex > 8) return Result.OPEN_SESSION if (!stillWaiting(conn, event, AgentState.WAITING)) return Result.ALREADY_HANDLED - conn.sendKeys(event.nodeId, (optionIndex + 1).toString()) - return Result.SENT + return typeOrOpen(conn, event, (optionIndex + 1).toString()) + } + + /** Keys the host could not deliver are not "sent": the prompt is on screen, so open it. A + * [NeedsRelayException] propagates — the caller retries through the relay. */ + private suspend fun typeOrOpen(conn: HostConnection, event: InboxEvent, keys: String): Result = try { + conn.sendKeys(event.nodeId, keys) + Result.SENT + } catch (e: NeedsRelayException) { + throw e + } catch (_: HostException) { + Result.OPEN_SESSION } private suspend fun stillWaiting(conn: HostConnection, event: InboxEvent, expected: AgentState): Boolean { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt index 75d0e9b6b8..c4ba16de5e 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt @@ -18,6 +18,9 @@ import dev.nodeterm.protocol.relay.RelaySocket import dev.nodeterm.protocol.relay.RelaySocketListener import dev.nodeterm.protocol.relay.RpcException import dev.nodeterm.protocol.relay.SnapshotReassembler +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.delay +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -269,15 +272,46 @@ class RelayHostConnection private constructor() : HostConnection, RelaySocketLis } } + /** + * Type a quick answer. `node.sendKeys` types through the node's EXISTING session on the desktop + * (audit A12); `sent:false` means it could not be delivered, which is a [HostException] so the + * caller opens the session instead. A desktop that predates the verb gets the old + * attach-and-write, made less lossy: the keys go only after the pane has painted (proof the + * desktop has attached its client), and the stream stays open well past tmux's escape-time so a + * lone ESC is not killed with the client. + */ override suspend fun sendKeys(nodeId: String, keys: String) { + val body = try { + J.obj(call("node.sendKeys", buildJsonObject { + put("nodeId", nodeId) + put("keys", keys) + })) + } catch (e: HostException) { + if (e.message?.contains("not served") == true || e.message?.startsWith("Unknown method") == true) { + legacySendKeys(nodeId, keys) + return + } + throw e + } + if (body?.b("sent") != true) throw HostException("The answer couldn't be typed into the session. Open it to answer there.") + } + + private suspend fun legacySendKeys(nodeId: String, keys: String) { + val painted = CompletableDeferred() val sink = object : TerminalSink { override fun onPaint(text: String) {} - override fun onOutput(bytes: ByteArray) {} - override fun onExit(code: Int?) {} + override fun onOutput(bytes: ByteArray) { + painted.complete(Unit) + } + override fun onExit(code: Int?) { + painted.complete(Unit) + } } val stream = attach(nodeId, 80, 24, sink) try { + withTimeoutOrNull(3_000) { painted.await() } stream.write(keys) + delay(400) } finally { stream.detach() } diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index 4301d5e75a..06ca197c7b 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -192,7 +192,12 @@ async function runRelay(): Promise { nodeActions: { wake: (nodeId) => (emit({ event: 'wake', nodeId }), true), refresh: (nodeId) => (emit({ event: 'refresh', nodeId }), true), - rename: (nodeId, title) => (emit({ event: 'rename', nodeId, title }), true) + rename: (nodeId, title) => (emit({ event: 'rename', nodeId, title }), true), + // A12. `FIXTURE_NO_SENDKEYS=1` stands for a desktop that predates the verb; a node id + // containing `gone` stands for a session the background write could not reach. + ...(process.env.FIXTURE_NO_SENDKEYS === '1' + ? {} + : { sendKeys: async (nodeId: string, keys: string) => (emit({ event: 'sendKeys', nodeId, keys }), !nodeId.includes('gone')) }) }, kanban: { ensureBoard: async (projectId) => (emit({ event: 'ensureBoard', projectId }), [{ id: 'c1', title: 'To Do', color: '#0a84ff' }]), diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index e47147e57a..7c38512001 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -42,10 +42,10 @@ class RelayInteropTest { @AfterTest fun tearDown() = harnesses.forEach { it.close() } - private fun start(approveAfterMs: Long = 0, rejectAfterMs: Long = -1): InteropHarness = + private fun start(approveAfterMs: Long = 0, rejectAfterMs: Long = -1, extra: Map = emptyMap()): InteropHarness = InteropHarness.start( "relay", - mapOf("FIXTURE_APPROVE_AFTER_MS" to approveAfterMs.toString(), "FIXTURE_REJECT_AFTER_MS" to rejectAfterMs.toString()) + mapOf("FIXTURE_APPROVE_AFTER_MS" to approveAfterMs.toString(), "FIXTURE_REJECT_AFTER_MS" to rejectAfterMs.toString()) + extra ).also { harnesses += it } private fun InteropHarness.str(key: String) = ready[key]!!.jsonPrimitive.content @@ -308,4 +308,27 @@ class RelayInteropTest { assertEquals(QuickActions.Result.EXPIRED, QuickActions.answerApproval(conn, late, allow = false)) } } + + @Test + fun `quick answers are typed through the node's session, not a throwaway client`() = runBlocking { + // A12: node.sendKeys through the desktop's real verb handler. + val h = start() + connect(h).connection.use { conn -> + conn.sendKeys("term-abc-1", "\u001b") + val ev = h.awaitEvent("sendKeys") + assertEquals("term-abc-1", ev.str("nodeId")) + assertEquals("\u001b", ev.str("keys")) + // Not delivered is not "sent": the caller opens the session instead. + assertFailsWith { conn.sendKeys("term-gone-1", "1") } + } + } + + @Test + fun `an older desktop without the verb still gets the keys, after the pane painted`() = runBlocking { + val h = start(extra = mapOf("FIXTURE_NO_SENDKEYS" to "1")) + connect(h).connection.use { conn -> + conn.sendKeys("term-abc-1", "2") + assertEquals("2", h.awaitEvent("write").str("data")) + } + } } diff --git a/docs/android.md b/docs/android.md index 0291dafa97..c36123312a 100644 --- a/docs/android.md +++ b/docs/android.md @@ -55,6 +55,7 @@ describes as the future. The Android client implements what the host actually se | New session | `pty.attach` of a fresh `term-…` id, launch line, then `projects.registerNode` | — | | Answer a held approval | **`approvals.answer`** (new) → `{answered}`, plus `reason: gone\|failed` when not | write `~/.nodeterm/pending/.answer` (prints `gone` when the hold ended) | | Read-ack | **`inbox.ack`** (new) | write `~/.nodeterm/acks/.seen` | +| Quick answer keys (question digits, legacy approve/deny) | **`node.sendKeys {nodeId, keys}`** (new) → `{sent}`, typed through the node's existing session; an older desktop gets attach → wait for paint → write → linger | `tmux send-keys -l` | `resizedFrames` is deliberately not sent on attach, matching iOS: the phone is a size *ceiling* on the shared pty. An `OP.Resized` still shows a "sized to another screen · fit this screen" hint. diff --git a/src/main/index.ts b/src/main/index.ts index 66e3870861..29ed2f2522 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4026,13 +4026,15 @@ app.whenReady().then(async () => { return { wake: (nodeId: string) => deliver(IPC.agentWake, nodeId), refresh: (nodeId: string) => deliver(IPC.agentRefreshNode, nodeId), - rename: (nodeId: string, title: string) => deliver(IPC.agentRenameNode, { nodeId, title }) + rename: (nodeId: string, title: string) => deliver(IPC.agentRenameNode, { nodeId, title }), + // A quick answer typed WITHOUT a throwaway tmux client (audit A12): the painter pty or a + // control client, via the existing background write. A node of an SSH project is refused + // (its tmux is on the host; background writes do not reach it yet) — the phone then opens + // the session instead of guessing. + sendKeys: async (nodeId: string, keys: string) => + workspaceStore.sshProjectIdForNode(nodeId) ? false : ptyManager.backgroundWrite(nodeId, keys) } })(), - // A relay phone's Inbox actions (`approvals.answer` / `inbox.ack`): the SAME answer writer the - // canvas Approve/Deny button uses, and the SAME ack pair the `~/.nodeterm/acks` sweep runs for a - // phone on direct SSH — `ackDone` (resolve the done event, dismiss other phones' activities) - // plus the desktop unread clear, WITHOUT a re-ack (the external-clear channel). // A phone opening a node of an SSH project reaches THAT host's tmux over the project's master, // or is told why not — never a phantom local session (audit A09). remoteNodes: { @@ -4045,6 +4047,10 @@ app.whenReady().then(async () => { return sshRemote ? { where, sshRemote } : { where } } }, + // A relay phone's Inbox actions (`approvals.answer` / `inbox.ack`): the SAME answer writer the + // canvas Approve/Deny button uses, and the SAME ack pair the `~/.nodeterm/acks` sweep runs for a + // phone on direct SSH — `ackDone` (resolve the done event, dismiss other phones' activities) + // plus the desktop unread clear, WITHOUT a re-ack (the external-clear channel). inbox: { answerPermission, ackRead: (nodeId: string) => { diff --git a/src/main/remote/host-node-actions.test.ts b/src/main/remote/host-node-actions.test.ts index c485c56826..bc3269a3da 100644 --- a/src/main/remote/host-node-actions.test.ts +++ b/src/main/remote/host-node-actions.test.ts @@ -162,3 +162,57 @@ describe('delivery is the answer, never assumed', () => { ]) }) }) + +// Audit A12: quick answers used to go through a throwaway attach + write + kill, which dropped +// input that beat the async attach and killed the client before tmux read an ESC. `node.sendKeys` +// types through the node's existing session instead. +describe('node.sendKeys', () => { + const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + + it('types a short answer through the injected writer and reports it', async () => { + const sendKeys = vi.fn(async () => true) + const { handlers, responses } = makeFakes({ sendKeys }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'term-a-1', keys: '2' } }) + await flush() + expect(sendKeys).toHaveBeenCalledWith('term-a-1', '2') + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: true } }) + }) + + it('accepts a lone Esc (Deny) and Enter', async () => { + const sendKeys = vi.fn(async () => true) + const { handlers, responses } = makeFakes({ sendKeys }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'n', keys: '\u001b' } }) + handlers.onRpc({ id: '2', method: 'node.sendKeys', params: { nodeId: 'n', keys: '\r' } }) + await flush() + expect(responses.map((r) => r.ok)).toEqual([true, true]) + }) + + it('answers {sent:false} — not an error — when nothing was delivered', async () => { + const { handlers, responses } = makeFakes({ sendKeys: async () => false }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'n', keys: '1' } }) + await flush() + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: false } }) + }) + + it.each([ + ['an empty answer', ''], + ['a long input', 'x'.repeat(17)], + ['other control bytes', '\u0003'], + ['a newline', 'a\nb'] + ])('refuses %s before the writer is asked', async (_label, keys) => { + const sendKeys = vi.fn(async () => true) + const { handlers, responses } = makeFakes({ sendKeys }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'n', keys } }) + await flush() + expect(sendKeys).not.toHaveBeenCalled() + expect(responses[0].ok).toBe(false) + }) + + it('is "not served" without the injection', async () => { + const { handlers, responses } = makeFakes() + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'n', keys: '1' } }) + await flush() + expect(responses[0].ok).toBe(false) + expect((responses[0].body as { message: string }).message).toMatch(/not served/) + }) +}) diff --git a/src/main/remote/host-service.ts b/src/main/remote/host-service.ts index f8102bd31b..821812eeab 100644 --- a/src/main/remote/host-service.ts +++ b/src/main/remote/host-service.ts @@ -151,8 +151,22 @@ export interface HostNodeActions { refresh(nodeId: string): boolean /** Rename a node through the renderer's `renameSession` funnel (title pre-sanitized here). */ rename(nodeId: string, title: string): boolean + /** + * Type a short quick answer (`node.sendKeys`) into the node's session without attaching a new + * client. Resolves whether it was delivered. Optional: absent ⇒ "not served", and the phone falls + * back to its old attach-and-write. + */ + sendKeys?(nodeId: string, keys: string): Promise } +/** + * What `node.sendKeys` accepts: a SHORT quick answer — a digit or a few printable characters, ESC, + * Enter. It exists for the Inbox's question/approval shortcuts, not as a general input channel + * (`pty.attach` is that), so anything longer or carrying other control bytes is refused. + */ +// eslint-disable-next-line no-control-regex -- the allowed controls are exactly ESC and CR +export const SEND_KEYS_RE = /^[\x1b\r\x20-\x7e]{1,16}$/ + /** * The kanban board writes the phone may ask this host to make on its behalf (`projects.ensureBoard` * / `projects.setCardColumn`). Both land in `WorkspaceStore`, which owns the read-modify-write and @@ -862,6 +876,36 @@ export function createHostHandlers( * "delivered to a live desktop window", never "the action happened" — that contract is in the * verb docs the iOS client mirrors. */ + /** + * `node.sendKeys {nodeId, keys}` → `{sent}`. The phone's Inbox answers used to attach a throwaway + * 80x24 tmux client, write, and kill it at once: an Input that beat the async attach was dropped + * (no session yet), and one that did not could die with the client before tmux read it — a lone + * ESC (Deny) nearly always, inside tmux's escape-time (audit A12). This types through the node's + * existing session instead. `sent:false` is an ANSWER (not delivered: open the session). + */ + function handleSendKeys(req: RpcRequest): void { + if (!nodeActions?.sendKeys) { + socket.respond(req.id, false, { message: `${req.method} is not served on this host.` }) + return + } + const p = asRecord(req.params) + const nodeId = str(p.nodeId) + // eslint-disable-next-line no-control-regex -- refusing control chars is the point + if (!nodeId || nodeId.length > REF_MAX_LEN || /[\x00-\x1f\x7f-\x9f]/.test(nodeId)) { + socket.respond(req.id, false, { message: 'Invalid node id.' }) + return + } + const keys = str(p.keys) ?? '' + if (!SEND_KEYS_RE.test(keys)) { + socket.respond(req.id, false, { message: 'node.sendKeys takes a short answer: up to 16 printable characters, Esc or Enter.' }) + return + } + void nodeActions + .sendKeys(nodeId, keys) + .then((sent) => socket.respond(req.id, true, { sent })) + .catch(() => socket.respond(req.id, true, { sent: false })) + } + function handleNodeAction(req: RpcRequest): void { if (!nodeActions) { socket.respond(req.id, false, { message: `${req.method} is not served on this host.` }) @@ -953,6 +997,9 @@ export function createHostHandlers( case 'node.rename': handleNodeAction(req) break + case 'node.sendKeys': + handleSendKeys(req) + break case 'approvals.answer': case 'inbox.ack': handleInbox(req) From d13d6fc102c4333198d80bdff54c7b6a4807841a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:53:23 +0000 Subject: [PATCH 024/284] docs(android): mark A12, A14, A15, A16 fixed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index 4ff987efe3..c80183142d 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -25,11 +25,11 @@ the audit's proposal, the handover's progress log says how and why. | [A09](#a09) | medium | BLOCK | small | protocol/bug | ✅ fixed in `726271a,1cdd2f0` · Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine | | [A10](#a10) | medium | BLOCK | small | ci-docs/risk | ✅ fixed in `fcda932` · Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | | [A11](#a11) | medium | | small | build/bug | ✅ fixed in `de1eded` · Tapping an Inbox notification while the app is in the background does not open that computer | -| [A12](#a12) | medium | | medium | protocol/bug | Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | +| [A12](#a12) | medium | | medium | protocol/bug | ✅ fixed in `ab1335c` · Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | | [A13](#a13) | medium | | medium | protocol/bug | Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | -| [A14](#a14) | medium | | small | protocol/bug | New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned | -| [A15](#a15) | medium | | small | protocol/bug | The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found' | -| [A16](#a16) | medium | | small | protocol/gap | The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | +| [A14](#a14) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned | +| [A15](#a15) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found' | +| [A16](#a16) | medium | | small | protocol/gap | ✅ fixed in `c58ad65` · The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | | [A17](#a17) | medium | | small | protocol/risk | ✅ fixed in `3d36d60` · The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against | | [A18](#a18) | medium | | small | runtime/bug | No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely | | [A19](#a19) | medium | | small | runtime/bug | ✅ fixed in `de1eded` · Tapping an inbox notification while the activity is alive ignores the target computer | From a622e718df19508b409752d6bfa2de6c379b341f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:54:44 +0000 Subject: [PATCH 025/284] fix(android): notice a silently dead SSH peer and fall back (A31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A laptop asleep, or a desktop whose IP/VPN changed, left the phone on "On your network" for many minutes: sshj's default HEARTBEAT keepalive expects no reply so it never notices a dead peer, run() read the channel with no deadline, and a failed command on a transport that still claimed to be connected was reported as an ordinary answer, so nothing fell back. - KEEP_ALIVE provider (keepalive@openssh.com, want-reply) every 15 s, max 3 misses: the transport dies, the disconnect listener fires, and the owner reconnects or falls back to the relay. - run() has a real deadline: past it the transport is torn down (which also wakes the blocked read) and the drop is reported. - A command that cannot be run at all drops the connection and reports it, whatever isConnected claims. - HostScreen shows a listing error while connected (it was invisible). Tests: a hung command stands in for the vanished peer — the call returns within the deadline and onClosed fires. The keepalive miss path itself is not exercised (it needs a peer that stops answering at the TCP level). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/HostScreen.kt | 11 +++++ .../protocol/ssh/SshHostConnection.kt | 41 +++++++++++++++++-- .../dev/nodeterm/protocol/SshTransportTest.kt | 14 +++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index fe1d46fdc1..ce2a5c9033 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -106,6 +106,17 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { ConnectionBanner(state, session) + // A listing that failed on a connection that is still up (e.g. nodeterm's data not found + // over SSH) used to be invisible: the screen just stayed empty (audit A31). + val listError by session.lastError.collectAsState() + val err = listError + if (state is ConnState.Connected && err != null) { + Text( + err, + Modifier.fillMaxWidth().background(NtColors.attention.copy(alpha = 0.12f)).padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) + } TabRow(selectedTabIndex = tab) { Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Sessions") }) Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Board") }) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index 8a920af6ec..72761ce801 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -29,6 +29,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import net.schmizz.keepalive.KeepAliveProvider +import net.schmizz.keepalive.KeepAliveRunner import net.schmizz.sshj.DefaultConfig import net.schmizz.sshj.SSHClient import net.schmizz.sshj.common.Buffer @@ -89,18 +91,35 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos * never as a raw sshj exception a caller would have to know about. */ internal fun run(script: String, timeoutSec: Long = 20): Pair { + // A REAL deadline (audit A31): the read below waits on the channel with no timeout of its + // own, so a peer that vanished mid-command (laptop asleep, IP or VPN change) blocked it for + // as long as TCP took to give up — ~15 minutes. When the deadline passes, the connection is + // treated as dead: the transport is torn down (which also wakes the blocked read) and the + // drop is reported, so Auto falls back to the relay instead of sitting on "On your network". + var timedOut = false + val deadline = WATCHDOG.schedule({ + timedOut = true + disconnectQuietly() + }, timeoutSec, TimeUnit.SECONDS) try { client.startSession().use { session -> val cmd = session.exec("/bin/sh -c " + SshScripts.q(script)) val out = cmd.inputStream.readBytes() - cmd.join(timeoutSec, TimeUnit.SECONDS) + cmd.join(5, TimeUnit.SECONDS) + if (timedOut) throw java.util.concurrent.TimeoutException("no answer within ${timeoutSec}s") return cmd.exitStatus to String(out, Charsets.UTF_8) } } catch (e: HostException) { throw e } catch (e: Exception) { - if (!isConnected) fireClosed(e.message) + // A command that cannot even be run (the channel would not open, the transport timed out, + // the deadline passed) says the CONNECTION is unusable, whatever `isConnected` claims: + // drop it and report the drop, so the owner reconnects — or falls back to the relay. + disconnectQuietly() + fireClosed(e.message ?: e.javaClass.simpleName) throw HostException("The SSH connection failed: ${e.message ?: e.javaClass.simpleName}") + } finally { + deadline.cancel(false) } } @@ -424,6 +443,15 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } companion object { + /** A dead peer is noticed within about interval × missed (≈45 s). */ + const val KEEPALIVE_INTERVAL_SEC = 15 + const val KEEPALIVE_MAX_MISSED = 3 + + /** One daemon timer for every [run] deadline. */ + private val WATCHDOG = java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "nodeterm-ssh-watchdog").apply { isDaemon = true } + } + const val NO_USER_DATA = "nodeterm's data wasn't found on this computer over SSH (looked for " + "~/Library/Application Support/node-terminal and ~/.config/node-terminal). Open nodeterm on the computer " + "once, or connect through the relay." @@ -444,7 +472,11 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos connectTimeoutMs: Int = 8_000, socketFactory: SocketFactory? = null ): SshHostConnection { - val client = SSHClient(DefaultConfig()) + // KEEP_ALIVE, not sshj's default HEARTBEAT: a heartbeat is an SSH_MSG_IGNORE that expects + // no reply, so it never notices a dead peer. keepalive@openssh.com wants a reply and + // kills the transport after [KEEPALIVE_MAX_MISSED] misses, which fires the disconnect + // listener below → onClosed → the owner reconnects or falls back (audit A31). + val client = SSHClient(DefaultConfig().apply { keepAliveProvider = KeepAliveProvider.KEEP_ALIVE }) if (socketFactory != null) client.socketFactory = socketFactory var mismatch: HostKeyChangedException? = null client.addHostKeyVerifier(object : HostKeyVerifier { @@ -476,7 +508,8 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos override fun getPublic(): PublicKey = kp.public override fun getType(): KeyType = KeyType.ED25519 }) - client.connection.keepAlive.keepAliveInterval = 20 + client.connection.keepAlive.keepAliveInterval = KEEPALIVE_INTERVAL_SEC + (client.connection.keepAlive as? KeepAliveRunner)?.maxAliveCount = KEEPALIVE_MAX_MISSED } catch (e: Exception) { runCatching { client.disconnect() } if (client.isConnected) runCatching { client.socket?.close() } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index 4cbaf6a893..aa0db4e5b0 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -373,6 +373,20 @@ class SshTransportTest { } } + @Test + fun `a command that never answers drops the connection instead of hanging`() = runBlocking { + // A31: the read had no deadline, so a peer that vanished mid-command blocked it for as long + // as TCP took to give up. A hung command stands in for the vanished peer here. + val conn = connect() + val closed = java.util.concurrent.CountDownLatch(1) + conn.setOnClosed { closed.countDown() } + val t0 = System.currentTimeMillis() + assertFailsWith { conn.run("sleep 30", timeoutSec = 1) } + assertTrue(System.currentTimeMillis() - t0 < 10_000, "the deadline bounded the call") + assertTrue(closed.await(5, TimeUnit.SECONDS), "the drop was reported, so the owner can fall back") + conn.close() + } + @Test fun `a session that is not running is never created over SSH`() = runBlocking { // A08: `new-session -A` over SSH created the desktop's session with no hook env. Now the From 10de4b971a67646456659167feb7e2eee37deeb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:55:35 +0000 Subject: [PATCH 026/284] fix(android): stop polling and detach terminals while the app is in the background (A18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watching (the 8 s poll) and the terminal's relay stream were tied to composition, not to the activity being started, so a backgrounded app kept polling and kept its stream open for as long as the process ran — and the desktop kept treating the session as watched (Eco shield, the phone's size as a ceiling). HostScreen and TerminalScreen now watch inside LifecycleStartEffect. The terminal detaches on stop (keeping its WebView, which is paused) and reattaches on start; its own detach is not mistaken for a dropped connection, and nothing attaches while stopped. The offline type-check gained a stub for LifecycleStartEffect (Google Maven only). Not device-verified. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/HostScreen.kt | 8 +++-- .../nodeterm/android/ui/TerminalController.kt | 31 +++++++++++++++++-- .../dev/nodeterm/android/ui/TerminalScreen.kt | 14 +++++++-- android/tools/typecheck/README.md | 2 +- .../androidx/lifecycle/compose/Stubs.kt | 9 ++++++ 5 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 android/tools/typecheck/stubs/kotlin/androidx/lifecycle/compose/Stubs.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index ce2a5c9033..8727308ef2 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -62,9 +62,11 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { var tab by rememberSaveable { mutableIntStateOf(initialTab) } var newSession by remember { mutableStateOf(false) } - DisposableEffect(hostId) { + // Watch (the 8 s poll) only while the screen is STARTED: a backgrounded app used to keep + // polling, and keep its relay stream open, for as long as the process ran (audit A18). + LifecycleStartEffect(hostId) { session.startWatching() - onDispose { session.stopWatching() } + onStopOrDispose { session.stopWatching() } } val needsYou = snapshot.status?.inbox?.events?.count { it.actionable } ?: 0 diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 0fbee8fdd8..02a8a995e7 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -119,6 +119,8 @@ class TerminalController( override fun onExit(code: Int?) { main.post { + // Our own detach on ON_STOP ends the stream too; that is not a drop to recover from. + if (stopped) return@post stream = null if (code == null && !disposed && autoReattach()) { state = TermState.Ended("Disconnected. Reconnecting…") @@ -147,7 +149,7 @@ class TerminalController( rows = r sizedElsewhere = null val s = stream - if (s != null) s.resize(c, r) else if (attachJob == null && state == TermState.Connecting) attach() + if (s != null) s.resize(c, r) else if (!stopped && attachJob == null && state == TermState.Connecting) attach() } } @@ -251,7 +253,7 @@ class TerminalController( } fun attach() { - if (disposed) return + if (disposed || stopped) return state = TermState.Connecting resumeOffer = null attachJob = graph.scope.launch { @@ -266,7 +268,7 @@ class TerminalController( val c = if (cols > 0) cols else 80 val r = if (rows > 0) rows else 24 val s = conn.attach(nodeId, c, r, sink) - if (disposed) { + if (disposed || stopped) { s.detach() return@launch } @@ -384,6 +386,29 @@ class TerminalController( js("nt.setFontSize(${graph.hosts.fontSize})") } + /** The screen went to the background: detach (the session keeps running on the computer). */ + private var stopped = false + + fun onStop() { + if (disposed || stopped) return + stopped = true + attachJob?.cancel() + attachJob = null + val s = stream + stream = null + if (s != null) graph.scope.launch { runCatching { s.detach() } } + webView?.onPause() + state = TermState.Connecting + } + + /** Back in the foreground: reattach where it left off (a first start is the normal attach). */ + fun onStart() { + if (disposed || !stopped) return + stopped = false + webView?.onResume() + if (stream == null && attachJob == null) attach() + } + fun dispose() { disposed = true val s = stream diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 115c9ad10a..b08c44e827 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,13 +55,20 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String val controller = remember(hostId, nodeId) { TerminalController(graph, session, nodeId) } var draft by remember { mutableStateOf("") } - DisposableEffect(controller) { + // Attached and watching only while the screen is STARTED (audit A18): in the background the + // relay stream kept the desktop treating the session as watched (Eco shield, and the phone's + // size as a ceiling) and kept the radio busy. Stop detaches; start reattaches. + LifecycleStartEffect(controller) { session.startWatching() - onDispose { - controller.dispose() + controller.onStart() + onStopOrDispose { + controller.onStop() session.stopWatching() } } + DisposableEffect(controller) { + onDispose { controller.dispose() } + } Scaffold( topBar = { diff --git a/android/tools/typecheck/README.md b/android/tools/typecheck/README.md index 25c0cea240..e4e60aa8f8 100644 --- a/android/tools/typecheck/README.md +++ b/android/tools/typecheck/README.md @@ -7,7 +7,7 @@ plain Kotlin/JVM against: - `org.robolectric:android-all` (the Android framework classes, from Maven Central), - Compose Multiplatform **desktop** artifacts (the same Compose APIs, from Maven Central), and - hand-written stubs in `stubs/` for the androidx / zxing classes that exist only on Google Maven - (activity-compose, work, core, the zxing scanner contract, and `R`). + (activity-compose, lifecycle-runtime-compose, work, core, the zxing scanner contract, and `R`). ```bash cd android/tools/typecheck diff --git a/android/tools/typecheck/stubs/kotlin/androidx/lifecycle/compose/Stubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/lifecycle/compose/Stubs.kt new file mode 100644 index 0000000000..acef0c9388 --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/lifecycle/compose/Stubs.kt @@ -0,0 +1,9 @@ +package androidx.lifecycle.compose +import androidx.compose.runtime.Composable +// lifecycle-runtime-compose's start/stop effect, reduced to the shape the app calls (the real +// onStopOrDispose block has a LifecycleOwner receiver; a plain lambda compiles against both). +class LifecycleStopOrDisposeEffectResult +open class LifecycleStartStopEffectScope { + fun onStopOrDispose(onStopOrDisposeEffect: () -> Unit): LifecycleStopOrDisposeEffectResult = LifecycleStopOrDisposeEffectResult() +} +@Composable fun LifecycleStartEffect(key1: Any?, effects: LifecycleStartStopEffectScope.() -> LifecycleStopOrDisposeEffectResult) {} From 345efee219b1984df808d0fb6dc80679dae93712 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:55:41 +0000 Subject: [PATCH 027/284] docs(android): mark A31 and A18 fixed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index c80183142d..4ecc5da12f 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -31,7 +31,7 @@ the audit's proposal, the handover's progress log says how and why. | [A15](#a15) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found' | | [A16](#a16) | medium | | small | protocol/gap | ✅ fixed in `c58ad65` · The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | | [A17](#a17) | medium | | small | protocol/risk | ✅ fixed in `3d36d60` · The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against | -| [A18](#a18) | medium | | small | runtime/bug | No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely | +| [A18](#a18) | medium | | small | runtime/bug | ✅ fixed in `10de4b9` · No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely | | [A19](#a19) | medium | | small | runtime/bug | ✅ fixed in `de1eded` · Tapping an inbox notification while the activity is alive ignores the target computer | | [A20](#a20) | medium | | small | runtime/bug | ✅ fixed in `d199f03` · Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure | | [A21](#a21) | medium | | small | runtime/bug | A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On | @@ -44,7 +44,7 @@ the audit's proposal, the handover's progress log says how and why. | [A28](#a28) | medium | | small | parity/gap | ✅ fixed in `726271a,1cdd2f0` · SSH-project sessions over direct SSH are attached, approved and resumed on the wrong machine | | [A29](#a29) | medium | | medium | parity/gap | No source-control screen, although the protocol layer already implements the git verbs iOS uses | | [A30](#a30) | medium | | small | critic/bug | ✅ fixed in `3d36d60` · Pressing Deny on the desktop is not respected: the phone re-dials about 8 s later and the SAS approval dialog reappears | -| [A31](#a31) | medium | | small | critic/bug | A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown | +| [A31](#a31) | medium | | small | critic/bug | ✅ fixed in `a622e71` · A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown | | [A32](#a32) | medium | | medium | critic/gap | No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm | | [A33](#a33) | medium | | medium | critic/bug | On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it | | [A34](#a34) | medium | | small | critic/bug | ✅ fixed in `2a273a7` · The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter | From daf15d36b9c8c073a79e7076b1ce0dc4fe832e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:56:24 +0000 Subject: [PATCH 028/284] fix(android): the notifications switch reflects what the phone will show (A21) The switch read On while Android had notifications blocked for the app (a denied POST_NOTIFICATIONS on 13+, or blocked in system settings on any version); nothing arrived, and the worker still dialed every computer every 15 minutes. The permission was also re-asked on every rotation until Android stopped showing the dialog, after which nothing recovered. - InboxNotifier.canPost = NotificationManagerCompat.areNotificationsEnabled. - Settings shows On only when the preference is on AND the phone can post (re-read each time the screen starts); switching it on asks for the permission, or opens the app's notification settings when the dialog is no longer available, and a blocked state says so with "Open settings". - MainActivity asks on a fresh start only. - The worker does nothing when nothing could be shown. Type-checked offline (stub gained areNotificationsEnabled); not device-verified. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/MainActivity.kt | 5 +- .../nodeterm/android/notify/InboxNotifier.kt | 6 +++ .../dev/nodeterm/android/ui/SettingsScreen.kt | 51 ++++++++++++++++++- .../core/app/NotificationManagerCompat.java | 1 + 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt index 71aa682af5..504b2a562d 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -86,7 +86,10 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val graph = NodetermApp.graph(this) - if (Build.VERSION.SDK_INT >= 33 && graph.hosts.notificationsEnabled && + // Asked on a fresh start only — not on every rotation or recreation (audit A21). Later asks + // come from the Settings switch, which also offers the system settings once Android stops + // showing the dialog. + if (savedInstanceState == null && Build.VERSION.SDK_INT >= 33 && graph.hosts.notificationsEnabled && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt index 5c14041f40..3ef44c0775 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -43,6 +43,10 @@ object InboxNotifier { private const val CH_DONE = "done" private const val WORK = "nodeterm.inbox" + /** This phone will actually SHOW our notifications: the app-level switch in system settings, + * which on Android 13+ also reflects the runtime POST_NOTIFICATIONS permission (audit A21). */ + fun canPost(context: Context): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() + fun createChannels(context: Context) { if (Build.VERSION.SDK_INT < 26) return val nm = context.getSystemService(NotificationManager::class.java) ?: return @@ -119,6 +123,8 @@ object InboxNotifier { class InboxWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val graph = NodetermApp.graph(applicationContext) + // Nothing could be shown: do not dial every computer every 15 minutes for it (audit A21). + if (!graph.hosts.notificationsEnabled || !InboxNotifier.canPost(applicationContext)) return Result.success() for (host in graph.hosts.hosts.value) { val session = graph.connections.session(host.id) val watched = session.isWatched diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt index 8c56326ac8..133c125f55 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -1,5 +1,13 @@ package dev.nodeterm.android.ui +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -49,6 +57,25 @@ fun SettingsScreen(nav: Navigator) { var name by remember { mutableStateOf(graph.hosts.deviceName) } var apiBase by remember { mutableStateOf(graph.hosts.apiBase) } var notify by remember { mutableStateOf(graph.hosts.notificationsEnabled) } + // Whether the phone will actually SHOW them, re-read whenever the screen starts (the user may + // have come back from the system settings). The switch used to read On while nothing could + // arrive (audit A21). + var canPost by remember { mutableStateOf(InboxNotifier.canPost(context)) } + LifecycleStartEffect(Unit) { + canPost = InboxNotifier.canPost(context) + onStopOrDispose { } + } + val askPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + canPost = InboxNotifier.canPost(context) + } + fun openNotificationSettings() { + runCatching { + context.startActivity( + Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName) + ) + } + } var routes by remember { mutableStateOf(hosts.associate { it.id to graph.hosts.route(it.id) }) } Scaffold( @@ -86,12 +113,34 @@ fun SettingsScreen(nav: Navigator) { color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = notify, onCheckedChange = { + Switch(checked = notify && canPost, onCheckedChange = { notify = it graph.hosts.notificationsEnabled = it InboxNotifier.schedule(context, it) + if (it && !canPost) { + // Ask once through the system dialog; once Android stops showing it (after + // two denials) only the system settings can turn them back on. + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + askPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + openNotificationSettings() + } + } }) } + if (notify && !canPost) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Notifications are turned off for nodeterm on this phone.", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = { openNotificationSettings() }) { Text("Open settings") } + } + } if (hosts.isNotEmpty()) { HorizontalDivider() diff --git a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java index e75f0c71f6..fd0e2bb25d 100644 --- a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java +++ b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java @@ -4,4 +4,5 @@ public final class NotificationManagerCompat { public static NotificationManagerCompat from(Context context) { return null; } public void notify(int id, Notification notification) {} + public boolean areNotificationsEnabled() { return true; } } From ade74283c8eef315cbc7c4cc6c5a90781d61bdfd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:56:59 +0000 Subject: [PATCH 029/284] fix(android): keep the back stack across recreation; apply the launch intent once (A22) The Navigator was held in plain `remember`, so a density, font-scale or locale change (not covered by configChanges) or process death dropped the user back on the computers list, and onCreate re-applied the launch intent (the pairing link or notification) each time. - The stack is rememberSaveable with a Saver (each route a JSON array of strings). A pairing route is not restored (its code is single-use), and routes naming a computer forgotten meanwhile are dropped. - The launch intent is applied only when savedInstanceState is null. Type-checked offline; not device-verified. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/MainActivity.kt | 75 +++++++++++++++++-- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt index 504b2a562d..f2f0ab3ce6 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -16,7 +16,12 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import androidx.core.content.ContextCompat import dev.nodeterm.android.ui.HostScreen import dev.nodeterm.android.ui.HostsScreen @@ -34,8 +39,10 @@ sealed interface Route { data class Terminal(val hostId: String, val nodeId: String, val title: String) : Route } -class Navigator(initial: Route) { - val stack = mutableStateListOf(initial) +class Navigator(initial: List) { + constructor(initial: Route) : this(listOf(initial)) + + val stack = mutableStateListOf().apply { addAll(initial.ifEmpty { listOf(Route.Hosts) }) } val current: Route get() = stack.last() fun push(route: Route) { @@ -52,6 +59,44 @@ class Navigator(initial: Route) { stack.clear() stack.add(route) } + + companion object { + /** + * Saves the back stack across activity recreation (a density, font-scale or locale change, + * or process death) — it used to reset to the computers list (audit A22). Each route is a + * JSON array of strings; an entry that no longer decodes is dropped, never guessed. + */ + val Saver: Saver = Saver( + save = { nav -> JsonArray(nav.stack.map { encode(it) }).toString() }, + restore = { raw -> + val routes = runCatching { (Json.parseToJsonElement(raw) as JsonArray).mapNotNull { decode(it) } }.getOrNull() + Navigator(routes ?: listOf(Route.Hosts)) + } + ) + + private fun encode(r: Route): JsonArray = JsonArray( + when (r) { + Route.Hosts -> listOf("hosts") + is Route.PairHost -> listOfNotNull("pair", r.code) + Route.Settings -> listOf("settings") + is Route.Host -> listOf("host", r.hostId, r.tab.toString()) + is Route.Terminal -> listOf("terminal", r.hostId, r.nodeId, r.title) + }.map { JsonPrimitive(it) } + ) + + private fun decode(e: JsonElement): Route? { + val parts = (e as? JsonArray)?.map { (it as? JsonPrimitive)?.content ?: return null } ?: return null + return when (parts.firstOrNull()) { + "hosts" -> Route.Hosts + // A pairing code is single-use: coming back to it would only fail. Drop it. + "pair" -> null + "settings" -> Route.Settings + "host" -> parts.getOrNull(1)?.let { Route.Host(it, parts.getOrNull(2)?.toIntOrNull() ?: 0) } + "terminal" -> if (parts.size == 4) Route.Terminal(parts[1], parts[2], parts[3]) else null + else -> null + } + } + } } class MainActivity : ComponentActivity() { @@ -94,15 +139,33 @@ class MainActivity : ComponentActivity() { ) { notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } - val openHost = intent?.getStringExtra(EXTRA_HOST_ID) - takePairLink(intent) + // The launch intent (a notification tap, a pairing link) is applied on a FRESH start only: on a + // recreation the saved back stack already reflects it, and re-applying it would push the + // same screen again (audit A22). + val fresh = savedInstanceState == null + val openHost = if (fresh) intent?.getStringExtra(EXTRA_HOST_ID) else null + if (fresh) takePairLink(intent) setContent { NodetermTheme { - val nav = remember { + val nav = rememberSaveable(saver = Navigator.Saver) { Navigator(Route.Hosts).also { n -> if (openHost != null && graph.hosts.get(openHost) != null) n.push(Route.Host(openHost, tab = 2)) } } + // A restored stack may name a computer that was forgotten meanwhile. + LaunchedEffect(Unit) { + val known = nav.stack.filter { r -> + when (r) { + is Route.Host -> graph.hosts.get(r.hostId) != null + is Route.Terminal -> graph.hosts.get(r.hostId) != null + else -> true + } + } + if (known.size != nav.stack.size) { + nav.stack.clear() + nav.stack.addAll(known.ifEmpty { listOf(Route.Hosts) }) + } + } val hostTap = incomingHost LaunchedEffect(hostTap) { if (hostTap != null) { From 68d09250e4e1ac347a95032f12adc1ba45c46448 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:57:16 +0000 Subject: [PATCH 030/284] fix(android): a takeover by another screen reattaches instead of reading as "ended" (A13, phone side) Registering a phone-started node makes the desktop mount it and attach its own client with `-D`, which detaches the phone's; the phone then showed "The session ended (exit 0)". An exit 0 is now checked against the listing: if the session is still live, the terminal says another screen took over and reattaches (within the same bounded auto-reattach as A36); a session that really ended still says so. The desktop root cause (a renderer client attaching with -D over a live relay-served client) is not changed here; see the handover. Type-checked offline; not device-verified. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/TerminalController.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 02a8a995e7..c68fdddd82 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -122,8 +122,11 @@ class TerminalController( // Our own detach on ON_STOP ends the stream too; that is not a drop to recover from. if (stopped) return@post stream = null - if (code == null && !disposed && autoReattach()) { - state = TermState.Ended("Disconnected. Reconnecting…") + // exit 0 with the session still running = another client attached with -D and + // detached us (the desktop mounting a phone-started node does this) — audit A13. + // It is checked, not assumed: a killed session also exits 0. + if ((code == null || code == 0) && !disposed && autoReattach(requireLive = code == 0)) { + state = TermState.Ended(if (code == null) "Disconnected. Reconnecting…" else "Another screen took over this session. Reattaching…") } else { state = TermState.Ended(if (code == null) "Disconnected." else "The session ended (exit $code).") } @@ -226,7 +229,7 @@ class TerminalController( * lived a minute resets the count), and only while this screen is showing. Returns false when * out of tries. */ - private fun autoReattach(): Boolean { + private fun autoReattach(requireLive: Boolean = false): Boolean { if (System.currentTimeMillis() - attachedAt > 60_000) autoReattaches = 0 if (autoReattaches >= 3) return false autoReattaches++ @@ -234,7 +237,11 @@ class TerminalController( delay(1_500L * autoReattaches) // A re-list notices a dead SSH transport (and drops it) before we ask for a connection, // so the attach below does not get the stale one back. - if (!useRelay) session.refreshNow() + if (!useRelay || requireLive) session.refreshNow() + if (requireLive && !session.snapshot.value.isLive(nodeId)) { + main.post { if (!disposed && stream == null) state = TermState.Ended("The session ended (exit 0).") } + return@launch + } val up = if (useRelay) true else withTimeoutOrNull(120_000) { session.state.first { it is ConnState.Connected } } != null main.post { if (disposed || stream != null || attachJob != null) return@post From 0db0b6e9325050ee7810b4a4c5685d420c534936 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 11:59:59 +0000 Subject: [PATCH 031/284] fix(relay): the desktop creates a phone-started session in its project (A33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session the phone starts was created by the relay pty.attach with only {cols, rows}: in the user's home folder, under the default account. The phone steered it through its launch line (`cd '' &&`, `CLAUDE_CONFIG_DIR=…`), which is POSIX shell, so on a Windows host — relay-only, the one platform that always takes this path — the agent started in the home folder and the chosen account was silently dropped while still being registered on the node. Desktop (additive): pty.attach accepts optional projectId / accountId / agentId. HostNewSessions (wired in main) resolves them from the host's own registry and settings — a local folder project only, a local logged-in managed Claude account only, a builtin agent id only — and they apply only when this attach creates the session. An older phone sends nothing and gets exactly the old behaviour. Android: HostConnection.attach takes an optional NewSessionHint; the terminal passes it for a session it is starting (PendingLaunches.peek). SSH ignores it (it never creates sessions). Tests: remote-security (applied on create, not on a join, nothing for an unknown project) and a relay interop test through the desktop's real handler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/SessionsTab.kt | 3 ++ .../nodeterm/android/ui/TerminalController.kt | 6 ++- .../nodeterm/protocol/host/HostConnection.kt | 10 +++- .../protocol/host/RelayHostConnection.kt | 7 ++- .../protocol/ssh/SshHostConnection.kt | 3 +- .../protocol/src/test/interop/host-fixture.ts | 8 ++- .../dev/nodeterm/protocol/RelayInteropTest.kt | 16 ++++++ src/main/index.ts | 18 +++++++ src/main/remote/host-service.ts | 48 ++++++++++++++++-- src/main/remote/remote-security.test.ts | 50 ++++++++++++++++++- src/main/remote/standing-host.ts | 1 + 11 files changed, 160 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt index bf5dc6219f..f5523aaad6 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -71,6 +71,9 @@ object PendingLaunches { @Synchronized fun take(nodeId: String): LaunchRequest? = byNode.remove(nodeId) + + @Synchronized + fun peek(nodeId: String): LaunchRequest? = byNode[nodeId] } private val BUCKET_ORDER = listOf(SessionBucket.NEEDS_YOU, SessionBucket.RUNNING, SessionBucket.SLEEPING, SessionBucket.UNKNOWN) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index c68fdddd82..8ac7d9d04c 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -27,6 +27,7 @@ import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TerminalStream import dev.nodeterm.protocol.model.Agent @@ -274,7 +275,10 @@ class TerminalController( } val c = if (cols > 0) cols else 80 val r = if (rows > 0) rows else 24 - val s = conn.attach(nodeId, c, r, sink) + // A session this phone is starting: let the host create it in its project, under + // the chosen account (audit A33). + val hint = PendingLaunches.peek(nodeId)?.let { NewSessionHint(it.projectId, it.accountId, it.agentId) } + val s = conn.attach(nodeId, c, r, sink, hint) if (disposed || stopped) { s.detach() return@launch diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt index 3ce6151ad3..6d82bb4f99 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -74,7 +74,7 @@ interface HostConnection : Closeable { val capabilities: HostCapabilities suspend fun listProjects(): ProjectsSnapshot - suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink): TerminalStream + suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint? = null): TerminalStream suspend fun wake(nodeId: String) suspend fun refresh(nodeId: String) @@ -121,3 +121,11 @@ open class HostException(message: String) : Exception(message) * app offers to open it through the relay, where the desktop attaches it properly. */ class NeedsRelayException(val nodeId: String, message: String) : HostException(message) + +/** + * For an attach that STARTS a session the phone just created: which project it belongs to, and the + * account/agent chosen. A current desktop creates the session in that project's folder under that + * account itself (audit A33 — the phone's `cd`/env launch prefix is POSIX-only and cannot work on a + * Windows host); an older one ignores it. Direct SSH never creates sessions, so it ignores it too. + */ +data class NewSessionHint(val projectId: String, val accountId: String?, val agentId: String?) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt index c4ba16de5e..13668eac89 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt @@ -141,12 +141,17 @@ class RelayHostConnection private constructor() : HostConnection, RelaySocketLis return ProjectsParser.parseBlob(body.s("output") ?: "") } - override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink): TerminalStream = + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = suspendCancellableCoroutine { cont -> val params = buildJsonObject { put("nodeId", nodeId) put("cols", cols) put("rows", rows) + if (create != null) { + put("projectId", create.projectId) + create.accountId?.let { put("accountId", it) } + create.agentId?.let { put("agentId", it) } + } } socket.request("pty.attach", params) { r -> r.fold( diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index 72761ce801..9afbbf5ab1 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -9,6 +9,7 @@ import dev.nodeterm.protocol.host.HostException import dev.nodeterm.protocol.host.LabelEditResult import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TerminalStream import dev.nodeterm.protocol.host.TransportKind @@ -196,7 +197,7 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } } - override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink): TerminalStream = + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = withContext(Dispatchers.IO) { refuseRemoteNode(nodeId) if (run(SshScripts.hasSession(nodeId)).second.trim() != "yes") throw notRunning(nodeId) diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index 06ca197c7b..60a73bfc1e 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -103,7 +103,7 @@ async function runRelay(): Promise { attachDetached(persistKey, s, options) { sinks = s const id = `sess-${++sessionCounter}` - emit({ event: 'attach', persistKey, cols: options?.cols, rows: options?.rows, adaptsToSize: s.adaptsToSize }) + emit({ event: 'attach', persistKey, cols: options?.cols, rows: options?.rows, adaptsToSize: s.adaptsToSize, cwd: options?.cwd, accountId: options?.accountId }) setTimeout(() => s.onData(`hello ${persistKey}\r\n`), 20) return id }, @@ -111,7 +111,7 @@ async function runRelay(): Promise { return persistKey === 'term-big-1' ? bigSnapshot : `screen of ${persistKey}` }, async sessionExists(persistKey) { - return persistKey !== 'term-new-1' + return !persistKey.startsWith('term-new-') }, write(clientId, sessionId, data) { emit({ event: 'write', sessionId, data }) @@ -215,6 +215,10 @@ async function runRelay(): Promise { ), ackRead: (nodeId) => emit({ event: 'ack', nodeId }) }, + // A33: the host resolves where a phone-started session is created (fake registry: project p1). + newSessions: { + resolve: ({ projectId, accountId }) => (projectId === 'p1' ? { cwd: '/repo', ...(accountId ? { accountId } : {}) } : null) + }, onPeerReady: (s) => { emit({ event: 'peer-ready', sas: s.sas(), pub: s.peerPublicKeyB64() }) if (approveAfter >= 0) setTimeout(approveNow, approveAfter) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index 7c38512001..21d07f61c8 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -331,4 +331,20 @@ class RelayInteropTest { assertEquals("2", h.awaitEvent("write").str("data")) } } + + @Test + fun `a session the phone starts is created in its project by the desktop`() = runBlocking { + // A33, through the desktop's real pty.attach handler: the host applies the project folder + // and account (the fixture's session "does not exist", so this attach creates it). + val h = start() + connect(h).connection.use { conn -> + val s = conn.attach("term-new-9", 90, 30, RecordingSink(), dev.nodeterm.protocol.host.NewSessionHint("p1", "acct-1", "claude")) + val ev = h.awaitEvent("attach") + assertEquals("term-new-9", ev.str("persistKey")) + assertTrue(s.fresh) + assertEquals("/repo", ev.str("cwd")) + assertEquals("acct-1", ev.str("accountId")) + s.detach() + } + } } diff --git a/src/main/index.ts b/src/main/index.ts index 29ed2f2522..ff4595c6e8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -215,6 +215,7 @@ import { codexHome } from '../core/usage/codex-usage' import { isAsyncSubagentLaunch, grokRawFields, type NormalizedAgentEvent } from '../shared/agents/normalize' import { applyGrokHookSession } from '../core/grok-hook-session' import { agentAccountColor } from '../shared/agents/account-color' +import { AGENT_CONFIG, type BuiltinAgentId } from '../shared/agents/config' import { setRemoteTranscriptReader, TITLE_TAIL_BYTES, @@ -4047,6 +4048,23 @@ app.whenReady().then(async () => { return sshRemote ? { where, sshRemote } : { where } } }, + // A session the phone starts is created in its project's folder, under the account it chose, + // resolved HERE from the host's own registry and settings (audit A33: the phone's `cd`/env launch + // prefix is POSIX-only, so on Windows it was dropped). Only a local folder project qualifies — + // the same set the registrar accepts — and only a local, logged-in managed Claude account. + newSessions: { + resolve: ({ projectId, accountId, agentId }: { projectId: string; accountId?: string; agentId?: string }) => { + const info = workspaceStore.projectTargetInfo(projectId) + if (!info || info.ssh || !info.cwd) return null + const account = + accountId && agentId === 'claude' && + (settingsStore.get().claudeAccounts ?? []).some((a) => a.id === accountId && !a.pending && !a.host) + ? accountId + : undefined + const agent = agentId && Object.prototype.hasOwnProperty.call(AGENT_CONFIG, agentId) ? (agentId as BuiltinAgentId) : undefined + return { cwd: info.cwd, ...(account ? { accountId: account } : {}), ...(agent ? { agentId: agent } : {}) } + } + }, // A relay phone's Inbox actions (`approvals.answer` / `inbox.ack`): the SAME answer writer the // canvas Approve/Deny button uses, and the SAME ack pair the `~/.nodeterm/acks` sweep runs for a // phone on direct SSH — `ackDone` (resolve the done event, dismiss other phones' activities) diff --git a/src/main/remote/host-service.ts b/src/main/remote/host-service.ts index 821812eeab..6f8c72f62c 100644 --- a/src/main/remote/host-service.ts +++ b/src/main/remote/host-service.ts @@ -238,6 +238,22 @@ export interface HostRemoteNodes { resolve(nodeId: string): { where: string; sshRemote?: NonNullable } | null } +/** + * Where a session the PHONE starts is created (audit A33). `pty.attach` of a fresh node id creates + * its tmux session; the phone could only steer it through the launch line (`cd '' &&`, + * `CLAUDE_CONFIG_DIR=…`), which is POSIX shell — on a Windows host the session started in the home + * folder and silently dropped the account. The phone now names the project (and account/agent) on + * the attach, and the HOST resolves them from its own registry and settings, applied only when the + * attach creates the session. Everything is validated here; anything unknown is simply not applied. + */ +export interface HostNewSessions { + resolve(req: { + projectId: string + accountId?: string + agentId?: string + }): Pick | null +} + interface Stream { sessionId: string /** The node id (tmux persistKey) this stream attached to. The ONLY tmux target a client can @@ -327,7 +343,9 @@ export function createHostHandlers( inbox?: HostInboxOps, // Which nodes belong to an SSH project, and how to reach their host (see HostRemoteNodes). // Absent ⇒ every node is attached locally, as before (the Server Edition has no SSH projects). - remoteNodes?: HostRemoteNodes + remoteNodes?: HostRemoteNodes, + // Where a phone-started session is created (see HostNewSessions). Absent ⇒ `{cols, rows}` only. + newSessions?: HostNewSessions ): HostHandlers { // streamId -> Stream. PTY callbacks close over their own `streamId` directly, so no // reverse (sessionId -> streamId) index is needed. @@ -450,6 +468,23 @@ export function createHostHandlers( remote = owner.sshRemote } + // A phone starting a NEW session names its project (and account/agent): the host resolves the + // folder and account itself (A33). Resolved now, applied only if this attach creates the session. + let create: Pick | null = null + const projectId = str(p.projectId) + if (!remote && newSessions && projectId && projectId.length <= REF_MAX_LEN) { + try { + create = newSessions.resolve({ + projectId, + accountId: str(p.accountId) ?? undefined, + agentId: str(p.agentId) ?? undefined + }) + } catch { + create = null + } + } + let created = false + const streamId = ++streamCounter const stream: Stream = { sessionId: '', persistKey: nodeId, seq: 0, paused: false } const sinks = makeSinks(streamId, stream, p.resizedFrames === true) @@ -481,6 +516,7 @@ export function createHostHandlers( (remote ? pty.sessionExistsOver!(nodeId, remote) : pty.sessionExists(nodeId)).catch(() => true), new Promise((r) => setTimeout(() => r(true), FRESH_PROBE_BUDGET_MS)) ]) + created = !existed socket.respond(req.id, true, { streamId, fresh: !existed }) return (remote ? pty.captureSnapshotOver!(nodeId, remote) : pty.captureSnapshot(nodeId)).catch(() => '') })() @@ -494,7 +530,7 @@ export function createHostHandlers( // through to a local session (PtyCreateOptions.requireRemote). stream.sessionId = remote ? pty.attachDetached(nodeId, sinks, { cols, rows, sshRemote: remote, requireRemote: true }) - : pty.attachDetached(nodeId, sinks, { cols, rows }) + : pty.attachDetached(nodeId, sinks, { cols, rows, ...(created && create ? create : {}) }) } catch { // Attach failed (e.g. tmux unavailable) — surface as an exit so the client tears down. socket.sendFrame( @@ -1253,6 +1289,8 @@ export interface HostSessionOptions { inbox?: HostInboxOps /** SSH-project nodes: attach over their master, never locally. Optional (see HostRemoteNodes). */ remoteNodes?: HostRemoteNodes + /** Where a phone-started session is created (see HostNewSessions). Optional. */ + newSessions?: HostNewSessions /** Extra fs/git jail roots beyond the shared canvas's node cwds — production passes the * workspace's local project cwds: the phone browses EVERY project over `projects.list`, so a * canvas-only jail denied whichever project the desktop didn't happen to have focused. */ @@ -1379,7 +1417,8 @@ export function connectHostSession(opts: HostSessionOptions): HostSession { opts.nodeActions, opts.kanban, opts.inbox, - opts.remoteNodes + opts.remoteNodes, + opts.newSessions ) canvasSync = createHostCanvasSync(socket, opts.applyMutation) unsubCanvas = opts.subscribeCanvas(() => scheduleBroadcast()) @@ -1414,6 +1453,8 @@ export interface HostBridgeDeps { inbox?: HostInboxOps /** SSH-project nodes attach over their project's ControlMaster, or are refused (audit A09). */ remoteNodes?: HostRemoteNodes + /** A phone-started session is created in its project's folder, under its account (audit A33). */ + newSessions?: HostNewSessions /** Workspace-level jail roots (local project cwds) merged with the canvas node cwds. */ workspaceRoots?: () => string[] } @@ -1490,6 +1531,7 @@ export function initRemoteHost( kanban: bridge.kanban, inbox: bridge.inbox, remoteNodes: bridge.remoteNodes, + newSessions: bridge.newSessions, extraRoots: bridge.workspaceRoots, // Typing attribution: this session's input frames are this phone's keystrokes. getClientId: () => phone.id(), diff --git a/src/main/remote/remote-security.test.ts b/src/main/remote/remote-security.test.ts index 4e3b41e731..94fdde853a 100644 --- a/src/main/remote/remote-security.test.ts +++ b/src/main/remote/remote-security.test.ts @@ -5,7 +5,7 @@ // R4 — killing a stream forgets it in the SAME synchronous turn, so a late Input frame for that // streamId can never be written into a session that is already released. import { describe, expect, it, vi } from 'vitest' -import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket, type HostRemoteNodes } from './host-service' +import { createHostHandlers, type HostFsOps, type HostNewSessions, type HostPtyManager, type HostRelaySocket, type HostRemoteNodes } from './host-service' import { genKeyPair, deriveSharedKey, sasFromSharedKey, publicKeyToB64 } from './e2ee' import { connectRelay, type RelaySocket, type RelayTransport } from './relay-socket' import { OP, type Frame } from './framing' @@ -701,3 +701,51 @@ describe('pty.attach of an SSH-project node never runs locally', () => { expect(pty.sessionExistsOver).not.toHaveBeenCalled() }) }) + +// Audit A33: a session the PHONE starts is created where the desktop would create it — the host +// resolves the project's folder and the account from its own registry — and only when this attach +// is the one creating it. On Windows the phone's `cd`/env launch prefix cannot work at all. +describe('pty.attach creates a phone-started session in its project', () => { + const newSessions = (resolve: HostNewSessions['resolve']): HostNewSessions => ({ resolve }) + const handlersWith = (pty: HostPtyManager, socket: HostRelaySocket, fs: HostFsOps, ns: HostNewSessions) => + createHostHandlers( + pty, socket, fs, () => ['/work'], + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, ns + ) + + it('applies the host-resolved cwd/account/agent when the attach creates the session', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockResolvedValue(false) + const resolve = vi.fn(() => ({ cwd: 'C:\\repo', accountId: 'acct-1', agentId: 'claude' as const })) + const handlers = handlersWith(pty, socket, fs, newSessions(resolve)) + handlers.onRpc({ + id: 'n', + method: 'pty.attach', + params: { nodeId: 'term-new-1', cols: 80, rows: 24, projectId: 'p1', accountId: 'acct-1', agentId: 'claude' } + }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(resolve).toHaveBeenCalledWith({ projectId: 'p1', accountId: 'acct-1', agentId: 'claude' }) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ + cols: 80, rows: 24, cwd: 'C:\\repo', accountId: 'acct-1', agentId: 'claude' + }) + }) + + it('applies nothing when the session already exists (a join, not a create)', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockResolvedValue(true) + const handlers = handlersWith(pty, socket, fs, newSessions(() => ({ cwd: '/repo' }))) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-a', cols: 80, rows: 24, projectId: 'p1' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('an unknown project (resolver says null) creates exactly as before', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockResolvedValue(false) + const handlers = handlersWith(pty, socket, fs, newSessions(() => null)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-b', cols: 80, rows: 24, projectId: 'nope' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) +}) diff --git a/src/main/remote/standing-host.ts b/src/main/remote/standing-host.ts index 2f712e810f..ec2d4ae28f 100644 --- a/src/main/remote/standing-host.ts +++ b/src/main/remote/standing-host.ts @@ -314,6 +314,7 @@ export function initStandingHost( kanban: bridge.kanban, inbox: bridge.inbox, remoteNodes: bridge.remoteNodes, + newSessions: bridge.newSessions, extraRoots: bridge.workspaceRoots, // Typing attribution: this pooled session's input frames are ITS phone's keystrokes. getClientId: () => pooled.presence.id(), From 591e9cf5ddc33d194ba059b8f83dbff092a3d82f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 12:00:37 +0000 Subject: [PATCH 032/284] docs(android): WP4 progress, open items, decisions and the next follow-up prompt Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 8 +-- docs/android-handover.md | 107 ++++++++++++++++++++-------------- docs/android.md | 2 +- 3 files changed, 67 insertions(+), 50 deletions(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index 4ecc5da12f..3ef15b94d7 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -26,7 +26,7 @@ the audit's proposal, the handover's progress log says how and why. | [A10](#a10) | medium | BLOCK | small | ci-docs/risk | ✅ fixed in `fcda932` · Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | | [A11](#a11) | medium | | small | build/bug | ✅ fixed in `de1eded` · Tapping an Inbox notification while the app is in the background does not open that computer | | [A12](#a12) | medium | | medium | protocol/bug | ✅ fixed in `ab1335c` · Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | -| [A13](#a13) | medium | | medium | protocol/bug | Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | +| [A13](#a13) | medium | | medium | protocol/bug | ✅ phone side fixed in `68d0925` (desktop root cause open) · Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | | [A14](#a14) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned | | [A15](#a15) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found' | | [A16](#a16) | medium | | small | protocol/gap | ✅ fixed in `c58ad65` · The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | @@ -34,8 +34,8 @@ the audit's proposal, the handover's progress log says how and why. | [A18](#a18) | medium | | small | runtime/bug | ✅ fixed in `10de4b9` · No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely | | [A19](#a19) | medium | | small | runtime/bug | ✅ fixed in `de1eded` · Tapping an inbox notification while the activity is alive ignores the target computer | | [A20](#a20) | medium | | small | runtime/bug | ✅ fixed in `d199f03` · Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure | -| [A21](#a21) | medium | | small | runtime/bug | A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On | -| [A22](#a22) | medium | | small | runtime/bug | The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied | +| [A21](#a21) | medium | | small | runtime/bug | ✅ fixed in `daf15d3` · A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On | +| [A22](#a22) | medium | | small | runtime/bug | ✅ fixed in `ade7428` · The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied | | [A23](#a23) | medium | | small | security/risk | ✅ fixed in `3d36d60` · Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows | | [A24](#a24) | medium | | small | security/bug | ✅ fixed in `8e304db` · SecureStore treats ANY decrypt error as 'absent', so getOrCreate32 permanently overwrites the phone's identity | | [A25](#a25) | medium | | medium | parity/gap | No real push notifications: 15-minute background polling, no notification actions, no Live-Activity equivalent, and the desktop's phone-push switches are ignored | @@ -46,7 +46,7 @@ the audit's proposal, the handover's progress log says how and why. | [A30](#a30) | medium | | small | critic/bug | ✅ fixed in `3d36d60` · Pressing Deny on the desktop is not respected: the phone re-dials about 8 s later and the SAS approval dialog reappears | | [A31](#a31) | medium | | small | critic/bug | ✅ fixed in `a622e71` · A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown | | [A32](#a32) | medium | | medium | critic/gap | No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm | -| [A33](#a33) | medium | | medium | critic/bug | On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it | +| [A33](#a33) | medium | | medium | critic/bug | ✅ fixed in `0db0b6e` · On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it | | [A34](#a34) | medium | | small | critic/bug | ✅ fixed in `2a273a7` · The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter | | [A35](#a35) | medium | | small | critic/bug | ✅ fixed in `16706f4` · approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.' | | [A36](#a36) | medium | | small | critic/gap | ✅ fixed in `2a273a7` · After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself | diff --git a/docs/android-handover.md b/docs/android-handover.md index f8d26efd87..4e05023354 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -12,22 +12,54 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730), artifact `nodeterm-android-debug`, which expires 2026-12-24). The protocol tests pass in CI too. - **It has never been run on a phone.** A 170-agent audit found **9 distinct release blockers**. - WP1 and WP2 (all nine) are fixed on the branch; see the progress log below. Do not hand the APK - to anyone until the device pass (WP3) has run. -- The next session should **fix the blockers**, then do a **device test pass** using the checklist - below, then work down the medium findings. No PR is open, and none should be opened unless the - user asks. + All nine (WP1 + WP2) and every WP4 medium bug are fixed on the branch (A13 on the phone side + only); see the progress log below. Everything is unit/interop-tested where the layer allows and + type-checked, and CI builds the APK — but **nothing has run on a device**. Do not hand the APK to + anyone until the device pass (WP3) has run. +- The next session should run the **device test pass** (the checklist below), then the open items + in "What is still open", then WP5/WP6. No PR is open, and none should be opened unless the user + asks. ## Progress log Newest first. Each entry says what landed, how it was checked, and where the fix differs from the audit's proposal. -### WP4 (medium bugs): in progress +### WP4 (medium bugs): done on the branch, not device-verified | Finding | Commit | What changed | Checked by | |---|---|---|---| | A24 | `8e304db` | `SecretStoreCore` (protocol) replaces a stored secret only on positive evidence it is gone (nothing stored, malformed blob, `AEADBadTagException`); anything else is `SecretUnavailableException` and nothing is written. The identity's first write is durable. `SecureStore` generates the Keystore key only when the alias is absent. | `SecretStoreCoreTest` with real AES-GCM. | +| A20 | `d199f03` | `connectLocked` rethrows cancellation on both legs (state back to Idle, no "offline"), and closes an SSH connection that finished dialing after its caller was cancelled. | Type-check + CI build only. | +| A11 / A19 | `de1eded` | `onNewIntent` records the host id (and `setIntent`); a LaunchedEffect opens Hosts → that computer's Inbox. The Host destination is `key(route)`-ed so an already-showing Host screen does not keep its tab. | Type-check + CI build only. | +| A34 / A36 | `2a273a7` | A34: an armed Ctrl applies to the input bar (`Keys.ctrl`, one control byte, no Enter). A36: a null exit (connection gone) reattaches automatically once the host connection is back — bounded (3 per flapping stretch), only while the screen is showing. | `KeysTest`; the controller is type-checked only. | +| A14 / A15 / A16 | `c58ad65` | A16: the project's `defaultPermissionMode` wins over the global one (both re-validated); its `defaultAccountId` is preselected only while the host still has it. A15: the resume offer is built like the desktop's cold restore (`cd`, `CLAUDE_CONFIG_DIR`, mode; portable `./` cwds resolved). A14: cwd-less projects are no longer offered, and a refused registration shows a notice. | `ModelTest` (4 new cases). | +| A12 | `ab1335c` | New additive desktop verb `node.sendKeys {nodeId, keys}` → `{sent}` via `PtyManager.backgroundWrite` (no throwaway client; short answers only; SSH-project nodes answer `sent:false`). The phone uses it; `sent:false` opens the session; an older desktop gets attach → wait for paint → write → 400 ms linger. | vitest `host-node-actions.test.ts`; relay interop incl. the older-desktop fallback. | +| A31 | `a622e71` | sshj `KEEP_ALIVE` (want-reply, 15 s × 3 misses) instead of `HEARTBEAT`; `run()` has a real deadline that tears the transport down; a command that cannot run drops the connection. HostScreen shows a listing error while connected. | SSH transport test: a hung command returns within its deadline and fires `onClosed`. The keepalive-miss path itself is not exercised. | +| A18 | `10de4b9` | `LifecycleStartEffect` in HostScreen and TerminalScreen: watching and the terminal stream stop on ON_STOP (WebView paused) and resume on ON_START. | Type-check (new stub) + CI build. | +| A21 | `daf15d3` | The switch shows On only when the pref is on AND `areNotificationsEnabled()`; switching on asks for the permission or opens the app's notification settings; the worker does nothing when nothing can be shown; the launch-time ask happens on a fresh start only. | Type-check + CI build only. | +| A22 | `ade7428` | The back stack is `rememberSaveable` (JSON Saver); pairing routes are not restored; routes naming a forgotten computer are dropped; the launch intent applies only on a fresh start. | Type-check + CI build only. | +| A13 | `68d0925` | **Phone side only.** An exit 0 while the session is still listed as live (another client attached with `-D`) reattaches instead of reading "ended". The desktop root cause is open (see below). | Type-check + CI build only. | +| A33 | `0db0b6e` | New optional `pty.attach` fields `projectId`/`accountId`/`agentId` (additive). The desktop resolves the project folder and a local, logged-in managed Claude account itself, applied only when the attach creates the session. The phone sends them for a session it starts. | vitest `remote-security.test.ts` (3 new cases); relay interop through the real handler. | + +### What is still open + +- **A13, desktop root cause.** When the renderer mounts a node whose session a relay-served client + holds, pty-manager attaches with `-A -D` (relay ptys are not indexed by persistKey), detaching the + phone. The phone now reattaches, but the right fix is on the desktop (attach without `-D` when a + detached relay session for that persistKey is live). It touches pty-manager's attach flags, so it + needs its own careful change and tests; iOS benefits too. +- **A12 for SSH-project nodes.** `node.sendKeys` answers `sent:false` for them (background writes + do not reach a remote host), so the phone opens the session instead of answering in one tap. +- **A33 on an older desktop.** An older desktop ignores the new attach fields, so a Windows host + still starts phone sessions in the home folder until it is updated. +- **A07 edges.** Late relay adoption does not pin; revoking a device unpins but does not cut a relay + session open at that moment. +- **A10 trade-off.** The debug key is public by the user's decision; a release key does not exist. +- **Server-e2e and native-module vitest suites** could not run in this sandbox (`npm ci + --ignore-scripts` skips the native builds); the same failures occur on the pre-session commit. + Desktop CI does not run on branch pushes here, so the desktop changes are checked by targeted + vitest files + `npm run typecheck` only. ### WP2 (blockers with decisions): done on the branch, not device-verified @@ -198,15 +230,14 @@ When the user asks: open a PR from this branch, following the repo's `pr-writing template. Mention @eneskirca for the mobile implications: the pairing-key pin from `A07`, and `approvals.answer` / `inbox.ack`, which the iOS app can adopt. -## Decisions the user still has to make +## Decisions made (2026-09-26) -1. **A07**: pin the phone's relay key at pairing (desktop + protocol change, additive), or the - interim "one handshake right after pairing" flow? Recommended: pin at pairing. -2. **A08**: refuse cold-create over direct SSH, or inject the hook env? Recommended: refuse and - route to the computer. -3. **A09**: hide or relay-route SSH-project nodes over direct SSH? Recommended: relay-route. -4. **Distribution**: a committed public debug key for sideloading (`A10`), and later a real release - signing key plus a store listing. Nothing exists yet for the latter. +1. **A07**: pin the phone's relay key at pairing (desktop + protocol change, additive). Done. +2. **A08**: refuse cold-create over direct SSH and route to the relay. Done. +3. **A09**: relay-route SSH-project nodes; the desktop attaches them over the project's ControlMaster. + Done. +4. **Distribution**: a committed public debug key for sideloading (`A10`). Done. A real release + signing key and a store listing are still to come. ## Device checklist (owed; nothing here has been run) @@ -271,39 +302,25 @@ Run each on a real phone against a real desktop, and note OS versions. ## Follow-up prompt -Paste this into the next session. Edit the "Decisions" line first if you want different choices -than the recommendations. +Paste this into the next session. ```text Continue the Android companion work on branch claude/android-ios-parity-75kfem of CPlusPlus17/nodeterm. -Start by reading docs/android-handover.md (status, plan, environment notes, conventions) and -docs/android-audit-2026-09.md (findings A01–A77 with evidence and fixes). Re-read the cited code -before changing it; the audit is at commit 2f58918. - -Do, in order, committing in small logical commits and pushing when each work package is green: -1. WP1 blockers: A01/A04 (SSH I/O off the main thread, one serial executor in SshStream, close() - off main), A02 (desktop userData dir is node-terminal; fix the SSH prelude, its test fixture, - and docs/uninstall.md + scripts/uninstall.sh), A03 (UTF-8 locale for the SSH tmux client), - A06 + A35 (approvals.answer must not report success after the hold expired; reason in the reply; - phone maps "gone" to open-session), A10 (committed public debug keystore so CI APKs update in place). -2. WP2 with these decisions: A05/A17/A23/A30 — background worker never makes a first relay - handshake (per-host relayApproved flag set by a successful foreground relay connect) and a denied - or pending approval backs off instead of re-dialing; A07 — send the phone's box public key in - the sealed /pair body and have the desktop pin it on successful pairing (additive; keep older - desktops working), and fix PairScreen's "reachable from anywhere" claim; A08 — never cold-create - a tmux session over direct SSH, offer to open it via the relay instead; A09/A28 — nodes of the - desktop's SSH projects are opened via the relay, never against the desktop's local tmux. -3. Then WP4 (medium bugs), starting with A24 (SecureStore identity overwrite). - -For every fix add a regression test where the layer allows (protocol tests are JVM + interop + -MINA/tmux). Before each push run: the protocol tests (cd android/protocol && gradle test --offline, -after npm ci --ignore-scripts at the repo root), the offline app type-check -(cd android/tools/typecheck && gradle compileKotlin), and for desktop changes the affected vitest -files plus npm run typecheck. After each push, check that the Android GitHub workflow is green. -Google Maven is blocked in this sandbox — do not try to reach it; CI does the real AGP build. -Keep docs/android.md, android/README.md, docs/android-handover.md and the audit index in sync -(mark fixed findings). Follow CLAUDE.md, including the Android rule under Conventions. Do not open a -PR unless I ask. At the end, report what was fixed, how it was verified, what is still unverified -(nothing has run on a device yet), and the updated next steps. +Start by reading docs/android-handover.md (progress log, what is still open, environment notes, +conventions) and docs/android-audit-2026-09.md (fixed findings are marked in the index). WP1, WP2 +and WP4 are fixed on the branch but nothing has run on a device. + +1. If a phone is available: run the device checklist in the handover (WP3) with the latest CI APK, + record results in docs/android.md → "What is verified", and turn every failure into a finding. +2. The desktop root cause of A13 (a renderer client attaching with -D over a live relay-served + client), with tests in pty-manager. +3. WP5 parity gaps and WP6 low-severity items, starting with A37 (R8 rules), A52 (lock-screen + content), A53/A54 (size caps), A63/A68 (CI triggers and path filters). + +Before each push run the protocol tests (cd android/protocol && gradle test --offline, after npm ci +--ignore-scripts at the repo root), the offline type-check (cd android/tools/typecheck && gradle +compileKotlin), and for desktop changes the affected vitest files plus npm run typecheck; after each +push confirm the Android workflow is green. Keep docs/android.md, android/README.md, the handover and +the audit index in sync. Do not open a PR unless asked. ``` diff --git a/docs/android.md b/docs/android.md index c36123312a..39b14d5ada 100644 --- a/docs/android.md +++ b/docs/android.md @@ -46,7 +46,7 @@ describes as the future. The Android client implements what the host actually se | Phone action | Relay (host-service.ts) | Direct SSH | |---|---|---| | List projects/sessions/status | `projects.list` → the `--NT-PROJECTS-SPLIT--` blob | same blob, from `workspace.json` + `tmux ls` + `agent-status.json`; the v3 index is resolved like `WorkspaceStore` (folder refs → `.nodeterm/project.json`, SSH refs → `cache`, data refs → `inline-projects/.json`) | -| Open a terminal | `pty.attach` → `{streamId, fresh}`, Snapshot frames, Output frames; a node of an SSH project is attached over that project's ControlMaster (`requireRemote`) or refused | `has-session`, then a pty exec of `tmux attach-session` — never `new-session`: a session that is not running, or a node of an SSH project, is refused with `NeedsRelayException` and the app offers the relay | +| Open a terminal | `pty.attach` → `{streamId, fresh}` (a session the phone starts adds `projectId`/`accountId`/`agentId`, which the desktop resolves itself and applies only when creating it), Snapshot frames, Output frames; a node of an SSH project is attached over that project's ControlMaster (`requireRemote`) or refused | `has-session`, then a pty exec of `tmux attach-session` — never `new-session`: a session that is not running, or a node of an SSH project, is refused with `NeedsRelayException` and the app offers the relay | | Type / resize | `OP.Input` / `OP.Resize` frames | channel stdin / window-change | | Scroll | `pty.scroll` (host writes SGR wheel events) | the phone writes the same SGR wheel events | | Detach / end | `pty.kill` / `pty.destroy` | close channel / `kill-session` | From 356e8c7de7716f9431ee1ed900e3cc6c2650e486 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 12:00:49 +0000 Subject: [PATCH 033/284] docs(android): refresh the README status and the handover's commit summary Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 8 ++++---- docs/android-handover.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/README.md b/android/README.md index 3cc9b9843a..be886e80e0 100644 --- a/android/README.md +++ b/android/README.md @@ -6,10 +6,10 @@ from anywhere through the end-to-end encrypted relay. It is the Android counterp and speaks the same protocol to the same desktop; nothing on the computer needs to know which phone it is talking to. -> **Status (2026-09-26): not ready for users.** CI builds the debug APK, but the app has not yet -> been run on a phone, and an audit found release blockers, including that typing in a -> direct-SSH terminal drops the connection. The plan and the full list are in -> [`docs/android-handover.md`](../docs/android-handover.md) and +> **Status (2026-09-26): not ready for users.** CI builds the debug APK, and the release blockers +> and medium bugs an audit found are fixed on this branch and tested where the code allows — but the +> app has **not yet been run on a phone**. The plan, the device checklist and what is still open are +> in [`docs/android-handover.md`](../docs/android-handover.md); the findings are in > [`docs/android-audit-2026-09.md`](../docs/android-audit-2026-09.md). The table below describes what > the code is written to do, not what has been verified on a device. diff --git a/docs/android-handover.md b/docs/android-handover.md index 4e05023354..68f5fc2e47 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -93,7 +93,7 @@ when it did not deliver. iOS can adopt `reason` unchanged; an older phone keeps | | | |---|---| | Repo / branch | `CPlusPlus17/nodeterm`, branch `claude/android-ios-parity-75kfem` (pushed) | -| Commits | `a0c07e6` protocol module + the two desktop relay verbs; `2f58918` the Compose app, docs, CI, desktop copy; plus the handover commit that adds this file | +| Commits | `a0c07e6` protocol module + the two desktop relay verbs; `2f58918` the Compose app, docs, CI, desktop copy; `2dd539f` this handover; then the WP1/WP2/WP4 fixes listed in the progress log (`af1f820` … `0db0b6e`) | | PR | none (do not open one unless asked) | | CI | `.github/workflows/android.yml`: **Protocol** (JVM + desktop interop) and **App** (`assembleDebug`). Both green on `2f58918` | From b718a04c09aa1f74c46e01f31c1b20fa0f384456 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 12:56:03 +0000 Subject: [PATCH 034/284] fix(pty): the app's tmux client no longer detaches a relay phone's client (A13) When the desktop mounted a node while a phone was attached to it over the relay (a phone that started a session and registered it as a node, or a node remounting on a project switch, park expiry or offscreen revive while a phone watched), PtyManager spawned the renderer's tmux client with `new-session -A -D`. Relay ptys are deliberately not indexed in byPersistKey, so co-attach could not join them, and `-D` detached the phone's tmux client; it exited 0 and the phone read "The session ended". spawnSession now asks relayClientOnLocalTmux(persistKey) (a relay-served Session of the same node, still holding its sink, on the LOCAL tmux server) synchronously with the spawn, and tmuxAttachFlags(detached, relayClientLive) drops -D in that case only. With no relay client of that node live, the renderer keeps `-A -D` exactly as before, so the "one app client per session" rule is unchanged for everyone without a phone attached. The currently unreachable warm `attach-session -d` branch gets the same rule. Paths considered: - SSH-project nodes: the remote attach (remoteTmuxCommand) has never passed -D, for the renderer or for the A09 relay attach over the ControlMaster; now pinned by a test, no change. - Session host (Windows / no tmux): an attach adds a subscriber socket and detaches nobody; nothing to change. - Plain-shell fallback: no shared session; nothing to change. Remaining window, documented in the tmuxAttachFlags comment: a relay pty spawned after the app's own whose attach reaches the tmux server first is still kicked (the few ms the app's tmux client takes to connect), and clients this process did not spawn (a direct-SSH phone, the user's own tmux attach) are still detached by -D. The Android client's exit-0 reattach (68d0925) covers both, and an older desktop. Tests: - src/core/pty-relay-coattach.test.ts (mocked node-pty, argv): -D without a relay client; no -D beside one; decided at spawn time (a relay attach landing while create() is in flight); another node's relay client, a closed relay stream and an exited relay pty all keep -D; a remount while the phone stays; SSH-project argv never carries -D; a relay client over an SSH master does not affect a local spawn. - src/core/relay-coattach.realtmux.test.ts (real tmux 3.4, private socket inside the TMUX_TMPDIR sandbox, control-mode clients): the control shows `-A -D` detaching the attached client with exit 0; the fix leaves both clients attached. Mutation checks: forcing the new flag off fails 3 argv tests; making tmuxAttachFlags ignore it fails those plus the real-tmux fix test; dropping the sshRemote filter fails the local-vs-remote test. Verified: the two new files pass; npm run typecheck; vitest over src/core/pty-*, src/main/remote/, the tmux isolation guard: 5 failures, identical on HEAD without this change (no ssh client and no node-pty native build in this sandbox). android/protocol `gradle cleanTest test --offline` (74 tests) and the offline app type-check pass. Not device-verified. Departures from the audit: the audit (and its verifier) proposed tracking detached sessions by persistKey or checking for a live relay pty; this walks the session table at spawn time instead of adding a second index, and restricts the match to the local tmux server. The Android comment in TerminalController and docs/android.md now say which cases still detach a phone; CLAUDE.md's tmux sentence names the exception. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- CLAUDE.md | 4 +- .../nodeterm/android/ui/TerminalController.kt | 4 +- docs/android.md | 5 + src/core/pty-manager.ts | 62 +++- src/core/pty-relay-coattach.test.ts | 300 ++++++++++++++++++ src/core/relay-coattach.realtmux.test.ts | 156 +++++++++ 6 files changed, 519 insertions(+), 12 deletions(-) create mode 100644 src/core/pty-relay-coattach.test.ts create mode 100644 src/core/relay-coattach.realtmux.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 11cc9f93b1..0b546fdacb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -527,7 +527,9 @@ project's nodes only.** The contract: ## Terminal session continuity (tmux) `src/core/pty-manager.ts` runs each terminal inside a persistent tmux session -(`tmux new-session -A -D -s nt-`) on a dedicated socket (`-L node-terminal`) with +(`tmux new-session -A -D -s nt-`; `-D` is left off while a relay-served phone client of +that node is attached, so mounting the node does not detach the phone — `tmuxAttachFlags`, audit +A13) on a dedicated socket (`-L node-terminal`) with a generated config (`-f /tmux.conf`, so the user's `~/.tmux.conf` never interferes; status bar off, **mouse on**, 50k history, `set-clipboard on` + `terminal-features ",*:clipboard"`, and the copy-mode mouse bindings). Because the tmux *server* outlives the app, diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 8ac7d9d04c..37885c49e2 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -124,7 +124,9 @@ class TerminalController( if (stopped) return@post stream = null // exit 0 with the session still running = another client attached with -D and - // detached us (the desktop mounting a phone-started node does this) — audit A13. + // detached us — audit A13. A current desktop no longer does this to a relay-attached + // phone, but an older desktop does, and any desktop still does it to a phone attached + // over direct SSH (its client is not one the desktop spawned, so it cannot see it). // It is checked, not assumed: a killed session also exits 0. if ((code == null || code == 0) && !disposed && autoReattach(requireLive = code == 0)) { state = TermState.Ended(if (code == null) "Disconnected. Reconnecting…" else "Another screen took over this session. Reattaching…") diff --git a/docs/android.md b/docs/android.md index 39b14d5ada..05c4c9e142 100644 --- a/docs/android.md +++ b/docs/android.md @@ -124,6 +124,11 @@ generated SSH answer command under a real `/bin/sh`. need the relay. iOS writes `project.json` over SSH for some of these; Android deliberately does not (the host verbs exist because that write breaks past `MAX_ARG_STRLEN` and cannot reach an SSH project's file at all). +- **A desktop mounting a node can still detach a direct-SSH phone.** The desktop leaves `-D` off + its own tmux client only while a relay-served client of that node is attached (it spawned that + one itself, so it can see it). A phone attached over direct SSH is detached (exit 0), and so is a + relay phone on a desktop older than that change; the app checks that the session is still live + and reattaches. - **Codex/Gemini/… launch flags.** A phone-started non-Claude agent launches bare (its own default approval mode): the per-agent approval table needs host facts (codex's vocabulary moved between releases, #785) the mirror only partly publishes. diff --git a/src/core/pty-manager.ts b/src/core/pty-manager.ts index 39f4560d22..d419a80688 100644 --- a/src/core/pty-manager.ts +++ b/src/core/pty-manager.ts @@ -741,15 +741,33 @@ export interface DetachedSinks { /** * tmux attach flags. `-A` = attach-or-create. `-D` = detach OTHER clients on attach. * - * `-D` STAYS for the app's own client, and co-attach does not change that: a second viewer - * subscribes to the existing `Session` in this process — it does NOT start a second tmux client. - * The app therefore always has exactly ONE tmux client per session, so tmux's own multi-client - * size negotiation never engages and "smallest subscriber wins" is decided by us (pty-size.ts). - * A relay-served (detached) pty is the one exception: the host's local client is already attached - * to the same session and must be mirrored, not kicked off. + * `-D` STAYS for the app's own (renderer) client, and co-attach does not change that: a second + * viewer subscribes to the existing `Session` in this process — it does NOT start a second tmux + * client. With no phone attached the app therefore has exactly ONE tmux client per session, so + * tmux's own multi-client size negotiation never engages and "smallest subscriber wins" is decided + * by us (pty-size.ts). + * + * A relay-served (phone) client is the exception, in both orders, and gets `-A` alone: + * - `detached`: the relay-served pty itself. The host's own local client may already be attached + * to the same session and must be mirrored, not kicked off. + * - `relayClientLive`: the app's client arrives while a relay-served client of the SAME node is + * already attached to the same tmux server (audit A13) — a phone that started a session and then + * registered it as a node (the desktop mounts it), or a node remounting on a project switch, a + * park expiry or an offscreen revive while a phone watches it. `-D` there detached the phone's + * client, which exits 0, and the phone read that as "the session ended". Relay ptys are + * deliberately not indexed in `byPersistKey`, so co-attach cannot join them; the caller asks + * `relayClientOnLocalTmux` instead. The result is the same two-client session the other order + * (phone attaches after the app) has always produced. + * + * Only clients THIS process spawned are visible. A phone attached over direct SSH, the user's own + * `tmux attach` and a second nodeterm on the socket are still detached by `-D` (the Android app + * reattaches on an exit 0 while its session is still listed live). And a relay client whose pty is + * spawned after the app's own, but whose attach reaches the tmux server first, is still kicked: + * the check is decided synchronously with the app's spawn, so that window is the few milliseconds + * the app's tmux client takes to connect. */ -export function tmuxAttachFlags(detached: boolean): string[] { - return detached ? ['-A'] : ['-A', '-D'] +export function tmuxAttachFlags(detached: boolean, relayClientLive = false): string[] { + return detached || relayClientLive ? ['-A'] : ['-A', '-D'] } // Output coalescing: a fast producer (e.g. `yes`, a verbose build, tmux full-screen @@ -2562,6 +2580,25 @@ export class PtyManager { return !this.tmuxPath && this.getSettings().tmuxEnabled && sessionHostSupported() } + /** + * Is a relay-served (detached) client of this node attached to the LOCAL tmux server right now? + * The question `tmuxAttachFlags` needs answered before the app's own client attaches (audit A13): + * relay ptys are deliberately not indexed in `byPersistKey`, so this walks the sessions. + * + * Local tmux only. A relay client of an SSH-project node rides that project's ControlMaster to + * another host's tmux server (and the remote attach never passes `-D` anyway); a session-host + * viewer is not a tmux client. `onData` is the sink: `kill(null, …)` clears it and releases the + * pty in the same tick, and an exited relay pty is forgotten, so a phone that has left never + * keeps `-D` off. + */ + private relayClientOnLocalTmux(persistKey: string): boolean { + for (const s of this.sessions.values()) { + if (s.nodeId === persistKey && s.onData && s.tmuxBacked && !s.sshRemote && !s.sessionHost) + return true + } + return false + } + /** The exact live generation for a node id, including a non-persistent indexed plain shell. */ private liveSessionForPersistKey(persistKey: string): Session | undefined { const indexedId = this.byPersistKey.get(persistKey) @@ -3251,11 +3288,16 @@ export class PtyManager { // PTY (sinks set) MUST NOT detach others: the host's own local client is attached to the same // `nt-` session, and a connecting client should MIRROR it (tmux co-attach), not kick it // off — `-D` there is exactly what showed "[detached]" in every host window on connect. + // The reverse order owes the same: a renderer client arriving while a relay-served client of + // this node is attached co-attaches beside it instead of detaching the phone (audit A13; see + // `tmuxAttachFlags`). Asked HERE, synchronously with the `pty.spawn` below, so nothing can + // register or drop a relay client between the answer and the attach it governs. // (tmux sizes a co-attached session to the smallest client — the accepted mirroring tradeoff.) // `-e` sets the session environment explicitly (the tmux server is shared, so relying // on the client's inherited env would leak the first session's values into later ones). file = this.tmuxPath useLocalTmux = true + const besideRelay = !sinks && this.relayClientOnLocalTmux(options.persistKey) if (warmWindowsBackend === 'tmux') { // Attach-only is the critical distinction from `new-session -A`: if the proven warm // generation disappears in this window, tmux rejects instead of cold-spawning a shell @@ -3266,7 +3308,7 @@ export class PtyManager { '-f', this.confPath, 'attach-session', - ...(sinks ? [] : ['-d']), + ...(sinks || besideRelay ? [] : ['-d']), '-t', sessionName(options.persistKey) ] @@ -3307,7 +3349,7 @@ export class PtyManager { ...Object.keys(customEnvMerged), ...Object.keys(projectEnv ?? {}) ]) - const attachFlags = tmuxAttachFlags(!!sinks) + const attachFlags = tmuxAttachFlags(!!sinks, besideRelay) args = [ '-L', TMUX_SOCKET, diff --git a/src/core/pty-relay-coattach.test.ts b/src/core/pty-relay-coattach.test.ts new file mode 100644 index 0000000000..9685cbd9ec --- /dev/null +++ b/src/core/pty-relay-coattach.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform, type FakePlatform } from './platform-fake' +import { IPC } from '../shared/ipc' +import { DEFAULT_SETTINGS } from '../shared/types' +import { sessionName } from './tmux-naming' + +/** + * AUDIT A13 — the app's own tmux client must not detach a phone's relay-served client. + * + * A relay-served pty (`attachDetached`, the phone over the relay) attaches with `-A` alone, so it + * mirrors a desktop client that is already there. The reverse order was the bug: when the desktop + * mounts a node while a phone is attached to it — a phone that started a session and registered it + * as a node, or a node remounting on a project switch / park expiry / offscreen revive while a + * phone watches — the renderer's client attached with `-A -D`, `-D` detached the phone's tmux + * client, it exited 0, and the phone said "The session ended (exit 0)." Relay ptys are deliberately + * not indexed in `byPersistKey`, so co-attach could not join them either. + * + * What is pinned here is the ARGV `PtyManager` hands tmux: `-D` present when no relay client of + * that node is live (the unchanged "one app client per session" rule), absent when one is. That + * `-D` is what detaches the other client, and that `-A` alone leaves it attached, is a property of + * tmux and is measured against a real one in relay-coattach.realtmux.test.ts. + */ + +interface FakePty { + onDataCb?: (d: string) => void + onExitCb?: (e: { exitCode: number }) => void + killed: boolean +} +const spawned: FakePty[] = [] +const spawnArgs: Array<{ file: string; args: string[] }> = [] + +// Pin the persistence backend (see src/core/__fixtures__/no-session-host.ts): whether this suite +// exercised tmux or a real session-host shim must not depend on whether anyone ran a build. +vi.mock('./session-host-backend', async () => + (await import('./__fixtures__/no-session-host')).noSessionHost() +) + +vi.mock('node-pty', () => ({ + spawn: (file: string, args: string[]) => { + const p: FakePty = { killed: false } + spawned.push(p) + spawnArgs.push({ file, args }) + return { + onData: (cb: (d: string) => void) => { + p.onDataCb = cb + }, + onExit: (cb: (e: { exitCode: number }) => void) => { + p.onExitCb = cb + }, + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => { + p.killed = true + }, + pid: 1 + } + } +})) + +/** Every tmux/ssh side-call goes through child_process; `has-session` answers from the set. */ +const liveTmuxSessions = new Set() + +vi.mock('child_process', () => { + type Cb = (err: Error | null, res?: { stdout: string; stderr: string }) => void + const execFile = (_file: string, args: string[], a?: unknown, b?: unknown): unknown => { + const cb = (typeof a === 'function' ? a : b) as Cb | undefined + const ok = (stdout: string): void => cb?.(null, { stdout, stderr: '' }) + if (args.includes('has-session')) { + const target = args[args.indexOf('-t') + 1] + if (liveTmuxSessions.has(target)) ok('') + else cb?.(Object.assign(new Error('no such session'), { code: 1 })) + } else if (args[0] === '-ilc') { + ok('__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__') + } else { + ok('') + } + return {} + } + return { execFile, execFileSync: (): string => '' } +}) + +// Hermetic tmux resolution (issue #160 — see pty-single-user.test.ts): the tmux-backed manager is +// what is under test, so it must not silently become the plain-shell fallback on a host without one. +vi.mock('./tmux-hint', async (importOriginal) => ({ + ...(await importOriginal()), + findFixedTmux: () => '/usr/bin/tmux' +})) + +// Hermetic ssh resolution for the SSH-project leg: `findSsh()` walks the real PATH, and a host +// without an ssh client (this sandbox has none) would silently fall through to the LOCAL tmux +// branch — i.e. test a spawn that never happens for a remote node. +vi.mock('./exec-path', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + findExecutableSync: (bin: string, fallbacks?: string[]) => + bin === 'ssh' ? '/usr/bin/ssh' : real.findExecutableSync(bin, fallbacks) + } +}) + +// A machine with pty devices to spare (see pty-single-user.test.ts for why this is pinned). +vi.mock('./pty-devices', async (importOriginal) => ({ + ...(await importOriginal()), + readPtyDevices: () => ({ ceiling: 511, inUse: 8 }) +})) + +const ALICE = 1 +const REMOTE = { conn: { host: 'h1', user: 'u' }, controlPath: '/tmp/cm', remoteCwd: '/srv/app' } + +/** The attach flags of a LOCAL tmux spawn: everything between `new-session` and the first `-e`/`-c`. */ +function localAttachFlags(args: string[]): string[] { + const at = args.indexOf('new-session') + expect(at).toBeGreaterThan(-1) + const out: string[] = [] + for (const a of args.slice(at + 1)) { + if (a !== '-A' && a !== '-D') break + out.push(a) + } + return out +} + +/** The tokens of the REMOTE tmux command between `new-session` and `-s` (the ssh argv carries the + * whole remote shell line as one string; every token there is posix-quoted). */ +function remoteAttachTokens(args: string[]): string[] { + const line = args.join(' ') + const at = line.indexOf('new-session') + expect(at).toBeGreaterThan(-1) + const end = line.indexOf(' -s ', at) + return line + .slice(at + 'new-session'.length, end) + .split(/\s+/) + .filter(Boolean) + .map((t) => t.replace(/^'|'$/g, '')) +} + +describe("the app's tmux client beside a relay-served (phone) client — audit A13", () => { + let fake: FakePlatform + let userDataDir: string + + beforeEach(() => { + spawned.length = 0 + spawnArgs.length = 0 + liveTmuxSessions.clear() + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-relayco-')) + fake = fakePlatform({ userDataDir }) + initPlatform(fake) + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + resetPlatformForTests() + // Best effort, as in pty-single-user.test.ts: a fire-and-forget scrollback snapshot may still + // be landing, and a cleanup that fails the suite is worse than a leftover temp dir. + try { + fs.rmSync(userDataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 }) + } catch { + /* a temp dir we could not remove is not a test result */ + } + }) + + async function tmuxManager() { + const { PtyManager } = await import('./pty-manager') + const m = new PtyManager() + m.init(() => DEFAULT_SETTINGS) + m.registerIpc() + return m + } + const create = (persistKey = 'node-1', extra: Record = {}) => + fake.handlers[IPC.ptyCreate](ALICE, { cols: 80, rows: 24, persistKey, ...extra }) as Promise<{ + sessionId: string + fresh: boolean + }> + const sinks = () => ({ onData: vi.fn(), onExit: vi.fn() }) + + it('keeps -D when no relay client of the node is live (the unchanged one-app-client rule)', async () => { + await tmuxManager() + await create() + expect(spawnArgs).toHaveLength(1) + expect(localAttachFlags(spawnArgs[0].args)).toEqual(['-A', '-D']) + }) + + it('co-attaches WITHOUT -D while a relay client of the same node is attached', async () => { + const m = await tmuxManager() + liveTmuxSessions.add(sessionName('node-1')) + const phone = sinks() + m.attachDetached('node-1', phone) // the phone, attached first (it started the session) + + await create() // the desktop mounts the node the phone just registered + + expect(spawnArgs).toHaveLength(2) + // The relay's own client never detached anyone… + expect(localAttachFlags(spawnArgs[0].args)).toEqual(['-A']) + // …and the app's client now does not detach the phone either. + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A']) + expect(spawnArgs[1].args.slice(-2)).toEqual(['-s', sessionName('node-1')]) + // The phone's pty is untouched and told nothing. + expect(spawned[0].killed).toBe(false) + expect(phone.onExit).not.toHaveBeenCalled() + }) + + it('decides at SPAWN time: a phone that attaches while the app create is in flight is spared', async () => { + const m = await tmuxManager() + liveTmuxSessions.add(sessionName('node-1')) + // create() awaits has-session (a subprocess) before it spawns anything… + const pending = create() + expect(spawnArgs).toHaveLength(0) + // …and the relay attach lands in that window. + m.attachDetached('node-1', sinks()) + await pending + + expect(spawnArgs).toHaveLength(2) + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A']) + }) + + it('a relay client of ANOTHER node does not take -D away', async () => { + const m = await tmuxManager() + m.attachDetached('node-2', sinks()) + await create('node-1') + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A', '-D']) + }) + + it('restores -D once the relay client has left (the stream was closed)', async () => { + const m = await tmuxManager() + const relayId = m.attachDetached('node-1', sinks()) + m.kill(null, relayId) // the phone detached right before the desktop mounted the node + await create() + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A', '-D']) + }) + + it("restores -D once the relay client's pty has exited", async () => { + const m = await tmuxManager() + m.attachDetached('node-1', sinks()) + spawned[0].onExitCb?.({ exitCode: 0 }) + await create() + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A', '-D']) + }) + + it('the next remount (park expiry, offscreen revive) still spares a phone that stayed', async () => { + const m = await tmuxManager() + liveTmuxSessions.add(sessionName('node-1')) + m.attachDetached('node-1', sinks()) + const first = await create() + m.kill(ALICE, first.sessionId) // the desktop node unmounts; the phone keeps watching + await create() + + expect(spawnArgs).toHaveLength(3) + expect(localAttachFlags(spawnArgs[2].args)).toEqual(['-A']) + expect(spawned[0].killed).toBe(false) + }) + + it('an SSH-project node never passes -D on the remote host, with or without a relay client', async () => { + const m = await tmuxManager() + const relayOpts = { cols: 80, rows: 24, sshRemote: REMOTE, requireRemote: true } + // The renderer's remote attach with no phone attached, then the phone joining it… + await create('node-r', { sshRemote: REMOTE, requireRemote: true }) + m.attachDetached('node-r', sinks(), relayOpts) + // …and the reverse order: the phone attached over the same ControlMaster first (audit A09's + // relay path), then the desktop mounts the node. + m.attachDetached('node-r2', sinks(), relayOpts) + await create('node-r2', { sshRemote: REMOTE, requireRemote: true }) + + expect(m.sshRemoteForNode('node-r')).toBeDefined() // the remote branch really ran + const remoteSpawns = spawnArgs.filter((s) => s.file === '/usr/bin/ssh') + expect(remoteSpawns).toHaveLength(4) + for (const s of remoteSpawns) { + const tokens = remoteAttachTokens(s.args) + expect(tokens[0]).toBe('-A') + expect(tokens).not.toContain('-D') + } + }) + + it('a relay client over an SSH master does not take -D away from a LOCAL spawn of the same id', async () => { + // Not a state the app reaches (a remote node refuses a local spawn — `requireRemote`), but the + // predicate must ask about the tmux server the spawn attaches to, not about the node id alone. + const m = await tmuxManager() + m.attachDetached('node-1', sinks(), { cols: 80, rows: 24, sshRemote: REMOTE, requireRemote: true }) + await create('node-1') + const local = spawnArgs.filter((s) => s.file !== '/usr/bin/ssh') + expect(local).toHaveLength(1) + expect(localAttachFlags(local[0].args)).toEqual(['-A', '-D']) + }) +}) + +describe('tmuxAttachFlags (A13)', () => { + it('drops -D for a relay-served pty and for the app client beside one, and only then', async () => { + const { tmuxAttachFlags } = await import('./pty-manager') + expect(tmuxAttachFlags(false)).toEqual(['-A', '-D']) + expect(tmuxAttachFlags(false, false)).toEqual(['-A', '-D']) + expect(tmuxAttachFlags(false, true)).toEqual(['-A']) + expect(tmuxAttachFlags(true)).toEqual(['-A']) + expect(tmuxAttachFlags(true, true)).toEqual(['-A']) + }) +}) diff --git a/src/core/relay-coattach.realtmux.test.ts b/src/core/relay-coattach.realtmux.test.ts new file mode 100644 index 0000000000..a101715e91 --- /dev/null +++ b/src/core/relay-coattach.realtmux.test.ts @@ -0,0 +1,156 @@ +// AUDIT A13, AGAINST A REAL TMUX: the app's client must not detach a phone's relay-served client. +// +// pty-relay-coattach.test.ts pins the ARGV `PtyManager` builds: `-A -D` for the app's own client +// normally, `-A` alone while a relay-served client of the same node is attached. What those flags +// DO to a client that is already attached is a property of tmux, so it is measured here, with the +// flags taken from the same `tmuxAttachFlags` the spawn path calls and the exact conf `tmuxConf()` +// ships. +// +// The first test is the control, and it is the A13 mechanism itself: the app's `-A -D` detaches the +// phone's client, which exits 0 — the "The session ended (exit 0)." the phone showed. Without it, +// the second test could pass on a harness that simply cannot observe a detach. +// +// The clients are CONTROL-MODE clients (`tmux -C` over plain pipes) rather than pty-backed ones: +// they need no terminal, and node-pty is a native module this suite must not depend on. `-D` +// detaches every other client of the session whatever its mode (measured on tmux 3.4), so a control +// client stands in for the phone's pty client exactly where it matters. +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { execFileSync, spawn, type ChildProcess } from 'child_process' +import fs from 'fs' +import path from 'path' +import { tmuxAttachFlags, tmuxConf } from './pty-manager' +import { makeTmuxTmpdir } from './tmux-test-socket' + +// Only two PURE exports of pty-manager are used here; the module still imports node-pty at load, +// and a checkout whose native build was skipped (`npm ci --ignore-scripts`) has no binary for it. +// Nothing below spawns through it — the clients are plain `child_process` children. +vi.mock('node-pty', () => ({ + spawn: () => { + throw new Error('node-pty is not used by this suite') + } +})) + +/** A private socket, never the app's — this must not touch a running nodeterm's tmux server. */ +const SOCKET = `nt-relayco-${process.pid}` + +function findTmux(): string | null { + for (const c of ['/usr/bin/tmux', '/usr/local/bin/tmux', '/opt/homebrew/bin/tmux', '/bin/tmux']) { + if (fs.existsSync(c)) return c + } + return null +} + +const TMUX = process.platform === 'win32' ? null : findTmux() +let work: string +let conf: string +const children: ChildProcess[] = [] + +/** The sandbox dir LAST: a caller never chooses which server it reaches. */ +function tmuxEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, TMUX_TMPDIR: work } + delete env.TMUX + delete env.TMUX_PANE + return env +} + +function tmux(args: string[]): string { + return execFileSync(TMUX as string, ['-L', SOCKET, ...args], { + encoding: 'utf8', + env: tmuxEnv(), + stdio: ['ignore', 'pipe', 'pipe'] + }) +} + +/** A tmux client attaching the way `spawnSession` attaches a node: `new-session -s `. */ +function attach(session: string, flags: string[]): ChildProcess { + const child = spawn( + TMUX as string, + ['-L', SOCKET, '-f', conf, '-C', 'new-session', ...flags, '-s', session, 'sleep', '300'], + // stdin stays OPEN: a control client exits on EOF, which would read as a detach. + { env: tmuxEnv(), stdio: ['pipe', 'pipe', 'pipe'] } + ) + child.stdout?.resume() + child.stderr?.resume() + children.push(child) + return child +} + +/** The pids of the clients attached to `session` — the tmux client processes themselves. */ +function clientPids(session: string): number[] { + try { + return tmux(['list-clients', '-t', `=${session}`, '-F', '#{client_pid}']) + .split('\n') + .filter(Boolean) + .map(Number) + } catch { + return [] + } +} + +async function waitUntil(pred: () => boolean, ms = 4000): Promise { + const t0 = Date.now() + while (!pred()) { + if (Date.now() - t0 > ms) throw new Error('timed out') + await new Promise((r) => setTimeout(r, 25)) + } +} + +/** Resolve with the exit code, or `'still-attached'` if the client is alive after `ms`. */ +function exitWithin(child: ChildProcess, ms: number): Promise { + if (child.exitCode !== null) return Promise.resolve(child.exitCode) + return new Promise((resolve) => { + const timer = setTimeout(() => resolve('still-attached'), ms) + child.once('exit', (code) => { + clearTimeout(timer) + resolve(code) + }) + }) +} + +beforeAll(() => { + if (!TMUX) return + work = makeTmuxTmpdir('ntrelayco-', SOCKET) + conf = path.join(work, 'tmux.conf') + fs.writeFileSync(conf, tmuxConf(2000)) +}) + +afterAll(() => { + if (!TMUX) return + for (const c of children) if (c.exitCode === null) c.kill() + try { + tmux(['kill-server']) // our PRIVATE socket — nothing else can be on it + } catch { + /* already gone */ + } + fs.rmSync(work, { recursive: true, force: true }) +}) + +describe('a relay-served client beside the app client on a real tmux (audit A13)', () => { + it.skipIf(!TMUX)('control: the app attaching with -A -D detaches the phone, which exits 0', async () => { + const session = 'nt-a13-kick' + const phone = attach(session, tmuxAttachFlags(true)) // the relay-served pty (attachDetached) + await waitUntil(() => clientPids(session).includes(phone.pid as number)) + + // The pre-A13 app client: no relay client known, or not asked. + const app = attach(session, tmuxAttachFlags(false)) + await waitUntil(() => clientPids(session).includes(app.pid as number)) + + expect(await exitWithin(phone, 3000)).toBe(0) + expect(clientPids(session)).toEqual([app.pid]) + }) + + it.skipIf(!TMUX)('the fix: the app attaching beside a live relay client leaves the phone attached', async () => { + const session = 'nt-a13-beside' + const phone = attach(session, tmuxAttachFlags(true)) + await waitUntil(() => clientPids(session).includes(phone.pid as number)) + + // What `spawnSession` now builds while a relay-served client of the node is attached. + const app = attach(session, tmuxAttachFlags(false, true)) + await waitUntil(() => clientPids(session).includes(app.pid as number)) + + // A `-D` detach happens while tmux processes the attach, before the new client is listed, so + // the control above exits well inside this window. + expect(await exitWithin(phone, 750)).toBe('still-attached') + expect(clientPids(session).sort()).toEqual([phone.pid, app.pid].sort()) + }) +}) From 71290de62ef89fb9a798cf0182ae773dda1696f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 13:12:03 +0000 Subject: [PATCH 035/284] fix(relay): a phone-started session gets its agent env and a proven owner (A72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What A33 already closed, verified: the relay pty.attach of a session the phone starts passes the host-resolved agentId into attachDetached, and spawnSession hands it to hookServer.buildPtyEnv, so the created session carries NODETERM_AGENT_ID, NODETERM_PERM_WAIT_SECS (Claude, with hook-reply approvals on), NODETERM_CANVAS_CONTROL (per canControlCanvas) and, for Codex, the managed launcher directory first on PATH. That was untested; it is now pinned on the tmux argv the session is created from. What was still missing: pane ownership. attachDetached never goes through spawnNew, so a phone-started pane was never recorded in the pane-ownership ledger and agent messaging refused it for its whole life (unproven-target-owner). - HostNewSessions.resolve also returns ownerProjectId: the index ENTRY id the host's own lookup matched (projectTargetInfo is keyed by it) — the machine-local id the canvas passes as its ownerProjectId and messaging compares against. The phone names a project; it never supplies an owner. The resolver moved out of src/main/index.ts into src/main/remote/host-new-sessions.ts (createHostNewSessions) so it can be tested; agent validation now reads BUILTIN_AGENT_IDS. - PtyManager.attachDetached records that owner via the same shouldRecordOwnership/recordFreshSpawnOwner gate spawnNew uses. The relay host passes it only when its probe of this manager (sessionExists, fail-safe toward "exists") found no session; the manager adds one refusal of its own: a live generation of the node in this process makes the attach a join, and a join never records. - A join, a failed or slow probe, and an older phone that names no project all behave exactly as before. Tests: src/core/pty-relay-create.test.ts (real hook server, tmux argv: agent env for Claude and Codex, bare env without an agent, owner recorded on create, never on a join); host-new-sessions.test.ts; remote-security.test.ts (owner reaches the spawn only on create; failed and timed-out probes are joins; no project = no resolve); the relay interop fixture now uses the real resolver and RelayInteropTest asserts agentId + ownerProjectId on a create and nothing on a join. Mutation checks: removing the record, removing the join guard, dropping the agent in attachDetached, dropping ownerProjectId from the resolver, accepting any agent id, and applying the create on a join each turn at least one of these tests red. Departures from the audit: the Codex launcher is not reinstalled on this path (boot's caps refresh installs it, and the phone launches plain `codex`, a supported mode); project spawn overrides (.nodeterm/settings.json env/shell) are still not applied to a phone-started session, because attachDetached is synchronous and the read is async — a follow-up, not part of A72. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../protocol/src/test/interop/host-fixture.ts | 14 +- .../dev/nodeterm/protocol/RelayInteropTest.kt | 25 +- docs/android.md | 2 +- src/core/agents/pane-ownership.ts | 13 +- src/core/pty-manager.ts | 28 ++- src/core/pty-relay-create.test.ts | 213 ++++++++++++++++++ src/main/index.ts | 28 +-- src/main/remote/host-new-sessions.test.ts | 71 ++++++ src/main/remote/host-new-sessions.ts | 59 +++++ src/main/remote/host-service.ts | 14 +- src/main/remote/remote-security.test.ts | 55 ++++- 11 files changed, 480 insertions(+), 42 deletions(-) create mode 100644 src/core/pty-relay-create.test.ts create mode 100644 src/main/remote/host-new-sessions.test.ts create mode 100644 src/main/remote/host-new-sessions.ts diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index 60a73bfc1e..7751ae9c94 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -20,6 +20,7 @@ import { type HostPtyManager, type HostSession } from '../../../../../src/main/remote/host-service' +import { createHostNewSessions } from '../../../../../src/main/remote/host-new-sessions' import { createPairingService } from '../../../../../src/main/pairing-service' import type { DetachedSinks } from '../../../../../src/core/pty-manager' import type { PtyManager } from '../../../../../src/core/pty-manager' @@ -103,7 +104,7 @@ async function runRelay(): Promise { attachDetached(persistKey, s, options) { sinks = s const id = `sess-${++sessionCounter}` - emit({ event: 'attach', persistKey, cols: options?.cols, rows: options?.rows, adaptsToSize: s.adaptsToSize, cwd: options?.cwd, accountId: options?.accountId }) + emit({ event: 'attach', persistKey, cols: options?.cols, rows: options?.rows, adaptsToSize: s.adaptsToSize, cwd: options?.cwd, accountId: options?.accountId, agentId: options?.agentId, ownerProjectId: options?.ownerProjectId }) setTimeout(() => s.onData(`hello ${persistKey}\r\n`), 20) return id }, @@ -215,10 +216,13 @@ async function runRelay(): Promise { ), ackRead: (nodeId) => emit({ event: 'ack', nodeId }) }, - // A33: the host resolves where a phone-started session is created (fake registry: project p1). - newSessions: { - resolve: ({ projectId, accountId }) => (projectId === 'p1' ? { cwd: '/repo', ...(accountId ? { accountId } : {}) } : null) - }, + // A33/A72: the desktop's REAL resolver decides what a phone-started session is created with — + // folder, account, agent and pane owner — over a fake index (local folder project p1) and one + // logged-in managed Claude account. + newSessions: createHostNewSessions({ + projectTargetInfo: (projectId) => (projectId === 'p1' ? { cwd: '/repo' } : null), + claudeAccounts: () => [{ id: 'acct-1' }] + }), onPeerReady: (s) => { emit({ event: 'peer-ready', sas: s.sas(), pub: s.peerPublicKeyB64() }) if (approveAfter >= 0) setTimeout(approveNow, approveAfter) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index 21d07f61c8..9ce9dd5bb2 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -334,8 +334,10 @@ class RelayInteropTest { @Test fun `a session the phone starts is created in its project by the desktop`() = runBlocking { - // A33, through the desktop's real pty.attach handler: the host applies the project folder - // and account (the fixture's session "does not exist", so this attach creates it). + // A33 + A72, through the desktop's real pty.attach handler and its real resolver: the host + // applies the project folder and account, the AGENT (what gives the session its + // agent-specific hook env: approvals, canvas control) and the pane OWNER it resolved from + // its own index. The fixture's session "does not exist", so this attach creates it. val h = start() connect(h).connection.use { conn -> val s = conn.attach("term-new-9", 90, 30, RecordingSink(), dev.nodeterm.protocol.host.NewSessionHint("p1", "acct-1", "claude")) @@ -344,6 +346,25 @@ class RelayInteropTest { assertTrue(s.fresh) assertEquals("/repo", ev.str("cwd")) assertEquals("acct-1", ev.str("accountId")) + assertEquals("claude", ev.str("agentId")) + assertEquals("p1", ev.str("ownerProjectId")) + s.detach() + } + } + + @Test + fun `a hint on an attach that joins a live session changes nothing, the owner included`() = runBlocking { + // A72: the phone must not be able to claim a running pane for its project by naming one. + // The fixture's `term-a` exists, so this attach is a join and the host applies nothing. + val h = start() + connect(h).connection.use { conn -> + val s = conn.attach("term-a", 90, 30, RecordingSink(), dev.nodeterm.protocol.host.NewSessionHint("p1", null, "claude")) + val ev = h.awaitEvent("attach") + assertEquals("term-a", ev.str("persistKey")) + assertEquals(false, s.fresh) + assertEquals(null, ev["cwd"]) + assertEquals(null, ev["agentId"]) + assertEquals(null, ev["ownerProjectId"]) s.detach() } } diff --git a/docs/android.md b/docs/android.md index 05c4c9e142..2c2ed818eb 100644 --- a/docs/android.md +++ b/docs/android.md @@ -46,7 +46,7 @@ describes as the future. The Android client implements what the host actually se | Phone action | Relay (host-service.ts) | Direct SSH | |---|---|---| | List projects/sessions/status | `projects.list` → the `--NT-PROJECTS-SPLIT--` blob | same blob, from `workspace.json` + `tmux ls` + `agent-status.json`; the v3 index is resolved like `WorkspaceStore` (folder refs → `.nodeterm/project.json`, SSH refs → `cache`, data refs → `inline-projects/.json`) | -| Open a terminal | `pty.attach` → `{streamId, fresh}` (a session the phone starts adds `projectId`/`accountId`/`agentId`, which the desktop resolves itself and applies only when creating it), Snapshot frames, Output frames; a node of an SSH project is attached over that project's ControlMaster (`requireRemote`) or refused | `has-session`, then a pty exec of `tmux attach-session` — never `new-session`: a session that is not running, or a node of an SSH project, is refused with `NeedsRelayException` and the app offers the relay | +| Open a terminal | `pty.attach` → `{streamId, fresh}` (a session the phone starts adds `projectId`/`accountId`/`agentId`; the desktop resolves them itself — the project folder, the account, the agent's hook env and the pane's owning project — and applies them only when this attach creates the session), Snapshot frames, Output frames; a node of an SSH project is attached over that project's ControlMaster (`requireRemote`) or refused | `has-session`, then a pty exec of `tmux attach-session` — never `new-session`: a session that is not running, or a node of an SSH project, is refused with `NeedsRelayException` and the app offers the relay | | Type / resize | `OP.Input` / `OP.Resize` frames | channel stdin / window-change | | Scroll | `pty.scroll` (host writes SGR wheel events) | the phone writes the same SGR wheel events | | Detach / end | `pty.kill` / `pty.destroy` | close channel / `kill-session` | diff --git a/src/core/agents/pane-ownership.ts b/src/core/agents/pane-ownership.ts index 1a5aebd158..1dd44d8d31 100644 --- a/src/core/agents/pane-ownership.ts +++ b/src/core/agents/pane-ownership.ts @@ -14,10 +14,16 @@ * session existed to reattach to). An attach/co-attach to a session someone else spawned never * records, so a second project that merely OPENS a node id another project is running cannot * claim it. `fresh` is the manager's own signal, not anything off the wire or the file. + * The one other spawn path is a session the PHONE starts over the relay (`PtyManager. + * attachDetached`, audit A72): recorded only when the relay host's probe of the same manager + * (`sessionExists`, fail-safe toward "exists") found no session AND this process holds no live + * generation of the node — the same "genuinely brought into being" fact, asked the only way a + * synchronous attach can ask it. * - The owner value is the machine-local project id the renderer passed at the create call * (`PtyCreateOptions.ownerProjectId`) — the entry id (`IndexEntryV3.id`), never the file's * git-copied `id`. A cloned repo gets a fresh entry id, so it cannot inherit another copy's - * ownership either. + * ownership either. For a phone-started session the relay host resolves that same entry id from + * its own index (`createHostNewSessions`) — the phone names a project, it never supplies an owner. * * ── COLD STATE / RESTART (stated honestly) ────────────────────────────────────────────────────── * This ledger is IN-MEMORY and starts empty every run. After an app restart the tmux SERVER @@ -58,8 +64,9 @@ export function shouldRecordOwnership( } /** - * Record the owner of a node whose pane was just GENUINELY spawned. Call site: `spawnNew`, guarded - * by `fresh === true` and a present `persistKey` + `ownerProjectId`. A fresh spawn for an id that + * Record the owner of a node whose pane was just GENUINELY spawned. Call sites: `spawnNew`, guarded + * by `fresh === true` and a present `persistKey` + `ownerProjectId`, and `attachDetached` for a + * session the relay host creates (see the header). A fresh spawn for an id that * somehow already has an entry OVERWRITES it — the live pane is the one that just came into being. * A missing owner is a no-op (old callers / tests that pass no `ownerProjectId` simply leave the * pane unproven, which fails closed downstream — the correct direction). diff --git a/src/core/pty-manager.ts b/src/core/pty-manager.ts index d419a80688..e6502cb54a 100644 --- a/src/core/pty-manager.ts +++ b/src/core/pty-manager.ts @@ -2272,10 +2272,15 @@ export class PtyManager { // // Those two are NOT merely attaches — `attachDetached` goes through `tmux new-session -A`, which // CREATES when the host's session died (that is exactly what `sessionExists` is asked ahead of, - // and what `fresh` reports). What makes them override-less is narrower and true either way: the - // relay host passes only `{cols, rows}` (host-service.ts), so those spawns carry no - // `ownerProjectId` — and no cwd, agent, account or hook env either. A mirrored client that - // lands on a re-created session gets the same bare login shell it got before this feature. + // and what `fresh` reports). They get no project overrides (`.nodeterm/settings.json` env and + // shell): being synchronous, nothing awaits that read for them. What the relay host DOES pass + // depends on who is attaching — a phone joining or re-creating an existing node sends + // `{cols, rows}` only, so that spawn keeps the bare login shell it always got (the hook env's + // node id and endpoint included, as for every persistKey); a phone STARTING a session names + // its project, and the host resolves a cwd, account, agent and `ownerProjectId` for it (audits + // A33, A72 — `HostNewSessions`), applied only when that attach creates the session. The agent + // gives that session the same agent-gated hook env a canvas spawn gets, and `attachDetached` + // records the owner. const projectOverrides = await this.projectSpawnOverrides(options) // Resolved HERE for the same synchronous-spawnSession reason as projectOverrides — and the // relay host's detached callers pass no `sshRemote` at all, so this is the one path that needs @@ -2732,7 +2737,20 @@ export class PtyManager { sinks: DetachedSinks, options: Omit = { cols: 80, rows: 24 } ): string { - return this.spawnSession({ ...options, persistKey }, null, sinks) + // PANE OWNERSHIP for a session the relay host CREATES (audit A72) — the counterpart of the + // `spawnNew` record, for the one spawn path that never goes through it. `ownerProjectId` has a + // single caller: the relay host's `pty.attach`, which passes it only when (a) this manager's + // own `sessionExists` probe (fail-safe toward "exists") found no session, so this attach is the + // one creating it, and (b) the owner came from the host's own index (`HostNewSessions` — the + // entry id the canvas passes as ITS `ownerProjectId`), never off the wire. One more refusal is + // answered here, from this process's own state: a live generation of this node means the + // attach is a JOIN whatever the caller probed, and a join never records + // (`agents/pane-ownership.ts`). No owner — every join, every older phone — records nothing. + const joining = !!this.liveSessionForPersistKey(persistKey) + const sessionId = this.spawnSession({ ...options, persistKey }, null, sinks) + if (shouldRecordOwnership(!joining, persistKey, options.ownerProjectId)) + recordFreshSpawnOwner(persistKey, options.ownerProjectId) + return sessionId } /** diff --git a/src/core/pty-relay-create.test.ts b/src/core/pty-relay-create.test.ts new file mode 100644 index 0000000000..496ed5ac85 --- /dev/null +++ b/src/core/pty-relay-create.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform, type FakePlatform } from './platform-fake' +import { IPC } from '../shared/ipc' +import { DEFAULT_SETTINGS } from '../shared/types' +import { sessionName } from './tmux-naming' +import { hookServer, PERM_WAIT_SECS_DEFAULT } from './agents/hook-server' +import { paneOwnerProject, resetPaneOwnershipForTests } from './agents/pane-ownership' +import { codexLauncherDir } from './codex-identity-proxy' + +/** + * AUDIT A72 — a session the PHONE starts gets the agent's env and a proven owner. + * + * The relay `pty.attach` of a fresh node id creates its tmux session through `attachDetached`, and + * tmux reads `-e` only at creation: whatever env that spawn lacks, the session lacks for life. A33 + * made the relay host resolve the phone's project/account/agent and pass them here when the attach + * creates the session; this file pins what that spawn then carries, and the one thing it lacked: + * + * - the agent-gated hook env (`NODETERM_AGENT_ID`, the Claude hook-reply wait, the canvas-control + * grant `canControlCanvas` decides) and, for Codex, the managed launcher's directory on PATH — + * the same `-e` pairs a canvas-started spawn gets, asserted on the tmux ARGV because that is + * what the session is created from; + * - PANE OWNERSHIP: recorded for the host-resolved owner (`agents/pane-ownership.ts`), and never + * for an attach this process can see is a join — so a phone cannot claim a live pane. + * + * Driven through a REAL hook server (as hook-server.env.test.ts does): a stubbed `buildPtyEnv` + * would assert the arguments, not the env the session ends up with. + */ + +const spawnArgs: string[][] = [] + +vi.mock('./session-host-backend', async () => + (await import('./__fixtures__/no-session-host')).noSessionHost() +) + +vi.mock('node-pty', () => ({ + spawn: (_file: string, args: string[]) => { + spawnArgs.push(args) + return { + onData: () => {}, + onExit: () => {}, + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => {}, + pid: 1 + } + } +})) + +/** Every tmux side-call goes through child_process; `has-session` answers from the set. */ +const liveTmuxSessions = new Set() + +vi.mock('child_process', () => { + type Cb = (err: Error | null, res?: { stdout: string; stderr: string }) => void + const execFile = (_file: string, args: string[], a?: unknown, b?: unknown): unknown => { + const cb = (typeof a === 'function' ? a : b) as Cb | undefined + const ok = (stdout: string): void => cb?.(null, { stdout, stderr: '' }) + if (args.includes('has-session')) { + const target = args[args.indexOf('-t') + 1] + if (liveTmuxSessions.has(target)) ok('') + else cb?.(Object.assign(new Error('no such session'), { code: 1 })) + } else if (args[0] === '-ilc') { + ok('__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__') + } else { + ok('') + } + return {} + } + return { execFile, execFileSync: (): string => '' } +}) + +// Hermetic tmux resolution (issue #160): the tmux-backed spawn is what is under test. +vi.mock('./tmux-hint', async (importOriginal) => ({ + ...(await importOriginal()), + findFixedTmux: () => '/usr/bin/tmux' +})) + +// A machine with pty devices to spare (see pty-single-user.test.ts). +vi.mock('./pty-devices', async (importOriginal) => ({ + ...(await importOriginal()), + readPtyDevices: () => ({ ceiling: 511, inUse: 8 }) +})) + +const ALICE = 1 +const NODE = 'term-new-1' + +/** The session env tmux is told to create the session with: every `-e KEY=VALUE` on the argv. */ +function sessionEnv(args: string[]): Record { + const env: Record = {} + args.forEach((a, i) => { + if (a !== '-e') return + const pair = args[i + 1] + const eq = pair.indexOf('=') + env[pair.slice(0, eq)] = pair.slice(eq + 1) + }) + return env +} + +describe('a session the phone starts over the relay — audit A72', () => { + let fake: FakePlatform + let userDataDir: string + + beforeAll(async () => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-relaycreate-')) + fake = fakePlatform({ userDataDir }) + initPlatform(fake) + // buildPtyEnv answers {} until the server has a port and a token. + await hookServer.start() + }) + afterAll(() => { + hookServer.stop() + resetPlatformForTests() + try { + fs.rmSync(userDataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 }) + } catch { + /* a temp dir we could not remove is not a test result */ + } + }) + beforeEach(() => { + spawnArgs.length = 0 + liveTmuxSessions.clear() + resetPaneOwnershipForTests() + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + resetPaneOwnershipForTests() + }) + + async function tmuxManager() { + const { PtyManager } = await import('./pty-manager') + const m = new PtyManager() + m.init(() => ({ ...DEFAULT_SETTINGS, hookReplyApprovals: true })) + m.registerIpc() + return m + } + const sinks = () => ({ onData: vi.fn(), onExit: vi.fn() }) + const create = (extra: Record = {}) => + fake.handlers[IPC.ptyCreate](ALICE, { cols: 80, rows: 24, persistKey: NODE, ...extra }) as Promise<{ + sessionId: string + fresh: boolean + }> + + it("creates a Claude session with the agent's hook env: identity, approval wait, canvas control", async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, cwd: userDataDir, agentId: 'claude', ownerProjectId: 'proj-A' }) + + expect(spawnArgs).toHaveLength(1) + const env = sessionEnv(spawnArgs[0]) + expect(env.NODETERM_NODE_ID).toBe(NODE) + expect(env.NODETERM_AGENT_ID).toBe('claude') + // Deterministic hook-reply approvals: without this the phone's `approvals.answer` has nothing + // held to answer, and the permission prompt only ever reaches the pane. + expect(env.NODETERM_PERM_WAIT_SECS).toBe(String(PERM_WAIT_SECS_DEFAULT)) + expect(env.NODETERM_CANVAS_CONTROL).toBe('1') + expect(spawnArgs[0][spawnArgs[0].indexOf('-s') + 1]).toBe(sessionName(NODE)) + }) + + it("puts a Codex session's managed launcher directory first on its PATH", async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, agentId: 'codex', ownerProjectId: 'proj-A' }) + const env = sessionEnv(spawnArgs[0]) + expect(env.NODETERM_AGENT_ID).toBe('codex') + expect(env.PATH.split(path.delimiter)[0]).toBe(codexLauncherDir()) + }) + + it('an attach with no agent (a join, an older phone) keeps the bare hook env it always had', async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24 }) + const env = sessionEnv(spawnArgs[0]) + // Status badges and inbox events never depended on the agent: node id + endpoint are there. + expect(env.NODETERM_NODE_ID).toBe(NODE) + expect(env.NODETERM_HOOK_ENDPOINT).toBeTruthy() + expect(env).not.toHaveProperty('NODETERM_AGENT_ID') + expect(env).not.toHaveProperty('NODETERM_PERM_WAIT_SECS') + expect(env).not.toHaveProperty('NODETERM_CANVAS_CONTROL') + }) + + it("records the pane's host-resolved owner when the relay host creates the session", async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, agentId: 'claude', ownerProjectId: 'proj-A' }) + expect(paneOwnerProject(NODE)).toBe('proj-A') + }) + + it('records nothing for an attach that names no owner (every join, every older phone)', async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, agentId: 'claude' }) + expect(paneOwnerProject(NODE)).toBeUndefined() + }) + + it('a live generation in this process makes it a JOIN: the phone cannot claim the pane', async () => { + const m = await tmuxManager() + // The canvas spawned the node first, for its own project… + await create({ ownerProjectId: 'proj-A' }) + expect(paneOwnerProject(NODE)).toBe('proj-A') + // …then an attach naming another project arrives (a caller whose probe was stale). + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, ownerProjectId: 'proj-B' }) + expect(paneOwnerProject(NODE)).toBe('proj-A') + }) + + it('a join of a session another relay stream holds records nothing either', async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24 }) + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, ownerProjectId: 'proj-B' }) + expect(paneOwnerProject(NODE)).toBeUndefined() + }) +}) diff --git a/src/main/index.ts b/src/main/index.ts index ff4595c6e8..358a9a8b21 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -215,7 +215,6 @@ import { codexHome } from '../core/usage/codex-usage' import { isAsyncSubagentLaunch, grokRawFields, type NormalizedAgentEvent } from '../shared/agents/normalize' import { applyGrokHookSession } from '../core/grok-hook-session' import { agentAccountColor } from '../shared/agents/account-color' -import { AGENT_CONFIG, type BuiltinAgentId } from '../shared/agents/config' import { setRemoteTranscriptReader, TITLE_TAIL_BYTES, @@ -301,6 +300,7 @@ import { RELAY_URL } from './remote/host-service' import { initStandingHost } from './remote/standing-host' +import { createHostNewSessions } from './remote/host-new-sessions' import { killRelayHostsByPeerKey } from './remote/relay-host' import { initRelayHost } from './remote/relay-host-service' import { createRevoker } from './remote/revocation' @@ -4048,23 +4048,15 @@ app.whenReady().then(async () => { return sshRemote ? { where, sshRemote } : { where } } }, - // A session the phone starts is created in its project's folder, under the account it chose, - // resolved HERE from the host's own registry and settings (audit A33: the phone's `cd`/env launch - // prefix is POSIX-only, so on Windows it was dropped). Only a local folder project qualifies — - // the same set the registrar accepts — and only a local, logged-in managed Claude account. - newSessions: { - resolve: ({ projectId, accountId, agentId }: { projectId: string; accountId?: string; agentId?: string }) => { - const info = workspaceStore.projectTargetInfo(projectId) - if (!info || info.ssh || !info.cwd) return null - const account = - accountId && agentId === 'claude' && - (settingsStore.get().claudeAccounts ?? []).some((a) => a.id === accountId && !a.pending && !a.host) - ? accountId - : undefined - const agent = agentId && Object.prototype.hasOwnProperty.call(AGENT_CONFIG, agentId) ? (agentId as BuiltinAgentId) : undefined - return { cwd: info.cwd, ...(account ? { accountId: account } : {}), ...(agent ? { agentId: agent } : {}) } - } - }, + // A session the phone starts is created in its project's folder, under the account and agent it + // chose, owned by that project — all resolved HERE from the host's own registry and settings + // (audit A33: the phone's `cd`/env launch prefix is POSIX-only, so on Windows it was dropped; + // audit A72: without the agent the session kept a bare hook env for life, and without an owner + // its pane stayed unproven for agent messaging). See host-new-sessions.ts. + newSessions: createHostNewSessions({ + projectTargetInfo: (projectId) => workspaceStore.projectTargetInfo(projectId), + claudeAccounts: () => settingsStore.get().claudeAccounts ?? [] + }), // A relay phone's Inbox actions (`approvals.answer` / `inbox.ack`): the SAME answer writer the // canvas Approve/Deny button uses, and the SAME ack pair the `~/.nodeterm/acks` sweep runs for a // phone on direct SSH — `ackDone` (resolve the done event, dismiss other phones' activities) diff --git a/src/main/remote/host-new-sessions.test.ts b/src/main/remote/host-new-sessions.test.ts new file mode 100644 index 0000000000..36d3fd8d62 --- /dev/null +++ b/src/main/remote/host-new-sessions.test.ts @@ -0,0 +1,71 @@ +// Audits A33 + A72: everything a phone-started session is created with is resolved by the HOST — +// the project's folder, the account, the agent, and the pane's owner — from this machine's own +// index and settings. The phone names a project; it never supplies an owner. +import { describe, expect, it } from 'vitest' +import { createHostNewSessions, type HostNewSessionsDeps } from './host-new-sessions' + +const ENTRIES: Record = { + 'entry-local': { cwd: '/repo' }, + 'entry-ssh': { cwd: '/remote/repo', ssh: { server: { host: 'h' }, remoteCwd: '/srv' } }, + 'entry-inline': {} +} +const ACCOUNTS = [ + { id: 'acct-ok' }, + { id: 'acct-pending', pending: true }, + { id: 'acct-remote', host: 'u@h' } +] + +function resolver(over: Partial = {}) { + return createHostNewSessions({ + projectTargetInfo: (id) => ENTRIES[id] ?? null, + claudeAccounts: () => ACCOUNTS, + ...over + }) +} + +describe('createHostNewSessions', () => { + it("owns the pane by the host's index ENTRY id, beside the project's folder", () => { + expect(resolver().resolve({ projectId: 'entry-local' })).toEqual({ + cwd: '/repo', + ownerProjectId: 'entry-local' + }) + }) + + it('carries a builtin agent, so the spawn gets that agent\'s hook env', () => { + for (const agentId of ['claude', 'codex', 'gemini', 'opencode', 'grok', 'copilot']) { + expect(resolver().resolve({ projectId: 'entry-local', agentId })?.agentId, agentId).toBe(agentId) + } + }) + + it('drops an agent id that is not a builtin — nothing from the wire reaches the env unchecked', () => { + for (const agentId of ['custom:abc', 'constructor', '__proto__', 'claude ', '']) { + const r = resolver().resolve({ projectId: 'entry-local', agentId }) + expect(r, agentId).not.toBeNull() + expect(r, agentId).not.toHaveProperty('agentId') + // The owner is the project's, whatever the agent field said. + expect(r?.ownerProjectId, agentId).toBe('entry-local') + } + }) + + it('resolves NOTHING — no owner, no folder — for a project this machine cannot create in', () => { + const r = resolver() + // Unknown id (e.g. a file id, or another machine's entry id): no owner can be claimed with it. + expect(r.resolve({ projectId: 'not-an-entry', agentId: 'claude' })).toBeNull() + // An SSH project's sessions run on its host, never here. + expect(r.resolve({ projectId: 'entry-ssh', agentId: 'claude' })).toBeNull() + // A cwd-less canvas has no folder to create the session in (the registrar refuses it too). + expect(r.resolve({ projectId: 'entry-inline', agentId: 'claude' })).toBeNull() + }) + + it('applies only a local, logged-in managed Claude account, and only to a Claude session', () => { + const r = resolver() + expect(r.resolve({ projectId: 'entry-local', agentId: 'claude', accountId: 'acct-ok' })?.accountId).toBe('acct-ok') + for (const accountId of ['acct-pending', 'acct-remote', 'acct-unknown']) { + expect(r.resolve({ projectId: 'entry-local', agentId: 'claude', accountId }), accountId).not.toHaveProperty( + 'accountId' + ) + } + expect(r.resolve({ projectId: 'entry-local', agentId: 'codex', accountId: 'acct-ok' })).not.toHaveProperty('accountId') + expect(r.resolve({ projectId: 'entry-local', accountId: 'acct-ok' })).not.toHaveProperty('accountId') + }) +}) diff --git a/src/main/remote/host-new-sessions.ts b/src/main/remote/host-new-sessions.ts new file mode 100644 index 0000000000..7ad57f88fd --- /dev/null +++ b/src/main/remote/host-new-sessions.ts @@ -0,0 +1,59 @@ +// Where a session the PHONE starts is created, and who owns its pane (audits A33 + A72). +// +// The relay `pty.attach` of a fresh node id creates that node's tmux session. The phone names the +// project it is starting the session in (plus the account and agent it chose); everything the +// session is created WITH is resolved here, from this machine's own index and settings — never +// taken off the wire as-is: +// +// - `cwd`: the project's folder, from the index entry. Only a local folder project qualifies (the +// same set `projects.registerNode` accepts); an unknown, SSH or cwd-less project resolves to +// null and the attach creates exactly the bare session it created before this existed. +// - `accountId`: only a local, logged-in managed Claude account, and only for a Claude session. +// - `agentId`: only a builtin agent. It is what makes the spawn's hook env agent-aware +// (`hookServer.buildPtyEnv`: `NODETERM_AGENT_ID`, the hook-reply wait for Claude, the +// canvas-control grant `canControlCanvas` decides) — a session created without it keeps its +// bare env for life, because tmux reads `-e` only when a session is created. +// - `ownerProjectId` (A72): the pane-ownership ledger's owner (`agents/pane-ownership.ts`). It is +// the index ENTRY id the lookup just matched — the machine-local id the canvas passes as its own +// `ownerProjectId`, and the id agent messaging compares against — so a phone-started pane is +// provably owned by the project it was created in, exactly like a canvas-started one. A cloned +// project gets a fresh entry id, so this can never name a git-shared file id. +// +// Every value applies only when the attach CREATES the session (the relay host decides that from +// this machine's own `sessionExists` probe); a join changes nothing. + +import { BUILTIN_AGENT_IDS, type BuiltinAgentId } from '../../shared/agents/config' +import type { HostNewSessions } from './host-service' + +export interface HostNewSessionsDeps { + /** This machine's index, keyed by the machine-local entry id (`WorkspaceStore.projectTargetInfo`). */ + projectTargetInfo(projectId: string): { cwd?: string; ssh?: unknown } | null + /** This machine's managed Claude accounts (settings.json — hand-editable, so re-checked here). */ + claudeAccounts(): ReadonlyArray<{ id: string; pending?: boolean; host?: string }> +} + +export function createHostNewSessions(deps: HostNewSessionsDeps): HostNewSessions { + return { + resolve: ({ projectId, accountId, agentId }) => { + const info = deps.projectTargetInfo(projectId) + if (!info || info.ssh || !info.cwd) return null + const account = + accountId && + agentId === 'claude' && + deps.claudeAccounts().some((a) => a.id === accountId && !a.pending && !a.host) + ? accountId + : undefined + const agent = + agentId && (BUILTIN_AGENT_IDS as readonly string[]).includes(agentId) + ? (agentId as BuiltinAgentId) + : undefined + return { + cwd: info.cwd, + // The entry the lookup above matched EXACTLY — not the phone's string re-used unchecked. + ownerProjectId: projectId, + ...(account ? { accountId: account } : {}), + ...(agent ? { agentId: agent } : {}) + } + } + } +} diff --git a/src/main/remote/host-service.ts b/src/main/remote/host-service.ts index 6f8c72f62c..91ec75435c 100644 --- a/src/main/remote/host-service.ts +++ b/src/main/remote/host-service.ts @@ -245,13 +245,18 @@ export interface HostRemoteNodes { * folder and silently dropped the account. The phone now names the project (and account/agent) on * the attach, and the HOST resolves them from its own registry and settings, applied only when the * attach creates the session. Everything is validated here; anything unknown is simply not applied. + * + * The agent id is what gives the created session its agent-specific hook env, and + * `ownerProjectId` — the index entry id, resolved by the host, never the phone's string unchecked — + * records the pane's owner the way a canvas-started spawn does (audit A72). Production: + * `createHostNewSessions` (host-new-sessions.ts). */ export interface HostNewSessions { resolve(req: { projectId: string accountId?: string agentId?: string - }): Pick | null + }): Pick | null } interface Stream { @@ -469,8 +474,11 @@ export function createHostHandlers( } // A phone starting a NEW session names its project (and account/agent): the host resolves the - // folder and account itself (A33). Resolved now, applied only if this attach creates the session. - let create: Pick | null = null + // folder, account, agent and pane owner itself (A33, A72). Resolved now, applied only if this + // attach creates the session — `created` below, from this machine's own `sessionExists` probe, + // which fails toward "exists". That gate is what keeps a JOIN from claiming a pane's ownership + // (`agents/pane-ownership.ts`: ownership is recorded on a genuine fresh spawn only). + let create: Pick | null = null const projectId = str(p.projectId) if (!remote && newSessions && projectId && projectId.length <= REF_MAX_LEN) { try { diff --git a/src/main/remote/remote-security.test.ts b/src/main/remote/remote-security.test.ts index 94fdde853a..397b811f11 100644 --- a/src/main/remote/remote-security.test.ts +++ b/src/main/remote/remote-security.test.ts @@ -714,10 +714,15 @@ describe('pty.attach creates a phone-started session in its project', () => { undefined, ns ) - it('applies the host-resolved cwd/account/agent when the attach creates the session', async () => { + it('applies the host-resolved cwd/account/agent/owner when the attach creates the session', async () => { const { socket, fs, pty } = makeHostFakes() ;(pty.sessionExists as ReturnType).mockResolvedValue(false) - const resolve = vi.fn(() => ({ cwd: 'C:\\repo', accountId: 'acct-1', agentId: 'claude' as const })) + const resolve = vi.fn(() => ({ + cwd: 'C:\\repo', + accountId: 'acct-1', + agentId: 'claude' as const, + ownerProjectId: 'p1' + })) const handlers = handlersWith(pty, socket, fs, newSessions(resolve)) handlers.onRpc({ id: 'n', @@ -726,17 +731,57 @@ describe('pty.attach creates a phone-started session in its project', () => { }) await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) expect(resolve).toHaveBeenCalledWith({ projectId: 'p1', accountId: 'acct-1', agentId: 'claude' }) + // The agent is what gives the created session its agent-gated hook env, and the owner is what + // makes its pane messageable (audit A72) — both reach the spawn, exactly once, on the create. expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ - cols: 80, rows: 24, cwd: 'C:\\repo', accountId: 'acct-1', agentId: 'claude' + cols: 80, rows: 24, cwd: 'C:\\repo', accountId: 'acct-1', agentId: 'claude', ownerProjectId: 'p1' }) }) + // Everything the resolver answers — the pane OWNER above all — is create-only. A join must not + // claim a live pane for the phone's project (agents/pane-ownership.ts: ownership is recorded on a + // genuine fresh spawn only), and "could not tell" is a join, never a create. + const RESOLVED = { cwd: '/repo', agentId: 'claude' as const, ownerProjectId: 'p1' } + it('applies nothing when the session already exists (a join, not a create)', async () => { const { socket, fs, pty } = makeHostFakes() ;(pty.sessionExists as ReturnType).mockResolvedValue(true) - const handlers = handlersWith(pty, socket, fs, newSessions(() => ({ cwd: '/repo' }))) - handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-a', cols: 80, rows: 24, projectId: 'p1' } }) + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + handlers.onRpc({ + id: 'n', + method: 'pty.attach', + params: { nodeId: 'term-a', cols: 80, rows: 24, projectId: 'p1', agentId: 'claude' } + }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('a probe that FAILS is a join: no owner, no agent, no folder', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockRejectedValue(new Error('EAGAIN')) + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-c', cols: 80, rows: 24, projectId: 'p1' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('a probe that never answers is a join once its budget runs out', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockReturnValue(new Promise(() => {})) + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-d', cols: 80, rows: 24, projectId: 'p1' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled(), { timeout: 3000 }) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('an older phone that names no project creates exactly as before, owner-less', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockResolvedValue(false) + const resolve = vi.fn(() => RESOLVED) + const handlers = handlersWith(pty, socket, fs, newSessions(resolve)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-e', cols: 80, rows: 24 } }) await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(resolve).not.toHaveBeenCalled() expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) }) From 437e359268186bc9d214f82143ecae04980291d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 13:22:28 +0000 Subject: [PATCH 036/284] fix(android): name Claude accounts by label, never by raw UUID (A39, A75) The New session picker printed each managed Claude account as its raw id, and the sessions row fell back to the observed account's id because it read an `account.label` the mirror never writes. Desktop: MirrorSettings.claudeAccounts entries gain additive optional `label` and `email`. One builder, `mirrorClaudeAccount` (core/agent-status-mirror.ts), re-validates both as strings (settings.json is hand-editable) and drops blank values; the desktop shell, the Server Edition and the desktop's per-host SSH slice all build their entries with it. Old readers ignore the new fields. The iOS app can adopt them unchanged (docs/mobile-usage-inbox.md now documents the entry shape). Android: ManagedAccount parses label/email; AgentNodeStatus carries the structured ObservedAccount {configDir, accountId, known} instead of a precomputed label. New AccountNames (android/protocol) is the one resolver: settings label, else usage.accounts label, else email, else "Account " - never the full UUID. For an observed account: an id -> that name (an id the mirror cannot name falls back to its dir's last segment, as the desktop's resolveObserved does, unless that segment is the id again, as for a managed dir); no id and not known -> last segment of configDir; no id and known -> nothing (system). SessionsTab's picker and row use it, and so does the Usage tab's card title, which had the same raw-id fallback. Departures from the audit: the Usage card is also routed through the helper (same bug class); a Windows-shaped config dir splits on both separators (the desktop's configDirLabel splits only on `\`). Tests: vitest for mirrorClaudeAccount, the flushed file, and a source-level pin that every claudeAccounts producer in main (2) and server (1) uses the builder. ModelTest covers parsing, the name order, blank values, the observed cases and config-dir shapes. The interop fixture's mirror now carries a node account and a settings entry built by the desktop's real mirrorClaudeAccount, and RelayInteropTest checks the phone names it "Work". Mutation-checked: dropping the label parse (4 fail), restoring the raw-id fallback (3 fail), dropping label/email from the builder (3 vitest + the interop test fail) and reverting one producer to an inline {id, dir} (the source pin fails). Verified with the full protocol suite, the offline app type-check, npm run typecheck and the affected vitest files. Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/InboxTab.kt | 13 ++- .../dev/nodeterm/android/ui/SessionsTab.kt | 5 +- .../nodeterm/protocol/model/AccountNames.kt | 86 ++++++++++++++++++ .../dev/nodeterm/protocol/model/Models.kt | 19 +++- .../nodeterm/protocol/model/ProjectsParser.kt | 13 ++- .../protocol/src/test/interop/host-fixture.ts | 15 +++- .../kotlin/dev/nodeterm/protocol/ModelTest.kt | 87 +++++++++++++++++++ .../dev/nodeterm/protocol/RelayInteropTest.kt | 5 ++ docs/mobile-usage-inbox.md | 7 ++ src/core/agent-status-mirror.test.ts | 61 +++++++++++++ src/core/agent-status-mirror.ts | 41 ++++++++- src/main/index.ts | 5 +- src/server/index.ts | 3 +- 13 files changed, 342 insertions(+), 18 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index 17d0fa5a00..b5fed81455 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -44,8 +44,10 @@ import dev.nodeterm.android.conn.HostSession import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.model.AccountNames import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.ProjectsSnapshot @@ -236,19 +238,22 @@ fun UsageTab(snapshot: ProjectsSnapshot) { return } LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - items(usage.accounts, key = { it.accountId ?: "system" }) { account -> UsageCard(account) } + items(usage.accounts, key = { it.accountId ?: "system" }) { account -> UsageCard(account, snapshot.status) } item { Text("Updated ${relativeAge(usage.updatedAt)} ago", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @Composable -private fun UsageCard(account: UsageAccount) { +private fun UsageCard(account: UsageAccount, status: AgentStatusFile?) { + // The same resolver the sessions list uses, so a managed account is never titled by its UUID. + val title = account.accountId?.let { AccountNames.managed(it, status) } + ?: account.label ?: account.email ?: AccountNames.SYSTEM Column( Modifier.fillMaxWidth().background(NtColors.panel, RoundedCornerShape(10.dp)).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(account.label ?: account.email ?: if (account.accountId == null) "System account" else account.accountId!!, fontWeight = FontWeight.SemiBold) - if (account.email != null && account.label != null) Text(account.email!!, style = MaterialTheme.typography.bodySmall) + Text(title, fontWeight = FontWeight.SemiBold) + account.email?.takeIf { it != title }?.let { Text(it, style = MaterialTheme.typography.bodySmall) } if (account.status != "ok" && account.limits.isEmpty()) { Text(if (account.status == "error") "Could not read usage." else "Usage unavailable.", color = MaterialTheme.colorScheme.onSurfaceVariant) } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt index f5523aaad6..fb17981d49 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -41,6 +41,7 @@ import dev.nodeterm.android.conn.HostSession import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AccountNames import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.Launch import dev.nodeterm.protocol.model.NodeInfo @@ -273,7 +274,7 @@ private fun SessionRow(node: NodeInfo, snapshot: ProjectsSnapshot, onClick: () - add(agent?.label ?: "Terminal") now?.activity?.let { add(it) } now?.contextPercent?.let { add("${it.toInt()}% context") } - status?.accountLabel?.let { add(it) } + AccountNames.observed(status?.account, snapshot.status)?.let { add(it) } if (!live) add("not running") }.joinToString(" · ") Text( @@ -333,7 +334,7 @@ fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate items(listOf(null) + accounts.map { it.id }, key = { "acct-${it ?: "system"}" }) { id -> Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = accountId == id, onClick = { accountId = id }) - Text(id ?: "System account") + Text(id?.let { AccountNames.managed(it, snapshot.status) } ?: AccountNames.SYSTEM) } } } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt new file mode 100644 index 0000000000..721da8a1cb --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt @@ -0,0 +1,86 @@ +package dev.nodeterm.protocol.model + +/** + * Display names for Claude accounts, so no screen prints a managed account's raw UUID (audit A39, + * A75). One resolver for every surface that names an account — the New session picker and the + * sessions row — so the two cannot disagree about the same id. + * + * Where a name comes from, in order: + * 1. `settings.claudeAccounts[].label` — written by every desktop shell and the per-host SSH slice + * (`mirrorClaudeAccount`), so it is the one source that is there whenever the account is; + * 2. `usage.accounts[].label` — a desktop older than (1) still has it, but only the desktop shell + * publishes it, only for local accounts, and only after the first usage poll; + * 3. the email, from either block; + * 4. a short form of the id ([fallbackName]) — never the whole UUID. + * + * The mirror is a file the desktop documents as hand-editable, so a blank value counts as absent. + */ +object AccountNames { + /** The name the picker gives the system `~/.claude` account (it has no record to name). */ + const val SYSTEM = "System account" + + /** How much of an unnamed account's id is shown. A managed id is a random UUID: eight hex + * characters tell two accounts apart and do not fill a line. */ + private const val SHORT_ID = 8 + + /** The display name of the managed (or linked) Claude account [accountId] on the host whose + * mirror is [status]. */ + fun managed(accountId: String, status: AgentStatusFile?): String = named(accountId, status) ?: fallbackName(accountId) + + /** Steps 1–3 above: a name the mirror gives [accountId], or null when it gives none. */ + private fun named(accountId: String, status: AgentStatusFile?): String? { + val settings = status?.settings?.claudeAccounts?.firstOrNull { it.id == accountId } + val usage = status?.usage?.accounts?.firstOrNull { it.accountId == accountId } + return settings?.label.nonBlank() + ?: usage?.label.nonBlank() + ?: settings?.email.nonBlank() + ?: usage?.email.nonBlank() + } + + /** + * What a session row says about the account the session was OBSERVED running as, or null for + * nothing. The desktop's account chip decides the same three cases (renderer/lib/accountChip.ts): + * - an account id → that account's name. An id this mirror cannot name (removed since the + * observation, or pinned to another machine) falls back to its dir, as the desktop's + * `resolveObserved` does — unless the dir's last segment is only the id again, which is what + * a managed account's dir is; then the short id; + * - no id and not known → a config dir the desktop has no record of, named by its last path + * segment (`.claude-2`), the only name such a dir has; + * - no id and known → the system account, the unremarkable case: nothing. + */ + fun observed(account: ObservedAccount?, status: AgentStatusFile?): String? { + if (account == null) return null + val dir = account.configDir?.let(::configDirLabel)?.nonBlank() + account.accountId?.nonBlank()?.let { id -> + return named(id, status) ?: dir?.takeIf { it != id } ?: fallbackName(id) + } + if (account.known) return null + return dir + } + + /** An account nothing names: a short, recognisable form of its id. */ + fun fallbackName(accountId: String): String { + val id = accountId.trim() + return "Account " + if (id.length > SHORT_ID) id.take(SHORT_ID) else id + } + + /** + * The last segment of a config dir (`configDirLabel`, renderer/lib/accountChip.ts). The dir may + * come from this desktop, an SSH host or Windows, so the separator follows the SHAPE of the + * string: a drive letter, a UNC prefix, or backslashes with no forward slash is Windows-shaped, + * and there both separators split (Windows takes either); anything else is POSIX, where a + * backslash is ordinary filename text and only `/` splits. + */ + fun configDirLabel(configDir: String): String { + val dir = configDir.trim() + if (dir.isEmpty()) return "" + val windows = Regex("^[a-zA-Z]:[\\\\/]").containsMatchIn(dir) || + dir.startsWith("\\\\") || + ('\\' in dir && '/' !in dir) + val parts = (if (windows) dir.split('\\', '/') else dir.split('/')).filter { it.isNotEmpty() } + // A bare root (`/`) has no segment to name; say the whole string rather than nothing. + return parts.lastOrNull() ?: dir + } + + private fun String?.nonBlank(): String? = this?.trim()?.takeIf { it.isNotEmpty() } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt index a45cbd4dfb..8344cf7688 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt @@ -119,8 +119,9 @@ data class AgentNodeStatus( val name: String?, val hibernated: Boolean, val updatedAt: Long, - /** Observed account label for a non-system Claude account, when the hook reported one. */ - val accountLabel: String? + /** Which Claude account the session was OBSERVED running under, when a hook reported one. + * Name it with [AccountNames.observed]; the mirror carries no display name of its own here. */ + val account: ObservedAccount? ) { val bucket: SessionBucket get() = when { @@ -131,6 +132,13 @@ data class AgentNodeStatus( } } +/** + * `ObservedClaudeAccount` (src/shared/types.ts): the account a running session's hooks reported, + * classified by the desktop from the transcript path. `accountId` null + [known] = the system + * `~/.claude`; `accountId` null + not [known] = a config dir the desktop has no record of. + */ +data class ObservedAccount(val configDir: String?, val accountId: String?, val known: Boolean) + data class AgentStatusFile( val updatedAt: Long, val nodes: Map, @@ -149,7 +157,12 @@ data class MirrorSettings( val codexApprovalValues: List ) -data class ManagedAccount(val id: String, val dir: String) +/** + * One `MirrorSettings.claudeAccounts` entry (`mirrorClaudeAccount`, agent-status-mirror.ts): [id] and + * [dir] are what a launch uses; [label] and [email] are what the phone shows (absent on a desktop + * older than them). Show it through [AccountNames], never by printing [id]. + */ +data class ManagedAccount(val id: String, val dir: String, val label: String? = null, val email: String? = null) data class MirrorUsage(val updatedAt: Long, val accounts: List) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt index d9b27b2f71..57f1067dee 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt @@ -120,7 +120,6 @@ object ProjectsParser { val nodes = LinkedHashMap() root.o("nodes")?.forEach { (nodeId, raw) -> val e = J.obj(raw) ?: return@forEach - val account = e.o("account") nodes[nodeId] = AgentNodeStatus( state = AgentState.of(e.s("state")), agentId = e.s("agentId"), @@ -128,7 +127,7 @@ object ProjectsParser { name = e.s("name"), hibernated = e.b("hibernated") == true, updatedAt = e.l("updatedAt") ?: 0, - accountLabel = account?.let { it.s("label") ?: it.s("accountId") } + account = e.o("account")?.let(::parseObservedAccount) ) } return AgentStatusFile( @@ -141,13 +140,21 @@ object ProjectsParser { ) } + /** `ObservedClaudeAccount`. A wrong-typed `known` reads as false: the entry then names its dir, + * which is the label that claims least. */ + private fun parseObservedAccount(a: JsonObject) = ObservedAccount( + configDir = a.s("configDir"), + accountId = a.s("accountId"), + known = a.b("known") == true + ) + private fun parseSettings(s: JsonObject) = MirrorSettings( claudePermissionMode = s.s("claudePermissionMode"), autoSupported = s.b("autoSupported"), claudeAccounts = s.objects("claudeAccounts").mapNotNull { a -> val id = a.s("id") ?: return@mapNotNull null val dir = a.s("dir") ?: return@mapNotNull null - ManagedAccount(id, dir) + ManagedAccount(id, dir, label = a.s("label"), email = a.s("email")) }, codexApprovalValues = s.strings("codexApprovalValues") ) diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index 7751ae9c94..f58b608291 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -21,6 +21,7 @@ import { type HostSession } from '../../../../../src/main/remote/host-service' import { createHostNewSessions } from '../../../../../src/main/remote/host-new-sessions' +import { mirrorClaudeAccount } from '../../../../../src/core/agent-status-mirror' import { createPairingService } from '../../../../../src/main/pairing-service' import type { DetachedSinks } from '../../../../../src/core/pty-manager' import type { PtyManager } from '../../../../../src/core/pty-manager' @@ -159,7 +160,19 @@ async function runRelay(): Promise { JSON.stringify({ v: 1, updatedAt: 1, - nodes: { 'term-abc-1': { state: 'blocked', agentId: 'claude', sessionId: 's-1', name: 'fix bug', updatedAt: 2 } }, + nodes: { + 'term-abc-1': { + state: 'blocked', agentId: 'claude', sessionId: 's-1', name: 'fix bug', updatedAt: 2, + account: { configDir: '/data/claude-accounts/acct-1', accountId: 'acct-1', known: true } + } + }, + // A39/A75: each entry is built by the desktop's real builder, so the phone is checked against + // what both shells write (label + email beside id + dir), not against a copy of it. + settings: { + claudePermissionMode: 'manual', + autoSupported: false, + claudeAccounts: [mirrorClaudeAccount({ id: 'acct-1', label: 'Work', email: 'me@work.example' }, '/data/claude-accounts/acct-1')] + }, inbox: { events: [{ id: 'e1', ts: 3, nodeId: 'term-abc-1', kind: 'approval', title: 'Approve Bash', pendingId: 'term-abc-1-1700000000000-42' }], nodes: { 'term-abc-1': { activity: 'Running npm test', updatedAt: 4 } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt index eb9a828185..b8f710cf38 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt @@ -1,16 +1,20 @@ package dev.nodeterm.protocol +import dev.nodeterm.protocol.model.AccountNames import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.Launch import dev.nodeterm.protocol.model.ManagedAccount import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.MirrorUsage import dev.nodeterm.protocol.model.NodeInfo import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.ObservedAccount import dev.nodeterm.protocol.model.ProjectInfo import dev.nodeterm.protocol.model.PairedHost import dev.nodeterm.protocol.model.ProjectsParser import dev.nodeterm.protocol.model.SessionBucket import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.model.UsageAccount import dev.nodeterm.protocol.pairing.PairingPayload import dev.nodeterm.protocol.relay.Framing import dev.nodeterm.protocol.relay.Op @@ -159,4 +163,87 @@ class ModelTest { assertEquals("/repo/", p.absoluteCwdOf(n(null))) assertNull(p.copy(cwd = null).absoluteCwdOf(n("./sub"))) } + + // ---- account names (A39, A75) ------------------------------------------------------------- + + private val uuid = "3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f" + private val linked = "7d1e0b2a-9f8c-4d3e-8a1b-0c9d8e7f6a5b" + + /** A mirror as a current desktop writes it: settings entries built by `mirrorClaudeAccount` + * (label + email), a usage block, and nodes observed on each kind of account. */ + private val accountsBlob = """ + --NT-STATUS-SPLIT-- + {"v":1,"updatedAt":1,"nodes":{ + "managed":{"agentId":"claude","updatedAt":1,"account":{"configDir":"/data/claude-accounts/$uuid","accountId":"$uuid","known":true}}, + "linked":{"agentId":"claude","updatedAt":1,"account":{"configDir":"/home/me/.claude-2","accountId":"$linked","known":true}}, + "unlinked":{"agentId":"claude","updatedAt":1,"account":{"configDir":"/home/me/.claude-work/","accountId":null,"known":false}}, + "system":{"agentId":"claude","updatedAt":1,"account":{"configDir":"/home/me/.claude","accountId":null,"known":true}}, + "none":{"agentId":"claude","updatedAt":1} + }, + "settings":{"claudeAccounts":[ + {"id":"$uuid","dir":"/data/claude-accounts/$uuid","label":"Work","email":"me@work.example"}, + {"id":"$linked","dir":"/home/me/.claude-2","email":"side@example.com"} + ]}, + "usage":{"updatedAt":1,"accounts":[{"accountId":null,"label":null,"email":"me@home.example","agentId":"claude","status":"ok","updatedAt":1,"limits":[]}]}} + """.trimIndent() + + @Test + fun `the mirror's account label, email and observed account are parsed`() { + val status = ProjectsParser.parseBlob(accountsBlob).status!! + assertEquals(ManagedAccount(uuid, "/data/claude-accounts/$uuid", "Work", "me@work.example"), status.settings!!.claudeAccounts[0]) + assertEquals(ManagedAccount(linked, "/home/me/.claude-2", null, "side@example.com"), status.settings!!.claudeAccounts[1]) + assertEquals(ObservedAccount("/data/claude-accounts/$uuid", uuid, true), status.nodes["managed"]!!.account) + assertEquals(ObservedAccount("/home/me/.claude-work/", null, false), status.nodes["unlinked"]!!.account) + assertNull(status.nodes["none"]!!.account) + // A desktop older than the label keeps parsing: the entry is still a launchable account. + val old = ProjectsParser.parseStatus("""{"settings":{"claudeAccounts":[{"id":"a","dir":"/d","label":7}]}}""")!! + assertEquals(ManagedAccount("a", "/d"), old.settings!!.claudeAccounts.single()) + } + + @Test + fun `a session row names the account it was observed on, never by its UUID (A39)`() { + val status = ProjectsParser.parseBlob(accountsBlob).status!! + fun row(node: String) = AccountNames.observed(status.nodes[node]!!.account, status) + assertEquals("Work", row("managed")) + assertEquals("side@example.com", row("linked"), "no label: the email") + assertEquals(".claude-work", row("unlinked"), "a dir with no record: its last segment") + assertNull(row("system"), "the system account is the unremarkable case") + assertNull(row("none")) + // An id this mirror cannot name: a linked account's dir says more than its id; a managed + // account's dir is only its id again, so the short form. + val bare = status.copy(settings = null) + assertEquals(".claude-2", AccountNames.observed(bare.nodes["linked"]!!.account, bare)) + assertEquals("Account 3f2a9c1e", AccountNames.observed(bare.nodes["managed"]!!.account, bare)) + for (n in status.nodes.keys) assertTrue(AccountNames.observed(status.nodes[n]!!.account, bare)?.contains(uuid) != true, n) + } + + @Test + fun `the new session picker names accounts label, then usage label, then email, then a short id (A75)`() { + val status = ProjectsParser.parseBlob(accountsBlob).status!! + assertEquals("Work", AccountNames.managed(uuid, status)) + assertEquals("side@example.com", AccountNames.managed(linked, status)) + // A desktop older than the settings label: the usage block's label still names it. + val usageOnly = status.copy( + settings = MirrorSettings(null, null, listOf(ManagedAccount(uuid, "/d")), emptyList()), + usage = MirrorUsage(1, listOf(UsageAccount(uuid, "From usage", "u@example.com", "claude", "ok", 1, emptyList()))) + ) + assertEquals("From usage", AccountNames.managed(uuid, usageOnly)) + assertEquals( + "u@example.com", + AccountNames.managed(uuid, usageOnly.copy(usage = MirrorUsage(1, listOf(UsageAccount(uuid, " ", "u@example.com", "claude", "ok", 1, emptyList()))))), + "a blank label is absent" + ) + assertEquals("Account 3f2a9c1e", AccountNames.managed(uuid, null)) + assertEquals("Account acct-1", AccountNames.managed("acct-1", null), "a short id is shown whole") + } + + @Test + fun `a config dir is named by the separator its shape implies`() { + assertEquals(".claude-2", AccountNames.configDirLabel("/home/me/.claude-2/")) + assertEquals("a\\b", AccountNames.configDirLabel("/home/me/a\\b"), "a POSIX backslash is filename text") + assertEquals(".claude-2", AccountNames.configDirLabel("C:\\Users\\me\\.claude-2")) + assertEquals(".claude-2", AccountNames.configDirLabel("C:/Users/me/.claude-2")) + assertEquals("/", AccountNames.configDirLabel("/")) + assertEquals("", AccountNames.configDirLabel(" ")) + } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index 9ce9dd5bb2..e6f750f02c 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -11,6 +11,7 @@ import dev.nodeterm.protocol.host.RelayApprovalRefusedException import dev.nodeterm.protocol.host.RelayApprovalRequiredException import dev.nodeterm.protocol.host.RelayApprovalTimeoutException import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.model.AccountNames import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.NodeKind @@ -165,6 +166,10 @@ class RelayInteropTest { assertEquals(AgentState.BLOCKED, st.state) assertEquals(SessionBucket.NEEDS_YOU, st.bucket) assertEquals("fix bug", st.name) + // A39/A75: the account is named by the label the desktop's own builder wrote, never its id. + assertEquals("Work", AccountNames.observed(st.account, snap.status)) + assertEquals("Work", AccountNames.managed("acct-1", snap.status)) + assertEquals("me@work.example", snap.status!!.settings!!.claudeAccounts.single().email) val ev = snap.status!!.inbox!!.events.single() assertEquals(InboxKind.APPROVAL, ev.kind) assertEquals("term-abc-1-1700000000000-42", ev.pendingId) diff --git a/docs/mobile-usage-inbox.md b/docs/mobile-usage-inbox.md index f544ca96db..75096b11bc 100644 --- a/docs/mobile-usage-inbox.md +++ b/docs/mobile-usage-inbox.md @@ -29,6 +29,13 @@ export interface MirrorFile { } ``` +`settings.claudeAccounts[]` entries are `{ id, dir, label?, email? }`, built by +`mirrorClaudeAccount` in both shells and in the desktop's per-host SSH slice. `label` and `email` +are display-only, and absent when blank or on an older desktop. A reader names a managed account by +that `label`, else the `usage` row's label, else an email, else a short form of the id — never by +the raw id (Android: `AccountNames`). The `usage` block alone is not enough for this: only the +desktop shell publishes it, it is dropped from SSH slices, and it is empty until the first poll. + ### Usage ```ts diff --git a/src/core/agent-status-mirror.test.ts b/src/core/agent-status-mirror.test.ts index 8aa145b729..15e8202ff1 100644 --- a/src/core/agent-status-mirror.test.ts +++ b/src/core/agent-status-mirror.test.ts @@ -17,6 +17,7 @@ import { flush, initAgentStatusMirror, setMirrorSettingsProvider, + mirrorClaudeAccount, setNodeHibernated, setMirrorServerProvider, setMirrorUsageProvider, @@ -311,6 +312,66 @@ describe('settings block', () => { }) }) +// The phone names a managed account from this entry (audit A39/A75): without `label`/`email` it had +// only the id, and every picker row and session row printed the account's raw UUID. +describe('settings.claudeAccounts entries (mirrorClaudeAccount)', () => { + let tmpDir: string + + beforeEach(() => { + _resetForTest() + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-status-')) + }) + + afterEach(() => { + _resetForTest() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('carries the label and email a phone shows beside the id and dir it launches with', () => { + expect( + mirrorClaudeAccount({ id: 'a1', label: 'Work', email: 'me@work.example' }, '/data/claude-accounts/a1') + ).toEqual({ id: 'a1', dir: '/data/claude-accounts/a1', label: 'Work', email: 'me@work.example' }) + }) + + it('trims, and leaves out a blank or wrong-typed value from a hand-edited settings.json', () => { + expect(mirrorClaudeAccount({ id: 'a1', label: ' Work ', email: '' }, '/d')).toEqual({ + id: 'a1', + dir: '/d', + label: 'Work' + }) + expect(mirrorClaudeAccount({ id: 'a1', label: 123, email: { x: 1 } }, '/d')).toEqual({ id: 'a1', dir: '/d' }) + // No settings-only field (host, configDir, color, createdAt…) leaks into the mirror. + const acct = { id: 'a1', label: 'L', host: 'u@h', configDir: '/x', color: '#fff', createdAt: 1 } + expect(Object.keys(mirrorClaudeAccount(acct, '/d')).sort()).toEqual(['dir', 'id', 'label']) + }) + + it('rides the flushed file unchanged', async () => { + const file = path.join(tmpDir, 'status.json') + initAgentStatusMirror(file) + setMirrorSettingsProvider(() => ({ + claudeAccounts: [mirrorClaudeAccount({ id: 'a1', label: 'Work', email: 'me@work.example' }, '/d/a1')] + })) + await flush() + expect(JSON.parse(fs.readFileSync(file, 'utf-8')).settings.claudeAccounts).toEqual([ + { id: 'a1', dir: '/d/a1', label: 'Work', email: 'me@work.example' } + ]) + }) + + // Both shells and the desktop's per-host SSH slice publish this list. A site that went back to an + // inline `{ id, dir }` would type-check (the display half is optional) and pass every other test, + // while its phones fell back to printing ids — so every producer is pinned to the one builder. + it('every producer of the list builds its entries with mirrorClaudeAccount', () => { + const root = path.resolve(__dirname, '../..') + const sites: Record = { 'src/main/index.ts': 2, 'src/server/index.ts': 1 } + for (const [rel, expected] of Object.entries(sites)) { + const src = fs.readFileSync(path.join(root, rel), 'utf8').replace(/\r\n/g, '\n') + const producers = [...src.matchAll(/claudeAccounts: \(s\.claudeAccounts \?\? \[\]\)([\s\S]*?)\.map\(\(a\) => ([^\n]*)/g)] + expect(producers.length, `${rel}: producer count`).toBe(expected) + for (const m of producers) expect(m[2], `${rel}: ${m[0]}`).toMatch(/^mirrorClaudeAccount\(a, /) + } + }) +}) + // ---- server-update (install metadata block) ------------------------------------------------- describe('server block', () => { diff --git a/src/core/agent-status-mirror.ts b/src/core/agent-status-mirror.ts index 8e02202d52..71cf73391a 100644 --- a/src/core/agent-status-mirror.ts +++ b/src/core/agent-status-mirror.ts @@ -180,8 +180,9 @@ export interface MirrorSettings { * on it only for claude (the desktop does exactly that — activePermissionMode in * renderer/state/permissionMode.ts). */ autoSupported?: boolean - /** Managed accounts usable on THIS host; dirs are absolute on that host. */ - claudeAccounts?: { id: string; dir: string }[] + /** Managed accounts usable on THIS host; dirs are absolute on that host. Build every entry with + * `mirrorClaudeAccount` — both shells and the SSH slice do — so the display half cannot drift. */ + claudeAccounts?: MirrorClaudeAccount[] /** * Which values THIS host's `codex` accepts for `--ask-for-approval`, read from its own `--help`. * Absent = not probed / not knowable for this host, which means "use `on-request` and `never` @@ -196,6 +197,42 @@ export interface MirrorSettings { codexApprovalValues?: string[] } +/** + * One managed Claude account as the mirror advertises it. `id` + `dir` are what a phone launches + * with (`CLAUDE_CONFIG_DIR`); `label` + `email` are what it SHOWS, so a picker or a session row + * never has to print the account's raw UUID. The `usage` block also carries a label per account, + * but it is local-only (dropped from SSH slices), only the desktop shell publishes it, and it is + * empty until the first usage poll — so the name has to ride here too. + * + * `label` and `email` are additive (absent on files written before them; old readers ignore + * them) and absent when blank. + */ +export interface MirrorClaudeAccount { + id: string + /** Absolute on the host whose mirror this is. */ + dir: string + /** The account's display label from settings (Settings → Accounts; defaults to its email). */ + label?: string + /** The login email captured when the account signed in. */ + email?: string +} + +/** + * Build one `MirrorSettings.claudeAccounts` entry. The ONE definition, used by the desktop shell, + * the Server Edition and the desktop's per-host SSH slice. `label`/`email` come out of a + * hand-editable settings.json that nothing checks field by field on load, so each is re-validated + * as a string here and a blank or wrong-typed value is simply left out. + */ +export function mirrorClaudeAccount( + account: { id: string; label?: unknown; email?: unknown }, + dir: string +): MirrorClaudeAccount { + const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '') + const label = text(account.label) + const email = text(account.email) + return { id: account.id, dir, ...(label ? { label } : {}), ...(email ? { email } : {}) } +} + export interface MirrorFile { v: 1 updatedAt: number diff --git a/src/main/index.ts b/src/main/index.ts index 358a9a8b21..1ba2a091e5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -171,6 +171,7 @@ import { recordRawToolEvent, recordContextUsage, setMirrorSettingsProvider, + mirrorClaudeAccount, setMirrorUsageProvider, buildMirrorUsage, onInboxActionable, @@ -2119,7 +2120,7 @@ app.whenReady().then(async () => { : {}), // unprobed ⇒ absent ⇒ the reader uses the baseline vocabulary claudeAccounts: (s.claudeAccounts ?? []) .filter((a) => !a.host && !a.pending) - .map((a) => ({ id: a.id, dir: claudeConfigDirFor(a.id) })) + .map((a) => mirrorClaudeAccount(a, claudeConfigDirFor(a.id))) } }) // Desktop → paired-phone APNs push (spec: apns-push). Feeds off the SAME actionable-event seam @@ -2325,7 +2326,7 @@ app.whenReady().then(async () => { ? { claudeAccounts: (s.claudeAccounts ?? []) .filter((a) => a.host === hostKey && !a.pending) - .map((a) => ({ id: a.id, dir: remoteAccountConfigDirAbs(home, a.id) })) + .map((a) => mirrorClaudeAccount(a, remoteAccountConfigDirAbs(home, a.id))) } : {}) // unresolved home ⇒ no accounts advertised (fail-open), autoSupported still ships } diff --git a/src/server/index.ts b/src/server/index.ts index 836a8be6bc..2ae978c539 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -68,6 +68,7 @@ import { recordAgentEvent, ackDone, setMirrorSettingsProvider, + mirrorClaudeAccount, setMirrorServerProvider, onInboxActionable, onNodeStateChange, @@ -459,7 +460,7 @@ export async function startServer( : {}), // unprobed ⇒ absent ⇒ the reader uses the baseline vocabulary claudeAccounts: (s.claudeAccounts ?? []) .filter((a) => !a.host && !a.pending) - .map((a) => ({ id: a.id, dir: claudeConfigDirFor(a.id) })) + .map((a) => mirrorClaudeAccount(a, claudeConfigDirFor(a.id))) } }) // Advertise this install's version/commit/installedAt to the phone (spec: server-update). The From 5ff8f6c221f29b949ce7d3a9f9f8f580b667ac99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 13:29:16 +0000 Subject: [PATCH 037/284] fix(android): answer a ticketed approval while the node still waits on a question (A38) Quick approve required the node to show exactly BLOCKED before it would answer an approval. The desktop publishes a concurrent (subagent) approval with its hook-reply ticket while the node keeps the parent's WAITING badge for a held AskUserQuestion (recordAgentEvent in src/core/agent-status-mirror.ts, pinned by src/core/pending-question.test.ts), so the phone answered "Already handled" for a request nobody had answered, and the subagent sat waiting until the hold timed out. QuickActions now splits the gate by how the answer is delivered: - Ticketed approval (pendingId != null): the node-state requirement is dropped. The CARD decides: a fresh card that is resolved is "already handled", an unresolved one is answered through answerApproval, and the host itself reports "gone" once the hook's hold ended. The post-"gone" EXPIRED/ALREADY_HANDLED choice uses the same card gate. - Ticketless approval (claude, keys `1`/Esc): unchanged, still requires BLOCKED. WAITING is not accepted: `1` on a node whose question picker is on screen would pick option 1 of the question. - Question (digits): unchanged, still requires WAITING, never BLOCKED. Departure from the audit's proposed fix, per the verifier: the relaxation is limited to the ticketed path; the keys paths keep their exact-state gates. One refinement the verifier did not cover: when the fresh feed no longer lists the card (the host trims its feed at 50 events, keeping only each node's newest unresolved ask), there is no card to consult, so the ticket gate falls back to the node being in a needs-you state (BLOCKED or WAITING) rather than answering blind or refusing outright. Nothing is typed on this path and the host still refuses an ended hold. This keeps the existing relay interop EXPIRED test (an event id absent from the feed) behaving as before. Verified: new QuickActionsTest (fake HostConnection, 10 cases: ticketed approval on a WAITING node is answered; its gone hold while WAITING is EXPIRED; a gone ticket settled meanwhile is already handled; a resolved ticketed card is not answered on BLOCKED or WAITING; card absent falls back to needs-you state; a host without the answer verb opens the session, no keys; a ticketless approval on WAITING is not typed; on BLOCKED it is; a question on BLOCKED is not typed; on WAITING it is). Mutation checks: against the unfixed QuickActions 4 tests fail; accepting either needs-you state on the keys gate fails the ticketless- WAITING and question-BLOCKED tests; ignoring `resolved` on the ticket gate fails the two resolved-card tests; routing ticketless approvals through the ticket gate fails the ticketless-WAITING test. Full protocol suite (89 tests, 0 skipped) and the offline app type-check pass. docs/android.md notes the new coverage. Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/protocol/host/QuickActions.kt | 69 ++++-- .../dev/nodeterm/protocol/QuickActionsTest.kt | 199 ++++++++++++++++++ docs/android.md | 5 +- 3 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt index d8a503ddd8..4899709c76 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt @@ -3,43 +3,40 @@ package dev.nodeterm.protocol.host import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.ProjectsSnapshot /** * The Inbox card actions (docs/mobile-usage-inbox.md "Quick approve", docs/hook-reply-approvals.md), * shared by every transport: * - * 1. RE-READ the status before acting, and act only if the node is still waiting on exactly this - * (a card can be minutes old; typing `1` into a pane that moved on is typing into whatever is - * there now); + * 1. RE-READ the status before acting, and act only if what the card asks is still open (a card + * can be minutes old; typing `1` into a pane that moved on is typing into whatever is there now); * 2. a held hook-reply approval (it carries a `pendingId`) is answered DETERMINISTICALLY — the * relay verb or the answer file — never with keys: while the hook holds the request, the prompt * is not on screen and a keystroke would land in the agent's composer; * 3. only a claude approval with NO ticket falls back to keys (`1` allow / Esc deny), because only * claude's prompt layout is known; anything else is "open the session". + * + * What "still open" means differs by path, on purpose (audit A38). KEYS need the node to show + * exactly the prompt they answer: `1` on a node whose AskUserQuestion picker is on screen picks + * option 1 of the question instead of approving anything, so a keyed approval needs [AgentState.BLOCKED] + * and a keyed question [AgentState.WAITING]. A TICKET does not: the desktop publishes a concurrent + * approval (a subagent's permission) with its `pendingId` while the node keeps the parent's WAITING + * badge for a held question (`recordAgentEvent`, src/core/agent-status-mirror.ts; pinned by + * src/core/pending-question.test.ts), so a ticketed answer is judged by its CARD, and the host + * refuses it ("gone") once the hook's hold has ended. */ object QuickActions { /** - * [EXPIRED]: the hook's hold ended before the answer arrived (audit A06) and the node still - * waits, so the prompt is on screen in the session — open it (with an explanation). + * [EXPIRED]: the hook's hold ended before the answer arrived (audit A06) and the request is still + * open, so the prompt is on screen in the session — open it (with an explanation). */ enum class Result { SENT, ALREADY_HANDLED, OPEN_SESSION, EXPIRED } suspend fun answerApproval(conn: HostConnection, event: InboxEvent, allow: Boolean): Result { if (event.kind != InboxKind.APPROVAL) return Result.OPEN_SESSION + if (event.pendingId != null) return answerTicket(conn, event, allow) if (!stillWaiting(conn, event, AgentState.BLOCKED)) return Result.ALREADY_HANDLED - if (event.pendingId != null && conn.capabilities.answerApprovals) { - when (conn.answerApproval(event, allow)) { - ApprovalOutcome.SENT -> return Result.SENT - // Nothing was written. If the node STILL waits, the hold timed out and the prompt is - // on screen now: "already handled" would be false — send the user to the session. - ApprovalOutcome.GONE, ApprovalOutcome.ALREADY_HANDLED -> - return if (stillWaiting(conn, event, AgentState.BLOCKED)) Result.EXPIRED else Result.ALREADY_HANDLED - ApprovalOutcome.UNSUPPORTED -> Unit - } - } - // A ticketed approval we could not answer deterministically: its prompt is not on screen - // while the hook holds it, so keys are wrong. Send the user to the session instead. - if (event.pendingId != null) return Result.OPEN_SESSION if (event.agentId != "claude") return Result.OPEN_SESSION return typeOrOpen(conn, event, if (allow) "1" else "\u001b") } @@ -52,6 +49,24 @@ object QuickActions { return typeOrOpen(conn, event, (optionIndex + 1).toString()) } + /** A held hook-reply approval: answered through its ticket or not at all — never with keys. */ + private suspend fun answerTicket(conn: HostConnection, event: InboxEvent, allow: Boolean): Result { + if (!ticketStillOpen(conn.listProjects(), event)) return Result.ALREADY_HANDLED + if (conn.capabilities.answerApprovals) { + when (conn.answerApproval(event, allow)) { + ApprovalOutcome.SENT -> return Result.SENT + // Nothing was written. If the request is STILL open, the hold timed out and the prompt + // is on screen now: "already handled" would be false — send the user to the session. + ApprovalOutcome.GONE, ApprovalOutcome.ALREADY_HANDLED -> + return if (ticketStillOpen(conn.listProjects(), event)) Result.EXPIRED else Result.ALREADY_HANDLED + ApprovalOutcome.UNSUPPORTED -> Unit + } + } + // A ticketed approval we could not answer deterministically: its prompt is not on screen + // while the hook holds it, so keys are wrong. Send the user to the session instead. + return Result.OPEN_SESSION + } + /** Keys the host could not deliver are not "sent": the prompt is on screen, so open it. A * [NeedsRelayException] propagates — the caller retries through the relay. */ private suspend fun typeOrOpen(conn: HostConnection, event: InboxEvent, keys: String): Result = try { @@ -63,11 +78,27 @@ object QuickActions { Result.OPEN_SESSION } + /** The KEYS gate: the node shows exactly [expected], and the card has not been settled. */ private suspend fun stillWaiting(conn: HostConnection, event: InboxEvent, expected: AgentState): Boolean { val snap = conn.listProjects() val status = snap.statusOf(event.nodeId) ?: return false if (status.state != expected) return false - val fresh = snap.status?.inbox?.events?.firstOrNull { it.id == event.id } + val fresh = freshCard(snap, event) return fresh == null || !fresh.resolved } + + /** + * The TICKET gate: the card decides, whatever the node's badge says (a concurrent approval rides a + * WAITING node). Only when the fresh feed no longer lists the card (the host trims its feed, and a + * snapshot may carry none) is the node's state the remaining evidence — either needs-you state, + * since nothing is typed on this path and the host itself refuses a hold that has ended. + */ + private fun ticketStillOpen(snap: ProjectsSnapshot, event: InboxEvent): Boolean { + freshCard(snap, event)?.let { return !it.resolved } + val state = snap.statusOf(event.nodeId)?.state + return state == AgentState.BLOCKED || state == AgentState.WAITING + } + + private fun freshCard(snap: ProjectsSnapshot, event: InboxEvent): InboxEvent? = + snap.status?.inbox?.events?.firstOrNull { it.id == event.id } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt new file mode 100644 index 0000000000..8991ad0bd3 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt @@ -0,0 +1,199 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.MirrorInbox +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Audit A38: which node state a quick answer requires depends on HOW it answers. + * + * The desktop publishes a concurrent (subagent) approval with its ticket while the node keeps the + * parent's WAITING badge for a held AskUserQuestion (`recordAgentEvent`, pinned by + * src/core/pending-question.test.ts "keeps the parent picker while concurrent child approvals…"). + * A ticket is answered deterministically and refused host-side once its hold ends, so the card + * decides. Keys are not: `1` typed on a node whose question picker is on screen picks option 1. + */ +class QuickActionsTest { + private val node = "term-1" + + private fun card( + id: String = "e1", + kind: InboxKind = InboxKind.APPROVAL, + pendingId: String? = "term-1-1700000000000-42", + resolved: Boolean = false, + options: List = emptyList() + ) = InboxEvent( + id = id, ts = 1, nodeId = node, agentId = "claude", sessionId = "s", kind = kind, + title = "Approve Bash", detail = null, interrupted = false, resolved = resolved, + options = options, multiSelect = false, pendingId = pendingId + ) + + private fun snapshot(state: AgentState?, vararg events: InboxEvent) = ProjectsSnapshot( + projects = emptyList(), liveSessions = setOf("nt-$node"), + status = AgentStatusFile( + updatedAt = 1, + nodes = mapOf(node to AgentNodeStatus(state, "claude", "s", null, false, 1, null)), + settings = null, usage = null, + inbox = MirrorInbox(events.toList(), emptyMap()), + serverVersion = null + ), + fetchedAt = 1 + ) + + /** Records every write; answers each `listProjects` from [snapshots] in turn (the last repeats). */ + private class FakeConn( + vararg snapshots: ProjectsSnapshot, + private val outcome: ApprovalOutcome = ApprovalOutcome.SENT, + answerApprovals: Boolean = true + ) : HostConnection { + private val queue = ArrayDeque(snapshots.toList()) + val answered = mutableListOf>() + val keys = mutableListOf() + + override val kind = TransportKind.RELAY + override val capabilities = HostCapabilities( + boardWrites = true, git = true, nodeActions = true, registerNode = true, answerApprovals = answerApprovals + ) + + override suspend fun listProjects(): ProjectsSnapshot = if (queue.size > 1) queue.removeFirst() else queue.first() + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome { + answered += event.pendingId to allow + return outcome + } + override suspend fun sendKeys(nodeId: String, keys: String) { + this.keys += keys + } + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = + error("unused") + override suspend fun wake(nodeId: String) { error("unused") } + override suspend fun refresh(nodeId: String) { error("unused") } + override suspend fun rename(nodeId: String, title: String) { error("unused") } + override suspend fun ensureBoard(projectId: String): List? = error("unused") + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = error("unused") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? = error("unused") + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = error("unused") + override suspend fun ackRead(nodeId: String, eventId: String?) { error("unused") } + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = error("unused") + override fun setOnChanged(listener: (() -> Unit)?) {} + override fun setOnClosed(listener: ((String?) -> Unit)?) {} + override fun close() {} + } + + @Test + fun `a ticketed approval on a node still WAITING on its question is answered`() = runBlocking { + val approval = card() + val question = card(id = "q1", kind = InboxKind.QUESTION, pendingId = null, options = listOf("a", "b")) + val conn = FakeConn(snapshot(AgentState.WAITING, question, approval)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(listOf>(approval.pendingId to true), conn.answered) + assertEquals(emptyList(), conn.keys) + } + + @Test + fun `a ticketed approval whose hold ended while the node WAITS opens the session`() = runBlocking { + // A06 on the concurrent path: "gone", and the card is still unresolved, so the prompt is on + // screen now. "Already handled" would leave the subagent waiting on nobody. + val approval = card() + val conn = FakeConn(snapshot(AgentState.WAITING, approval), outcome = ApprovalOutcome.GONE) + assertEquals(QuickActions.Result.EXPIRED, QuickActions.answerApproval(conn, approval, allow = false)) + assertEquals(emptyList(), conn.keys) + } + + @Test + fun `a gone ticket whose card the computer settled meanwhile is already handled`() = runBlocking { + val approval = card() + val conn = FakeConn( + snapshot(AgentState.WAITING, approval), + snapshot(AgentState.WAITING, approval.copy(resolved = true)), + outcome = ApprovalOutcome.GONE + ) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(conn, approval, allow = true)) + } + + @Test + fun `a resolved ticketed approval is not answered, whatever the node shows`() = runBlocking { + val approval = card() + for (state in listOf(AgentState.BLOCKED, AgentState.WAITING)) { + val conn = FakeConn(snapshot(state, approval.copy(resolved = true))) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(emptyList(), conn.answered) + assertEquals(emptyList(), conn.keys) + } + } + + @Test + fun `a ticketed approval the fresh feed no longer lists needs a needs-you node`() = runBlocking { + // The host trims its feed; with no card to consult, the node's state is the evidence left. + val approval = card() + val waiting = FakeConn(snapshot(AgentState.WAITING)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerApproval(waiting, approval, allow = true)) + val working = FakeConn(snapshot(AgentState.WORKING)) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(working, approval, allow = true)) + assertEquals(emptyList(), working.answered) + } + + @Test + fun `a ticketed approval this host cannot answer opens the session, never keys`() = runBlocking { + val approval = card() + val conn = FakeConn(snapshot(AgentState.WAITING, approval), answerApprovals = false) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(emptyList(), conn.keys) + } + + @Test + fun `a ticketless approval is not typed into a node WAITING on a question`() = runBlocking { + // `1` here would pick option 1 of the question on screen, not approve anything. + val approval = card(pendingId = null) + val conn = FakeConn(snapshot(AgentState.WAITING, approval)) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(emptyList(), conn.keys) + assertEquals(emptyList(), conn.answered) + } + + @Test + fun `a ticketless approval on a BLOCKED node is typed`() = runBlocking { + val approval = card(pendingId = null) + val conn = FakeConn(snapshot(AgentState.BLOCKED, approval)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(listOf("1"), conn.keys) + } + + @Test + fun `a question is not typed into a BLOCKED node`() = runBlocking { + // The digit would answer whatever permission prompt is on screen. + val question = card(kind = InboxKind.QUESTION, pendingId = null, options = listOf("yes", "no")) + val conn = FakeConn(snapshot(AgentState.BLOCKED, question)) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerQuestion(conn, question, 0)) + assertEquals(emptyList(), conn.keys) + } + + @Test + fun `a question on a WAITING node is typed`() = runBlocking { + val question = card(kind = InboxKind.QUESTION, pendingId = null, options = listOf("yes", "no")) + val conn = FakeConn(snapshot(AgentState.WAITING, question)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerQuestion(conn, question, 1)) + assertEquals(listOf("2"), conn.keys) + } +} diff --git a/docs/android.md b/docs/android.md index 2c2ed818eb..543abf5f2d 100644 --- a/docs/android.md +++ b/docs/android.md @@ -111,7 +111,10 @@ Since the audit, the SSH tests also cover: resize/keystrokes/close from a thread network I/O (a JVM stand-in for Android's StrictMode, `A01`), a transport that breaks mid-write (`A01`), and non-ASCII through an attach whose host sets no locale (`A03`). The relay interop tests cover an approval answered after the hook's hold ended (`A06`), and the desktop tests run the -generated SSH answer command under a real `/bin/sh`. +generated SSH answer command under a real `/bin/sh`. `QuickActionsTest` pins which node state each +Inbox quick answer needs (`A38`): a ticketed approval is judged by its card, so it is answered while +the node still shows WAITING for a held question; keys are typed only for a ticketless approval on a +BLOCKED node or a question on a WAITING one. ## Known gaps From 38fa6c4c496e8ba3d9be74a21642438dac2e7f93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 13:37:21 +0000 Subject: [PATCH 038/284] docs(android): record why approval cards have no "Always allow" (A56) The audit asked for an iOS-style "Always allow" on ticketless Claude approval cards: re-check the node is BLOCKED and the card unresolved, then type `2`. This commit does not build it, and says why in the three places a future reader would look. The digit is not established by this repo. The only statement of it is docs/hook-reply-approvals.md ("Digit `2`/Always allow keeps using send-keys in v1"), which describes iOS. No desktop code types `2`, and no captured prompt pins the option order. Reading the bundle of the Claude Code CLI installed here (2.1.283; a static read, not a live prompt) shows option 2 is conditional. The Bash prompt pushes `Yes`, then a "don't ask again" row only when Claude offers one for this ask (withheld when `suppressAlwaysAllowRule` is set, and behind a feature gate), then an optional "Yes, and switch to auto mode", then `No`. Other prompts differ again (the directory prompt is "Yes, for this session" / "Yes, and remember this directory" / No). So a blind `2` can deny the request or switch the whole session to auto mode, and the phone has no way to see which prompt is on screen. A button labelled "Always allow" that can do either breaks the rule that a guess degrades to nothing. - QuickActions.kt: the KDoc says there is deliberately no keyed "Always allow", with the evidence. - docs/android.md: a Known gaps entry (the verifier asked for one if the button is not built), naming the layout-independent route: answer `allow` with `updatedPermissions` from the request's `permission_suggestions`. Both fields exist in that CLI's hook schema. That changes the ~/.nodeterm/pending answer file and approvals.answer, so the desktop, iOS and Android have to change together. - docs/hook-reply-approvals.md: a caveat on the iOS `2` line. Departure from the audit: no button and no keyed `2` (the verifier's own alternative was a Known gaps entry). No behaviour changes, so there is no new test. Verified: `gradle test --offline` in android/protocol (89 tests, 0 failures) and `gradle compileKotlin --offline` in android/tools/typecheck. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../kotlin/dev/nodeterm/protocol/host/QuickActions.kt | 11 +++++++++++ docs/android.md | 11 +++++++++++ docs/hook-reply-approvals.md | 5 ++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt index 4899709c76..c77f4510fc 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt @@ -25,6 +25,17 @@ import dev.nodeterm.protocol.model.ProjectsSnapshot * badge for a held question (`recordAgentEvent`, src/core/agent-status-mirror.ts; pinned by * src/core/pending-question.test.ts), so a ticketed answer is judged by its CARD, and the host * refuses it ("gone") once the hook's hold has ended. + * + * There is deliberately NO "Always allow" here (audit A56), although iOS types `2` for it + * (docs/hook-reply-approvals.md, "Digit `2`/Always allow keeps using send-keys"). That line is the + * only place the repo states the digit: no desktop code types it, and no captured prompt pins it. + * What option 2 IS depends on the ask. Read from the Claude Code 2.1.283 bundle (not run against a + * live prompt): the Bash prompt is `Yes`, then a "don't ask again" row only when Claude offers one + * (it is withheld when `suppressAlwaysAllowRule` is set, and feature-gated), then an optional + * "Yes, and switch to auto mode", then `No`. So a blind `2` can DENY the request or switch the + * whole session to auto mode, and a ticketed approval has no prompt on screen to type into at all. + * The phone cannot see the prompt, and a guess must degrade to nothing: docs/android.md "Known + * gaps" names the layout-independent route (the hook's `updatedPermissions`). */ object QuickActions { /** diff --git a/docs/android.md b/docs/android.md index 543abf5f2d..7ad7e373eb 100644 --- a/docs/android.md +++ b/docs/android.md @@ -132,6 +132,17 @@ BLOCKED node or a question on a WAITING one. one itself, so it can see it). A phone attached over direct SSH is detached (exit 0), and so is a relay phone on a desktop older than that change; the app checks that the session is still live and reattaches. +- **No "Always allow" on approval cards** (audit `A56`; iOS has one). iOS types `2`, and + docs/hook-reply-approvals.md is the only place the repo states that digit. Read from the Claude + Code 2.1.283 bundle (not measured on a live prompt), option 2 depends on the ask: the Bash prompt + offers `Yes`, then a "don't ask again" row only when Claude has one for this ask, then an + optional "Yes, and switch to auto mode", then `No`. So a blind `2` can deny the request or switch + the session to auto mode, and the phone cannot see which. A held ticket (the default, since + `hookReplyApprovals` is on) has no prompt on screen at all. The layout-independent route is the + hook's own: answer `allow` with `updatedPermissions` taken from the request's + `permission_suggestions` (both fields exist in that CLI's hook schema). That changes the + `~/.nodeterm/pending` answer file and `approvals.answer`, so it needs the desktop, iOS and Android + together. - **Codex/Gemini/… launch flags.** A phone-started non-Claude agent launches bare (its own default approval mode): the per-agent approval table needs host facts (codex's vocabulary moved between releases, #785) the mirror only partly publishes. diff --git a/docs/hook-reply-approvals.md b/docs/hook-reply-approvals.md index 7b048f9fee..61a556bd5d 100644 --- a/docs/hook-reply-approvals.md +++ b/docs/hook-reply-approvals.md @@ -37,7 +37,10 @@ must be a **no-op** when the env var is absent (user's own terminals, older node **Answerers** (all write the same one-line answer file, atomically `printf > tmp && mv`): - **Phone (SSH)** — `InboxApproval` writes it over the connection when the approval event carries `pendingId`; else falls back to send-keys. Digit `2`/"Always allow" keeps using - send-keys in v1 (hook `updatedPermissions` is out of scope). + send-keys in v1 (hook `updatedPermissions` is out of scope). Caveat: option 2 is not fixed. + In the Claude Code 2.1.283 bundle (read, not measured live) the Bash prompt shows a "don't ask + again" row only when Claude offers one for that ask, so `2` can be `No` or "Yes, and switch to + auto mode". Android therefore has no "Always allow" (docs/android.md, Known gaps). - **Desktop canvas** — the NEEDS-YOU badge gains Approve/Deny buttons (approval events with `pendingId` only), routed over IPC to a main-side writer: local project → local fs; SSH project → write via the project's ControlMaster. So desktop users are not left staring at From a44f16c891c5d797b57bbef89ee45ce3e8b11705 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 13:43:36 +0000 Subject: [PATCH 039/284] feat(android): usage pace line and context ring on inbox cards (A58) The Usages tab showed only a limit's percentage and reset time, and the Inbox's approval, question and done cards never showed the node's context fill (only the live working cards did). docs/mobile-usage-inbox.md asks for both, and iOS has both. What changed: - android/protocol: new UsagePace (pure). The pace is computed only when resetsAt is known; the window is the limit's own windowMinutes, else 300 min for the session kind and 10080 for the weekly kinds (weekly_all, weekly_scoped), falling back to group session/weekly. Elapsed share vs usedPercent gives " usage pace faster/slower", or " usage on pace" within 5 points (inclusive). The window label ("5h", "7d", "7h") is derived from the minutes actually used. The clock is a parameter. Arithmetic is in doubles so hostile values from the hand-editable mirror are refused rather than wrapped. - android/protocol: ContextFill, one definition of the context label (clamped, rounded like the desktop's percentText) and the desktop context meter's colour bands (>85 red, >=60 yellow, else green). - InboxTab: UsageBar shows the pace line (warning colour when faster); EventCard takes inbox.nodes[nodeId].contextPercent and shows a small ring + "N% context" beside the agent chip; the live working card uses the same indicator. SessionsTab's row routes through ContextFill too, so the same node reads the same number everywhere (it truncated before). Departures from the audit / verifier: - An unknown kind with no windowMinutes gets NO pace line, instead of the verifier's "else 10080": a future provider's kind is not assumed to be weekly (a guess degrades to nothing, never to a wrong verdict). group == "session"/"weekly" is used as a second hint. - No verdict when resetsAt has passed (the percentage is the old window's) or when more time is left than the assumed window (the length is wrong for this limit, e.g. a plan whose session window is not five hours). - "on pace" band added; the doc names only slower/faster. Verified: UsagePaceTest (12 tests: null resetsAt, kind defaults for a mirror limit with windowMinutes null parsed through ProjectsParser, a reported window winning, unknown kinds, band boundaries, reset/overlong windows, injected clock, hostile values, window labels, context rounding/bands, context lookup per event node). Mutation-checked by temporarily removing the kind defaults (7 failures), the overlong-window refusal (2), making the band exclusive (1), truncating the context percent (1), switching to Long arithmetic (1) and ignoring a reported windowMinutes (2); all restored. Full protocol suite and the offline app type-check pass. No wire or host-service change. Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 4 +- .../dev/nodeterm/android/ui/InboxTab.kt | 59 +++++- .../dev/nodeterm/android/ui/SessionsTab.kt | 3 +- .../dev/nodeterm/protocol/model/UsagePace.kt | 136 ++++++++++++++ .../dev/nodeterm/protocol/UsagePaceTest.kt | 168 ++++++++++++++++++ docs/android.md | 4 +- 6 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt diff --git a/android/README.md b/android/README.md index be886e80e0..75fd95a0b3 100644 --- a/android/README.md +++ b/android/README.md @@ -28,9 +28,9 @@ it is talking to. | New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches | | Wake / refresh / rename / end session | relay (end: both) | `node.*` verbs; over SSH "end" stops the tmux session only | | Kanban board, move cards, labels | relay (read-only on SSH) | `projects.ensureBoard/setCardColumn/editCardLabels` | -| Inbox: approvals, questions, finished turns | ✓ | Held hook approvals are answered deterministically (never with keystrokes) | +| Inbox: approvals, questions, finished turns | ✓ | Held hook approvals are answered deterministically (never with keystrokes); each card shows its node's context % when known | | Read-ack (reading a finished session clears it on the computer) | ✓ | `inbox.ack` over the relay, `~/.nodeterm/acks` over SSH | -| Usage (rate limits per account) | ✓ | From the agent-status mirror | +| Usage (rate limits per account) | ✓ | From the agent-status mirror, with a pace line ("5h usage pace faster") when the reset time is known | | Notifications | local | See "Notifications" below | ## Build diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index b5fed81455..ebcb64f73a 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -48,11 +50,13 @@ import dev.nodeterm.protocol.model.AccountNames import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.ContextFill import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.ProjectsSnapshot import dev.nodeterm.protocol.model.UsageAccount import dev.nodeterm.protocol.model.UsageLimit +import dev.nodeterm.protocol.model.UsagePace import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date @@ -120,7 +124,7 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { items(actionable, key = { "a-${it.id}" }) { ev -> - EventCard(ev, titleOf(ev.nodeId), snapshot, highlight = true, onOpen = { open(ev.nodeId) }) { + EventCard(ev, titleOf(ev.nodeId), snapshot, inbox?.nodes?.get(ev.nodeId)?.contextPercent, highlight = true, onOpen = { open(ev.nodeId) }) { if (ev.kind == InboxKind.APPROVAL) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { @@ -157,7 +161,7 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro } now?.prompt?.let { Text("You: $it", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) } now?.activity?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - now?.contextPercent?.let { Text("${it.toInt()}% context", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + ContextIndicator(now?.contextPercent) } } if (archived.isNotEmpty()) { @@ -168,7 +172,7 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro } if (showArchive) { items(archived, key = { "r-${it.id}" }) { ev -> - EventCard(ev, titleOf(ev.nodeId), snapshot, highlight = false, onOpen = { open(ev.nodeId) }) {} + EventCard(ev, titleOf(ev.nodeId), snapshot, inbox?.nodes?.get(ev.nodeId)?.contextPercent, highlight = false, onOpen = { open(ev.nodeId) }) {} } } } @@ -180,6 +184,8 @@ private fun EventCard( ev: InboxEvent, title: String, snapshot: ProjectsSnapshot, + /** The node's context-window fill (`inbox.nodes[nodeId].contextPercent`), when known. */ + contextPercent: Double?, highlight: Boolean, onOpen: () -> Unit, actions: @Composable () -> Unit @@ -214,13 +220,44 @@ private fun EventCard( } Text(ev.title, fontWeight = FontWeight.SemiBold) ev.detail?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 4) } - Agent.of(ev.agentId ?: snapshot.statusOf(ev.nodeId)?.agentId)?.let { - Text(it.label, style = MaterialTheme.typography.labelSmall, color = parseHex(it.color)) + val agent = Agent.of(ev.agentId ?: snapshot.statusOf(ev.nodeId)?.agentId) + if (agent != null || ContextFill.percent(contextPercent) != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + agent?.let { Text(it.label, style = MaterialTheme.typography.labelSmall, color = parseHex(it.color)) } + ContextIndicator(contextPercent) + } } actions() } } +/** + * A node's context-window fill as a small ring and "42% context" (docs/mobile-usage-inbox.md: cards + * show the node's `contextPercent` ring when known). Nothing at all when it is unknown. The ring's + * colour follows the desktop context meter's bands ([ContextFill.level]). + */ +@Composable +private fun ContextIndicator(contextPercent: Double?) { + val raw = contextPercent ?: return + val pct = ContextFill.percent(raw) ?: return + val label = ContextFill.label(raw) ?: return + val color = when (ContextFill.level(raw)) { + ContextFill.Level.CRITICAL -> NtColors.attention + ContextFill.Level.HIGH -> NtColors.warning + ContextFill.Level.OK -> NtColors.success + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + CircularProgressIndicator( + progress = { pct / 100f }, + modifier = Modifier.size(12.dp), + color = color, + strokeWidth = 2.dp, + trackColor = NtColors.panel2 + ) + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + /** * Usages (docs/mobile-usage-inbox.md): per account, each limit's consumption and reset. Colour is * the provider's severity when given, else derived (≥90 red, ≥70 amber, else green). @@ -262,7 +299,7 @@ private fun UsageCard(account: UsageAccount, status: AgentStatusFile?) { } @Composable -private fun UsageBar(limit: UsageLimit) { +private fun UsageBar(limit: UsageLimit, now: Long = System.currentTimeMillis()) { val pct = limit.usedPercent.coerceIn(0.0, 100.0) val color: Color = when (limit.severity) { "critical", "error", "red" -> NtColors.attention @@ -279,7 +316,7 @@ private fun UsageBar(limit: UsageLimit) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Row { Text(name, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) - Text("${pct.toInt()}%" + (limit.resetsAt?.let { " · resets ${resetLabel(it)}" } ?: ""), style = MaterialTheme.typography.bodySmall) + Text("${pct.toInt()}%" + (limit.resetsAt?.let { " · resets ${resetLabel(it, now)}" } ?: ""), style = MaterialTheme.typography.bodySmall) } LinearProgressIndicator( progress = { (pct / 100.0).toFloat() }, @@ -287,6 +324,14 @@ private fun UsageBar(limit: UsageLimit) { trackColor = NtColors.panel2, modifier = Modifier.fillMaxWidth().height(6.dp) ) + // Only when the window's end and length are known (UsagePace says why it would refuse). + UsagePace.of(limit, now)?.let { reading -> + Text( + reading.line, + style = MaterialTheme.typography.labelSmall, + color = if (reading.pace == UsagePace.Pace.FASTER) NtColors.warning else MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt index fb17981d49..d5d0a38edd 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -43,6 +43,7 @@ import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TransportKind import dev.nodeterm.protocol.model.AccountNames import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.ContextFill import dev.nodeterm.protocol.model.Launch import dev.nodeterm.protocol.model.NodeInfo import dev.nodeterm.protocol.model.ProjectInfo @@ -273,7 +274,7 @@ private fun SessionRow(node: NodeInfo, snapshot: ProjectsSnapshot, onClick: () - val detail = buildList { add(agent?.label ?: "Terminal") now?.activity?.let { add(it) } - now?.contextPercent?.let { add("${it.toInt()}% context") } + ContextFill.label(now?.contextPercent)?.let { add(it) } AccountNames.observed(status?.account, snapshot.status)?.let { add(it) } if (!live) add("not running") }.joinToString(" · ") diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt new file mode 100644 index 0000000000..789890809f --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt @@ -0,0 +1,136 @@ +package dev.nodeterm.protocol.model + +/** + * The Usages tab's pace line (docs/mobile-usage-inbox.md → "iOS behavior": *compare `usedPercent` + * vs elapsed fraction of the window (`windowMinutes` else 300/10080 by kind) → "5h usage pace + * slower/faster"*). It answers one question the bare percentage cannot: at the rate this window is + * being used, will it run out before it resets? + * + * Every refusal below returns null, and null means the card shows NO pace line — a guess must + * degrade to nothing, never to a confident wrong verdict: + * - no `resetsAt`: without the window's end there is no elapsed share to compare against; + * - no window length: the limit neither reports `windowMinutes` nor has a kind whose length is + * known (a future provider's kind is not assumed to be weekly); + * - `resetsAt` already passed: the percentage belongs to a window that is over; + * - more time left than the whole window: the length we used is wrong for this limit (a plan whose + * session window is not five hours), so any verdict built on it would be too. + * + * `now` is always passed in, so the verdict is a pure function of its inputs. + */ +object UsagePace { + /** Claude's session window, the only length its `/api/oauth/usage` implies by `kind`. Claude + * reports no `windowMinutes` at all (`claude-usage-map.ts`), so without this default the pace + * line would never appear for the one provider the mirror publishes today. */ + const val SESSION_WINDOW_MINUTES = 300L + + /** The weekly windows (`weekly_all`, `weekly_scoped`). */ + const val WEEKLY_WINDOW_MINUTES = 10_080L + + /** Within this many percentage points of the elapsed share, usage is "on pace": a point or two + * either way is noise, not a trend worth a word. Inclusive at the edge. */ + const val ON_PACE_BAND = 5.0 + + enum class Pace { SLOWER, ON_PACE, FASTER } + + /** + * One limit's pace. [usedPercent] and [elapsedPercent] are both 0–100 shares of the SAME + * window ([windowMinutes] long), which is what makes them comparable. + */ + data class Reading( + val pace: Pace, + val windowMinutes: Long, + val usedPercent: Double, + val elapsedPercent: Double + ) { + /** "5h usage pace faster", "7d usage pace slower", "5h usage on pace". */ + val line: String + get() { + val window = windowLabel(windowMinutes) + return when (pace) { + Pace.SLOWER -> "$window usage pace slower" + Pace.FASTER -> "$window usage pace faster" + Pace.ON_PACE -> "$window usage on pace" + } + } + } + + /** + * The window length this limit's pace is measured against: the provider's own `windowMinutes` + * when it reports a usable one (Codex does, and it varies by plan, so it always wins), else the + * default for its kind, else null. `kind` is checked before `group` because it is the more + * specific of the two; a non-positive `windowMinutes` from a hand-edited mirror is ignored. + */ + fun windowMinutes(limit: UsageLimit): Long? { + limit.windowMinutes?.takeIf { it > 0 }?.let { return it } + val kind = limit.kind + return when { + kind == "session" -> SESSION_WINDOW_MINUTES + kind == "weekly" || kind.startsWith("weekly_") -> WEEKLY_WINDOW_MINUTES + limit.group == "session" -> SESSION_WINDOW_MINUTES + limit.group == "weekly" -> WEEKLY_WINDOW_MINUTES + else -> null + } + } + + /** The pace of [limit] at [now] (unix ms), or null when there is nothing honest to say. */ + fun of(limit: UsageLimit, now: Long): Reading? { + val resetsAt = limit.resetsAt ?: return null + val minutes = windowMinutes(limit) ?: return null + // Doubles, not Longs: both values come from a hand-editable file, and `resetsAt - now` or + // `minutes * 60_000` on a hostile value would wrap around instead of being refused. + val windowMs = minutes.toDouble() * 60_000.0 + val remainingMs = resetsAt.toDouble() - now.toDouble() + if (remainingMs <= 0.0 || remainingMs > windowMs) return null + val elapsedPercent = (1.0 - remainingMs / windowMs) * 100.0 + val used = limit.usedPercent + if (used.isNaN()) return null + val usedPercent = used.coerceIn(0.0, 100.0) + val lead = usedPercent - elapsedPercent + val pace = when { + lead > ON_PACE_BAND -> Pace.FASTER + lead < -ON_PACE_BAND -> Pace.SLOWER + else -> Pace.ON_PACE + } + return Reading(pace, minutes, usedPercent, elapsedPercent) + } + + /** + * A window length as a short label, derived from the minutes the pace was measured against — + * never from `kind` alone ("labelling a window '5h' from its kind alone can be a lie", + * `UsageLimit.windowMinutes` in src/shared/types.ts): 300 → "5h", 10080 → "7d", 90 → "90m". + */ + fun windowLabel(minutes: Long): String = when { + minutes > 0 && minutes % 1_440L == 0L -> "${minutes / 1_440L}d" + minutes > 0 && minutes % 60L == 0L -> "${minutes / 60L}h" + else -> "${minutes}m" + } +} + +/** + * A node's context-window fill (`inbox.nodes[nodeId].contextPercent`, docs/mobile-usage-inbox.md), + * as every Android surface shows it: the sessions row, the live working card and the approval, + * question and done cards. One definition, so the same node cannot read 41% on one card and 42% on + * the next. + */ +object ContextFill { + enum class Level { OK, HIGH, CRITICAL } + + /** The fill clamped to 0–100 and rounded like the desktop's `percentText` (`Math.round`), or + * null when unknown. The desktop clamps before writing; the mirror is hand-editable anyway. */ + fun percent(contextPercent: Double?): Int? { + val p = contextPercent ?: return null + if (p.isNaN()) return null + return Math.round(p.coerceIn(0.0, 100.0)).toInt() + } + + /** "42% context", or null when the fill is unknown. */ + fun label(contextPercent: Double?): String? = percent(contextPercent)?.let { "$it% context" } + + /** The desktop context meter's colour bands (`contextFillColor`, src/renderer/lib/usageFormat.ts): + * above 85 red, from 60 yellow, else green — judged on the unrounded value, as there. */ + fun level(contextPercent: Double): Level = when { + contextPercent > 85.0 -> Level.CRITICAL + contextPercent >= 60.0 -> Level.HIGH + else -> Level.OK + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt new file mode 100644 index 0000000000..fc8f924ccb --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt @@ -0,0 +1,168 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ContextFill +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.UsageLimit +import dev.nodeterm.protocol.model.UsagePace +import dev.nodeterm.protocol.model.UsagePace.Pace +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** The Usages tab's pace line and the cards' context fill (audit A58, docs/mobile-usage-inbox.md). */ +class UsagePaceTest { + private val now = 1_800_000_000_000L + private val minute = 60_000L + + private fun limit( + kind: String = "session", + used: Double = 50.0, + resetsAt: Long? = now + 150 * minute, + windowMinutes: Long? = null, + group: String? = null + ) = UsageLimit(kind, group, used, null, resetsAt, windowMinutes, null, false) + + @Test + fun `no reset time means no pace line`() { + assertNull(UsagePace.of(limit(resetsAt = null), now)) + } + + @Test + fun `claude reports no window length, so session and weekly kinds default to 5h and 7d`() { + // Halfway through a 5h window (150 of 300 minutes left) with half of it used. + val session = assertNotNull(UsagePace.of(limit(kind = "session", used = 50.0, resetsAt = now + 150 * minute), now)) + assertEquals(300L, session.windowMinutes) + assertEquals(50.0, session.elapsedPercent, 1e-9) + assertEquals(Pace.ON_PACE, session.pace) + assertEquals("5h usage on pace", session.line) + + // One day into a week: 1/7 elapsed; 60% used is far ahead of that. + for (kind in listOf("weekly_all", "weekly_scoped")) { + val weekly = assertNotNull(UsagePace.of(limit(kind = kind, used = 60.0, resetsAt = now + 6 * 1_440 * minute), now), kind) + assertEquals(10_080L, weekly.windowMinutes, kind) + assertEquals(100.0 / 7, weekly.elapsedPercent, 1e-9, kind) + assertEquals(Pace.FASTER, weekly.pace, kind) + assertEquals("7d usage pace faster", weekly.line, kind) + } + } + + @Test + fun `a reported window length wins over the kind's default and names the line`() { + // Codex reports a 7h session window: 210 of 420 minutes left = 50% elapsed. Against the + // 5h default the same resetsAt would be 30% elapsed and 50% used would read "faster". + val r = assertNotNull(UsagePace.of(limit(kind = "session", used = 50.0, resetsAt = now + 210 * minute, windowMinutes = 420), now)) + assertEquals(420L, r.windowMinutes) + assertEquals(Pace.ON_PACE, r.pace) + assertEquals("7h usage on pace", r.line) + } + + @Test + fun `an unknown kind gets no guessed window, but its group or a reported length is used`() { + assertNull(UsagePace.windowMinutes(limit(kind = "monthly_credits"))) + assertNull(UsagePace.of(limit(kind = "monthly_credits", resetsAt = now + minute), now)) + assertEquals(300L, UsagePace.windowMinutes(limit(kind = "session_opus", group = "session"))) + assertEquals(10_080L, UsagePace.windowMinutes(limit(kind = "opus_week", group = "weekly"))) + assertEquals(43_200L, UsagePace.windowMinutes(limit(kind = "monthly_credits", windowMinutes = 43_200))) + // A hand-edited non-positive length is ignored rather than dividing by it. + assertEquals(300L, UsagePace.windowMinutes(limit(kind = "session", windowMinutes = 0))) + assertEquals(300L, UsagePace.windowMinutes(limit(kind = "session", windowMinutes = -5))) + } + + @Test + fun `slower and faster are decided outside the on-pace band, inclusive at its edge`() { + // 150 of 300 minutes left: 50% elapsed. + fun paceAt(used: Double) = UsagePace.of(limit(used = used), now)!!.pace + assertEquals(Pace.ON_PACE, paceAt(55.0), "exactly +band is still on pace") + assertEquals(Pace.ON_PACE, paceAt(45.0), "exactly -band is still on pace") + assertEquals(Pace.FASTER, paceAt(55.1)) + assertEquals(Pace.SLOWER, paceAt(44.9)) + assertEquals("5h usage pace slower", UsagePace.of(limit(used = 10.0), now)!!.line) + } + + @Test + fun `a window that has reset, or is longer than the length we assumed, gets no verdict`() { + assertNull(UsagePace.of(limit(resetsAt = now), now), "resets now: the percentage is the old window's") + assertNull(UsagePace.of(limit(resetsAt = now - minute), now), "already reset") + // 301 minutes left of a "5h" window: the window is not five hours, so the verdict would be wrong. + assertNull(UsagePace.of(limit(resetsAt = now + 301 * minute), now)) + // Exactly the whole window left is the window's first instant: valid, 0% elapsed. + val start = assertNotNull(UsagePace.of(limit(used = 0.0, resetsAt = now + 300 * minute), now)) + assertEquals(0.0, start.elapsedPercent, 1e-9) + assertEquals(Pace.ON_PACE, start.pace) + // The last millisecond: almost all of it elapsed, and 100% used is on pace. + assertEquals(Pace.ON_PACE, UsagePace.of(limit(used = 100.0, resetsAt = now + 1), now)!!.pace) + } + + @Test + fun `the clock is injected, so the same limit reads differently as time passes`() { + val resetsAt = now + 300 * minute // a fresh 5h window, 60% already used + val l = limit(used = 60.0, resetsAt = resetsAt) + assertEquals(Pace.FASTER, UsagePace.of(l, now)!!.pace) + assertEquals(Pace.ON_PACE, UsagePace.of(l, now + 180 * minute)!!.pace, "60% elapsed, 60% used") + assertEquals(Pace.SLOWER, UsagePace.of(l, now + 270 * minute)!!.pace, "90% elapsed, 60% used") + assertNull(UsagePace.of(l, resetsAt), "and nothing once it has reset") + } + + @Test + fun `hostile values from the hand-editable mirror are clamped or refused, never wrapped`() { + assertEquals(100.0, UsagePace.of(limit(used = 250.0), now)!!.usedPercent) + assertEquals(0.0, UsagePace.of(limit(used = -20.0), now)!!.usedPercent) + assertNull(UsagePace.of(limit(used = Double.NaN), now)) + // Long arithmetic would wrap `resetsAt - now` and `minutes * 60000` to plausible values. + assertNull(UsagePace.of(limit(resetsAt = Long.MIN_VALUE), now)) + assertNull(UsagePace.of(limit(resetsAt = Long.MAX_VALUE), now)) + val huge = assertNotNull(UsagePace.of(limit(resetsAt = now + minute, windowMinutes = Long.MAX_VALUE), now)) + assertEquals(Pace.SLOWER, huge.pace, "one minute left of an enormous window: nearly all of it has elapsed") + } + + @Test + fun `window labels are derived from minutes`() { + assertEquals("5h", UsagePace.windowLabel(300)) + assertEquals("7d", UsagePace.windowLabel(10_080)) + assertEquals("1d", UsagePace.windowLabel(1_440)) + assertEquals("90m", UsagePace.windowLabel(90)) + } + + @Test + fun `a mirror limit without windowMinutes still gets a pace line`() { + // The desktop's buildMirrorUsage writes `windowMinutes: null` for every Claude limit + // (agent-status-mirror.test.ts), which is why the kind defaults exist at all. + val resetsAt = now + 60 * minute + val snap = ProjectsParser.parseBlob( + "--NT-STATUS-SPLIT--\n" + + """{"v":1,"updatedAt":1,"nodes":{},"usage":{"updatedAt":1,"accounts":[{"accountId":null,"label":null,"email":null,"agentId":"claude","status":"ok","updatedAt":1,"limits":[{"kind":"session","group":"session","usedPercent":95,"severity":null,"resetsAt":$resetsAt,"windowMinutes":null,"scopeLabel":null,"isActive":true}]}]}}""" + ) + val l = snap.status!!.usage!!.accounts.single().limits.single() + assertNull(l.windowMinutes) + val r = assertNotNull(UsagePace.of(l, now)) + assertEquals(80.0, r.elapsedPercent, 1e-9) + assertEquals("5h usage pace faster", r.line) + } + + @Test + fun `context fill is clamped, rounded like the desktop, and banded like its meter`() { + assertNull(ContextFill.label(null)) + assertNull(ContextFill.label(Double.NaN)) + assertEquals("42% context", ContextFill.label(41.6)) + assertEquals("42% context", ContextFill.label(42.4)) + assertEquals("43% context", ContextFill.label(42.5), "half rounds up, as Math.round") + assertEquals("100% context", ContextFill.label(140.0)) + assertEquals("0% context", ContextFill.label(-3.0)) + assertEquals(ContextFill.Level.OK, ContextFill.level(59.9)) + assertEquals(ContextFill.Level.HIGH, ContextFill.level(60.0)) + assertEquals(ContextFill.Level.HIGH, ContextFill.level(85.0)) + assertEquals(ContextFill.Level.CRITICAL, ContextFill.level(85.1)) + } + + @Test + fun `the context fill an event card shows is its node's, read from the parsed inbox`() { + val snap = ProjectsParser.parseBlob( + "--NT-STATUS-SPLIT--\n" + + """{"v":1,"updatedAt":1,"nodes":{},"inbox":{"events":[{"id":"e1","ts":1,"nodeId":"n1","kind":"approval","title":"Approve"},{"id":"e2","ts":2,"nodeId":"n2","kind":"done","title":"Finished"}],"nodes":{"n1":{"contextPercent":73.4,"updatedAt":1}}}}""" + ) + val inbox = snap.status!!.inbox!! + val byEvent = inbox.events.associate { it.id to ContextFill.label(inbox.nodes[it.nodeId]?.contextPercent) } + assertEquals(mapOf("e1" to "73% context", "e2" to null), byEvent) + } +} diff --git a/docs/android.md b/docs/android.md index 7ad7e373eb..bfe048f3a4 100644 --- a/docs/android.md +++ b/docs/android.md @@ -114,7 +114,9 @@ cover an approval answered after the hook's hold ended (`A06`), and the desktop generated SSH answer command under a real `/bin/sh`. `QuickActionsTest` pins which node state each Inbox quick answer needs (`A38`): a ticketed approval is judged by its card, so it is answered while the node still shows WAITING for a held question; keys are typed only for a ticketless approval on a -BLOCKED node or a question on a WAITING one. +BLOCKED node or a question on a WAITING one. `UsagePaceTest` pins the Usages pace line (`A58`): no line +without a reset time, 300/10080-minute defaults for the session/weekly kinds (Claude reports no +window length), none for an unknown kind, and an injected clock. ## Known gaps From d383e769a0c48e51996beee5c76c4a6794e7a6f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 13:50:54 +0000 Subject: [PATCH 040/284] fix(android): announce each Inbox event once; trim the seen log by age, under a lock (A48) The phone remembered the Inbox events it had announced or read as a bare id set, trimmed with `(set + ids).toList().takeLast(500)`. SharedPreferences hands the set back as a HashSet, so past 500 entries every new id evicted an ARBITRARY old one, and an evicted event that was still unresolved and younger than the 6 h announce window was notified again. The read-modify-write was also unlocked while the Inbox worker, the terminal screen and the Inbox tab all called it, so concurrent updates could lose each other. What changed: - New pure `SeenLog` in android/protocol (model/SeenLog.kt) with an injected storage and clock. It keeps id -> anchor, the latest of when the phone saw the event and the event's own ts, as one JSON object. An entry is pruned only when its anchor is older than the announce window plus an 18 h margin (24 h). An event stops being announceable 6 h after its ts on the same phone clock, and the anchor is never earlier than ts, so nothing still eligible is ever pruned, even when the desktop's clock runs ahead. - Every entry point is @Synchronized across the whole read-modify-write. `claimAnnounceable` decides and records in one locked step, so the worker's check-then-mark no longer has a window either. - The pre-A48 id set (pref `seenEvents`) is migrated on first use as "seen now": the upgrade re-announces nothing, and those ids age out one retention period later. The same prefs edit that writes `seenEvents.v2` removes the old key. - The 6 h window moved from InboxNotifier into `SeenLog.ANNOUNCE_WINDOW_MS`, so the retention is derived from it. The check is written as `ts > now - window`, which cannot overflow on a garbage ts. - HostStore delegates `markSeen(events)` / `claimAnnounceable(events)` to it. The callers pass InboxEvents instead of ids, so the anchor can carry the ts. InboxNotifier.announce uses the claim. - android/README.md: one paragraph on the once-per-event rule. Departures from the audit's proposal: - The lock is on SeenLog (a @Synchronized core, like SecretStoreCore and RelayApprovalGate), not a @Synchronized HostStore.markSeen. HostStore only delegates, and all the state it guards lives in SeenLog. - A count bound remains, but only as a memory backstop (5000 entries, far beyond what a real Inbox reaches: the desktop caps its feed at 50 events). It exists for a desktop that keeps minting future-dated events, which would otherwise be kept forever. When it applies, it drops the OLDEST anchors, never hash order. - apply() is kept, as the verifier said. Verified: - New SeenLogTest (11 tests): claim-once, the announce window and resolved events, no eviction of an eligible event after 1000 more ids, retention boundary, desktop clock ahead of the phone, anchor never moves back, legacy migration (by claim and by read), tolerant decode, the backstop drops the oldest, and a deterministic lost-update test (the first read waits for a second reader). - Mutation checks, each reverted afterwards: - removing @Synchronized from markSeen fails the concurrency test; - a hash-order takeLast(500) instead of the age prune fails the eviction, retention and migration tests; - an anchor that ignores the event ts fails the clock-ahead and anchor tests; - ignoring the legacy set fails both migration tests. - `gradle test --offline` in android/protocol: 112 tests, 0 failures, 0 skipped. - Offline app type-check (android/tools/typecheck `gradle compileKotlin --offline`) passes. Not verified on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 4 + .../dev/nodeterm/android/data/HostStore.kt | 30 ++- .../nodeterm/android/notify/InboxNotifier.kt | 8 +- .../dev/nodeterm/android/ui/InboxTab.kt | 2 +- .../nodeterm/android/ui/TerminalController.kt | 2 +- .../dev/nodeterm/protocol/model/SeenLog.kt | 139 ++++++++++++ .../dev/nodeterm/protocol/SeenLogTest.kt | 203 ++++++++++++++++++ 7 files changed, 375 insertions(+), 13 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SeenLogTest.kt diff --git a/android/README.md b/android/README.md index 75fd95a0b3..63853522a2 100644 --- a/android/README.md +++ b/android/README.md @@ -77,6 +77,10 @@ leg, so this app checks each computer's Inbox periodically in the background (Wo is ~15 minutes) and live every 8 seconds while a computer is open. Real-time push on Android needs an FCM leg in the backend. +Each Inbox event raises at most one notification: only events younger than 6 hours are announced, +and the phone remembers the ones it has announced or you have read for a day after it last saw them +(longer when the computer's clock runs ahead), so nothing still eligible is forgotten. + ## Security - The phone's relay identity (a Curve25519 box key) and SSH identity (an Ed25519 seed) are diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt index 08d79ae22d..7a4e7aeec1 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt @@ -1,7 +1,9 @@ package dev.nodeterm.android.data import android.content.Context +import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.SeenLog import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -103,12 +105,28 @@ class HostStore(context: Context) { prefs.edit().putInt("fontSize", value.coerceIn(8, 24)).apply() } - /** Inbox events this phone has seen/read (phone-local, like iOS). Bounded. */ - fun seenEvents(): Set = prefs.getStringSet("seenEvents", emptySet()) ?: emptySet() + /** + * Inbox events this phone has announced or read (phone-local, like iOS), trimmed by age and + * locked across each update — see [SeenLog] (audit A48). The pre-A48 build kept a bare id set + * under `seenEvents`; [SeenLog] migrates it on first use and the same edit removes it. + */ + private val seenLog = SeenLog(object : SeenLog.Storage { + override fun read(): String? = prefs.getString(SEEN_LOG_KEY, null) + override fun readLegacy(): Set? = prefs.getStringSet(LEGACY_SEEN_KEY, null) + override fun write(encoded: String) { + // apply() publishes to the in-memory map before it returns; SeenLog holds the lock. + prefs.edit().putString(SEEN_LOG_KEY, encoded).remove(LEGACY_SEEN_KEY).apply() + } + }) + + /** The phone has seen [events]: the user read them here. */ + fun markSeen(events: Collection) = seenLog.markSeen(events) + + /** The events of a feed to notify about now, recorded as announced in the same locked step. */ + fun claimAnnounceable(events: List): List = seenLog.claimAnnounceable(events) - fun markSeen(ids: Collection) { - if (ids.isEmpty()) return - val next = (seenEvents() + ids).toList().takeLast(500).toSet() - prefs.edit().putStringSet("seenEvents", next).apply() + private companion object { + const val SEEN_LOG_KEY = "seenEvents.v2" + const val LEGACY_SEEN_KEY = "seenEvents" } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt index 3ef44c0775..f0dd875a0b 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -79,16 +79,14 @@ object InboxNotifier { if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) return 0 - val seen = graph.hosts.seenEvents() - val fresh = snapshot.status?.inbox?.events.orEmpty().filter { ev -> - ev.id !in seen && !ev.resolved && System.currentTimeMillis() - ev.ts < 6 * 3_600_000L - } + // Decided and recorded in one locked step (SeenLog, audit A48): unresolved, younger than the + // announce window, and never announced or read on this phone. + val fresh = graph.hosts.claimAnnounceable(snapshot.status?.inbox?.events.orEmpty()) if (fresh.isEmpty()) return 0 val nm = NotificationManagerCompat.from(context) for (ev in fresh.takeLast(5)) { nm.notify("${host.id}:${ev.id}".hashCode(), build(context, host, snapshot, ev)) } - graph.hosts.markSeen(fresh.map { it.id }) return fresh.size } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index ebcb64f73a..1a960bc938 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -81,7 +81,7 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro val working = snapshot.status?.nodes.orEmpty() .filter { (id, st) -> st.state == AgentState.WORKING && inbox?.nodes?.get(id)?.activity != null } - LaunchedEffect(actionable.map { it.id }) { graph.hosts.markSeen(actionable.map { it.id }) } + LaunchedEffect(actionable.map { it.id }) { graph.hosts.markSeen(actionable) } fun titleOf(nodeId: String): String = snapshot.findNode(nodeId)?.second?.let { displayTitle(it, snapshot) } ?: snapshot.statusOf(nodeId)?.name ?: "Session" diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 37885c49e2..01d230fa82 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -347,7 +347,7 @@ class TerminalController( val ev = snap.status?.inbox?.events?.lastOrNull { it.nodeId == nodeId && it.kind == InboxKind.DONE && !it.resolved } if (ev != null) { runCatching { conn.ackRead(nodeId, ev.id) } - graph.hosts.markSeen(listOf(ev.id)) + graph.hosts.markSeen(listOf(ev)) } } } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt new file mode 100644 index 0000000000..70ba9af28b --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt @@ -0,0 +1,139 @@ +package dev.nodeterm.protocol.model + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Which Inbox events this phone has already announced or read (phone-local, like iOS), so the + * background check notifies each event once (audit A48). + * + * The first version kept a bare id set and trimmed it with `(set + ids).toList().takeLast(500)`. + * The set came back from SharedPreferences as a `HashSet`, whose order is hash order, so past 500 + * entries every new id evicted an ARBITRARY old one — and an evicted id whose event was still + * unresolved and inside the announce window was announced again. Its read-modify-write was also + * unlocked while the worker, the terminal screen and the Inbox tab all called it. + * + * What is kept instead: id → an ANCHOR time, the latest of when the phone saw the event and the + * event's own `ts`. An entry is pruned only when its anchor is more than [RETENTION_MS] old — the + * announce window plus a margin — and an event stops being announceable [ANNOUNCE_WINDOW_MS] after + * its `ts` on the same (phone) clock. Since the anchor is never earlier than `ts`, an entry cannot + * be pruned while its event could still be announced, whatever the desktop's clock says; the `ts` + * is in the anchor precisely for a desktop clock running ahead of the phone's. So nothing is + * evicted by COUNT on the way to a notification: [maxEntries] is only a memory backstop (for a + * desktop that keeps minting future-dated events), sized far beyond what a real Inbox reaches — + * the desktop caps its feed at 50 events — and it drops the OLDEST anchors first. + * + * Ids written by the pre-A48 build (the bare set, no times) are migrated on first use as "seen + * now", so the upgrade announces nothing again and they age out one retention period later. + * + * Every entry point holds this object's lock across the whole read-modify-write, and + * [claimAnnounceable] decides and records in one step, so two callers cannot both announce an event. + */ +class SeenLog( + private val storage: Storage, + private val clock: () -> Long = System::currentTimeMillis, + private val maxEntries: Int = MAX_ENTRIES +) { + /** Where the log is kept (one string in the app's preferences on Android). */ + interface Storage { + /** The log as [encode] wrote it, or null when it has never been written. */ + fun read(): String? + + /** The pre-A48 format: a bare set of ids with no times. Null when there is none. */ + fun readLegacy(): Set? + + /** Store [encoded] and drop the legacy set, in one edit. */ + fun write(encoded: String) + } + + /** Whether [id] is recorded as announced or read. */ + @Synchronized + fun isSeen(id: String): Boolean = id in load(clock()) + + /** The phone has seen [events] (the user read them, or they were announced). */ + @Synchronized + fun markSeen(events: Collection) { + if (events.isEmpty()) return + val now = clock() + val entries = load(now) + for (ev in events) record(entries, ev, now) + save(entries, now) + } + + /** + * The events of [events] (a host's feed, oldest → newest) that should raise a notification now — + * unresolved, inside the announce window and never announced or read — recorded as announced in + * the same step. Order is kept. + */ + @Synchronized + fun claimAnnounceable(events: List): List { + val now = clock() + val entries = load(now) + val fresh = ArrayList() + for (ev in events) { + if (!announceable(ev, now) || ev.id in entries) continue + record(entries, ev, now) + fresh += ev + } + if (fresh.isNotEmpty()) save(entries, now) + return fresh + } + + private fun record(entries: MutableMap, ev: InboxEvent, now: Long) { + entries[ev.id] = maxOf(entries[ev.id] ?: Long.MIN_VALUE, now, ev.ts) + } + + /** The stored entries; migrates the legacy set (and persists that) the first time. */ + private fun load(now: Long): LinkedHashMap { + storage.read()?.let { return decode(it, now) } + val legacy = storage.readLegacy() ?: return LinkedHashMap() + val migrated = LinkedHashMap() + for (id in legacy) migrated[id] = now + save(migrated, now) + return migrated + } + + private fun save(entries: Map, now: Long) { + storage.write(encode(prune(entries, now, maxEntries))) + } + + companion object { + /** The background check announces only events younger than this (by the phone's clock). */ + const val ANNOUNCE_WINDOW_MS: Long = 6 * 3_600_000L + + /** How long past the window an entry is kept: slack for a desktop clock that runs ahead. */ + const val RETENTION_MARGIN_MS: Long = 18 * 3_600_000L + + /** An entry whose anchor is older than this is dropped. */ + const val RETENTION_MS: Long = ANNOUNCE_WINDOW_MS + RETENTION_MARGIN_MS + + /** Memory backstop only (see the class comment); never reached by a real Inbox. */ + const val MAX_ENTRIES: Int = 5_000 + + /** Unresolved and inside the announce window. Written without `now - ts`, which overflows. */ + fun announceable(ev: InboxEvent, now: Long): Boolean = !ev.resolved && ev.ts > now - ANNOUNCE_WINDOW_MS + + fun encode(entries: Map): String = + JsonObject(entries.mapValues { JsonPrimitive(it.value) }).toString() + + /** + * Tolerant: an unreadable log is empty; an entry whose time is unreadable counts as seen + * [now] — an id we once recorded stays recorded rather than being announced again. + */ + fun decode(raw: String, now: Long): LinkedHashMap { + val obj = J.obj(J.parse(raw)) ?: return LinkedHashMap() + val out = LinkedHashMap() + for ((id, v) in obj) out[id] = J.long(v) ?: now + return out + } + + /** Drops entries past [RETENTION_MS]; above [maxEntries], keeps the newest anchors. */ + fun prune(entries: Map, now: Long, maxEntries: Int = MAX_ENTRIES): LinkedHashMap { + val cutoff = now - RETENTION_MS + val kept = entries.filterValues { it >= cutoff } + if (kept.size <= maxEntries) return LinkedHashMap(kept) + val newest = kept.entries.sortedByDescending { it.value }.take(maxEntries).map { it.key }.toHashSet() + return LinkedHashMap(kept.filterKeys { it in newest }) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SeenLogTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SeenLogTest.kt new file mode 100644 index 0000000000..6e64a53353 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SeenLogTest.kt @@ -0,0 +1,203 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.SeenLog +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Audit A48: each Inbox event is announced once — no hash-order eviction, no lost update. */ +class SeenLogTest { + private class MemStorage(var raw: String? = null, var legacy: Set? = null) : SeenLog.Storage { + var writes = 0 + override fun read(): String? = raw + override fun readLegacy(): Set? = legacy + override fun write(encoded: String) { + raw = encoded + legacy = null + writes++ + } + } + + private class Clock(var now: Long) : () -> Long { + override fun invoke(): Long = now + } + + private val hour = 3_600_000L + private val t0 = 1_800_000_000_000L + + private fun ev(id: String, ts: Long, resolved: Boolean = false, kind: InboxKind = InboxKind.APPROVAL) = InboxEvent( + id = id, ts = ts, nodeId = "n1", agentId = "claude", sessionId = null, kind = kind, title = "t", + detail = null, interrupted = false, resolved = resolved, options = emptyList(), multiSelect = false, pendingId = null + ) + + private fun stored(storage: MemStorage, now: Long): Map = SeenLog.decode(storage.raw ?: "{}", now) + + @Test + fun `an event is claimed once, and a read one is never claimed`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + log.markSeen(listOf(ev("read", t0 - hour))) + val feed = listOf(ev("read", t0 - hour), ev("a", t0 - hour), ev("b", t0)) + assertEquals(listOf("a", "b"), log.claimAnnounceable(feed).map { it.id }) + clock.now += 60_000 + assertEquals(emptyList(), log.claimAnnounceable(feed)) + assertTrue(log.isSeen("a") && log.isSeen("b") && log.isSeen("read")) + } + + @Test + fun `only unresolved events inside the announce window are claimed`() { + val log = SeenLog(MemStorage(), Clock(t0)) + val feed = listOf( + ev("old", t0 - SeenLog.ANNOUNCE_WINDOW_MS), + ev("resolved", t0 - hour, resolved = true), + ev("recent", t0 - SeenLog.ANNOUNCE_WINDOW_MS + 1), + ev("ahead", t0 + 3 * hour), + ev("ancient", Long.MIN_VALUE) + ) + assertEquals(listOf("recent", "ahead"), log.claimAnnounceable(feed).map { it.id }) + assertFalse(log.isSeen("old")) + assertFalse(log.isSeen("resolved")) + } + + @Test + fun `a busy phone never evicts an event that could still be announced`() { + // The old trim kept `takeLast(500)` of a HashSet: the 501st id evicted an arbitrary one. + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + val first = ev("first", t0) + assertEquals(1, log.claimAnnounceable(listOf(first)).size) + for (i in 0 until 1_000) { + clock.now = t0 + i * 1_000L + log.markSeen(listOf(ev("e$i", clock.now))) + } + clock.now = t0 + 5 * hour + assertEquals(emptyList(), log.claimAnnounceable(listOf(first)), "re-announced a still-eligible event") + assertEquals(1_001, stored(storage, clock.now).size) + } + + @Test + fun `entries age out one retention period after their anchor`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + log.markSeen(listOf(ev("a", t0 - hour))) + clock.now = t0 + SeenLog.RETENTION_MS + log.markSeen(listOf(ev("b", clock.now))) + assertEquals(setOf("a", "b"), stored(storage, clock.now).keys, "pruned at the boundary") + clock.now += 1 + log.markSeen(listOf(ev("c", clock.now))) + assertEquals(setOf("b", "c"), stored(storage, clock.now).keys) + } + + @Test + fun `a desktop clock running ahead keeps its event until the event itself is too old`() { + // Seen at t0, but the event is stamped 20 h ahead: it stays announceable until ts + 6 h, + // i.e. t0 + 26 h, past t0 + RETENTION (24 h). The anchor must carry the event's ts. + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + val ahead = ev("ahead", t0 + 20 * hour) + assertEquals(1, log.claimAnnounceable(listOf(ahead)).size) + clock.now = t0 + 25 * hour + log.markSeen(listOf(ev("other", clock.now))) + assertTrue(SeenLog.announceable(ahead, clock.now)) + assertEquals(emptyList(), log.claimAnnounceable(listOf(ahead))) + } + + @Test + fun `seeing an event again moves its anchor forward, never back`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + log.markSeen(listOf(ev("a", t0 + 10 * hour))) + clock.now = t0 + hour + log.markSeen(listOf(ev("a", t0 + 10 * hour))) + assertEquals(t0 + 10 * hour, stored(storage, clock.now)["a"]) + clock.now = t0 + 12 * hour + log.markSeen(listOf(ev("a", t0 + 10 * hour))) + assertEquals(t0 + 12 * hour, stored(storage, clock.now)["a"]) + } + + @Test + fun `the pre-A48 id set migrates as seen now, announces nothing, then ages out`() { + val clock = Clock(t0) + val storage = MemStorage(legacy = setOf("a", "b")) + val log = SeenLog(storage, clock) + val feed = listOf(ev("a", t0 - hour), ev("b", t0 - 2 * hour), ev("c", t0)) + assertEquals(listOf("c"), log.claimAnnounceable(feed).map { it.id }) + assertNull(storage.legacy, "the legacy set is dropped in the same write") + assertEquals(mapOf("a" to t0, "b" to t0, "c" to t0), stored(storage, t0)) + clock.now = t0 + SeenLog.RETENTION_MS + 1 + log.markSeen(listOf(ev("d", clock.now))) + assertEquals(setOf("d"), stored(storage, clock.now).keys) + } + + @Test + fun `a legacy set is migrated by a read too, once`() { + val storage = MemStorage(legacy = setOf("a")) + val log = SeenLog(storage, Clock(t0)) + assertTrue(log.isSeen("a")) + assertTrue(log.isSeen("a")) + assertEquals(1, storage.writes) + assertNull(storage.legacy) + } + + @Test + fun `an unreadable log is empty and an unreadable time counts as seen now`() { + assertEquals(emptyMap(), SeenLog.decode("not json", t0)) + assertEquals(emptyMap(), SeenLog.decode("[1,2]", t0)) + assertEquals(mapOf("a" to 5L, "b" to t0), SeenLog.decode("""{"a":5,"b":"x"}""", t0)) + assertEquals(mapOf("a" to 5L, "b" to t0), SeenLog.decode(SeenLog.encode(mapOf("a" to 5L, "b" to t0)), t0)) + } + + @Test + fun `the memory backstop drops the oldest anchors, not arbitrary ones`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock, maxEntries = 3) + for (i in 0 until 5) { + clock.now = t0 + i * 1_000L + log.markSeen(listOf(ev("e$i", clock.now))) + } + assertEquals(setOf("e2", "e3", "e4"), stored(storage, clock.now).keys) + } + + @Test + fun `concurrent callers do not lose each other's writes`() { + // Deterministic: the first read waits (briefly) for a second reader. Without the lock the + // second caller reads the same state and one write overwrites the other; with it the second + // caller is still waiting for the lock, so the first times out, writes, and the second + // reads that write. + val storage = object : SeenLog.Storage { + @Volatile var raw: String? = null + val readers = CountDownLatch(2) + val reads = AtomicInteger() + override fun read(): String? { + if (reads.incrementAndGet() <= 2) { + readers.countDown() + readers.await(500, TimeUnit.MILLISECONDS) + } + return raw + } + override fun readLegacy(): Set? = null + override fun write(encoded: String) { + raw = encoded + } + } + val log = SeenLog(storage, { t0 }) + val a = thread { log.markSeen(listOf(ev("a", t0))) } + val b = thread { log.markSeen(listOf(ev("b", t0))) } + a.join() + b.join() + assertEquals(setOf("a", "b"), SeenLog.decode(storage.raw!!, t0).keys) + } +} From af587ac12575595abab06029a25a7ad04f9d6a5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:00:10 +0000 Subject: [PATCH 041/284] fix(android): cap OSC 52 copies for the binder and catch a failed clipboard write (A53) The terminal page forwarded any OSC 52 payload to the bridge with no size limit (xterm.js accepts OSC payloads up to 10,000,000 characters), the Kotlin side decoded it leniently (android.util.Base64 skips characters it does not know) and called setPrimaryClip with no try/catch. The clipboard write is a binder transaction into the system server, whose buffer is about 1 MB and shared, and ClipData parcels text as UTF-16; a failed transaction is rethrown from setPrimaryClip as a RuntimeException, so pane output could crash the app. - android/protocol: new Osc52.parse, mirroring the desktop's parseOsc52 (src/renderer/terminal/osc52.ts): the ';' separator is required, a '?' read query is refused, base64 is decoded exactly as atob does (WHATWG forgiving-base64) and UTF-8 strictly (CodingErrorAction.REPORT, one leading BOM dropped as TextDecoder drops it). Results are Copy / Ignored / Invalid / TooLarge. - Caps: MAX_TEXT_CHARS = 100,000 UTF-16 units (a parcel of about 200 KB) is the real one; MAX_BASE64 = 400,000 is the most base64 such a text can take (3 UTF-8 bytes per unit), checked on the raw payload first so an oversized sequence is refused before anything is decoded. - terminal.js reads the cap over the bridge (copyLimit(), so there is one definition), refuses an oversized payload before it crosses (onCopyTooLarge), and otherwise hands the WHOLE sequence to onCopy so Kotlin applies the desktop's rules. - TerminalController parses on the bridge thread, wraps setPrimaryClip in try/catch, and toasts when a copy was over the cap, rejected for size (TransactionTooLargeException in the cause chain) or failed otherwise. Departures from the audit's proposal: the verifier's correction is followed (the desktop's 1,000,000 cap is not enough on Android). The task suggested about 350k base64 characters; I used 400,000 paired with a 100,000-character text cap, so the base64 cap never refuses a text the text cap would take (pinned by a test with 100,000 three-byte characters landing exactly on it). An empty decode writes nothing, where the desktop writes an empty string, so the phone never says "Copied" over an emptied clipboard. Invalid payloads stay silent, as on the desktop. Verified: Osc52Test (12 cases: cap incl. the 986,675-character payload tmux forwards, missing ';', '?', invalid base64, invalid UTF-8, BOM, normal and multibyte payloads) and TerminalJsOsc52Test, which runs the app's real terminal.js in node against stub xterm/bridge objects (src/test/interop/terminal-js-driver.cjs). Mutation-checked: the HEAD terminal.js fails all 3 JS tests; removing either cap, making ';' optional, REPLACE instead of REPORT for UTF-8, skipping bad base64 characters or accepting '?' each fail at least one Osc52Test case. Full protocol suite (127 tests, none skipped) and the offline app type-check pass. The try/catch and toasts in the app are not tested (no Android runtime here), and nothing has run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../app/src/main/assets/terminal/terminal.js | 14 +- .../nodeterm/android/ui/TerminalController.kt | 59 +++++++- .../dev/nodeterm/protocol/model/Osc52.kt | 140 ++++++++++++++++++ .../src/test/interop/terminal-js-driver.cjs | 88 +++++++++++ .../kotlin/dev/nodeterm/protocol/Osc52Test.kt | 134 +++++++++++++++++ .../nodeterm/protocol/TerminalJsOsc52Test.kt | 103 +++++++++++++ docs/android.md | 8 +- 7 files changed, 533 insertions(+), 13 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt create mode 100644 android/protocol/src/test/interop/terminal-js-driver.cjs create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt diff --git a/android/app/src/main/assets/terminal/terminal.js b/android/app/src/main/assets/terminal/terminal.js index faaddc73ad..2d15f4bdd6 100644 --- a/android/app/src/main/assets/terminal/terminal.js +++ b/android/app/src/main/assets/terminal/terminal.js @@ -50,12 +50,18 @@ term.onData(function (d) { bridge.onInput(d) }) term.onBinary(function (d) { bridge.onInput(d) }) - // Copy: tmux's copy-mode emits OSC 52 (set-clipboard on). Write-only — a read query ('?') is - // refused, exactly as the desktop's handler refuses it. + // Copy: tmux's copy-mode emits OSC 52 (set-clipboard on). The whole sequence goes to Kotlin's + // Osc52.parse, which mirrors the desktop's parseOsc52: the ';' is required, a read query ('?') is + // refused (write-only), and base64 and UTF-8 are decoded strictly. The one check made here is the + // size cap (audit A53): xterm accepts OSC payloads up to 10,000,000 characters, and a copy that + // big must neither cross the bridge nor reach the clipboard's binder call. The cap is Kotlin's + // own constant, read once over the bridge so there is one definition; Kotlin checks it again. + var copyLimit = bridge.copyLimit() term.parser.registerOscHandler(52, function (data) { var idx = data.indexOf(';') - var payload = idx >= 0 ? data.slice(idx + 1) : data - if (payload && payload !== '?') bridge.onCopy(payload) + if (idx < 0) return true + if (data.length - idx - 1 > copyLimit) bridge.onCopyTooLarge() + else bridge.onCopy(data) return true }) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 01d230fa82..4b57e804a6 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -12,6 +12,7 @@ import android.graphics.Color import android.net.Uri import android.os.Handler import android.os.Looper +import android.os.TransactionTooLargeException import android.util.Base64 import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest @@ -35,6 +36,7 @@ import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.Keys import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.Osc52 import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -175,19 +177,56 @@ class TerminalController( graph.scope.launch { runCatching { s.scroll(up, notches) } } } + /** The OSC 52 base64 cap terminal.js applies before a copy crosses this bridge (A53). */ @JavascriptInterface - fun onCopy(b64: String) { - val text = runCatching { String(Base64.decode(b64, Base64.DEFAULT), Charsets.UTF_8) }.getOrNull() ?: return - main.post { - val ctx = webView?.context ?: return@post - val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - cm.setPrimaryClip(ClipData.newPlainText("nodeterm", text)) - val lines = text.count { it == '\n' } + 1 - Toast.makeText(ctx, "Copied $lines line${if (lines == 1) "" else "s"}", Toast.LENGTH_SHORT).show() + fun copyLimit(): Int = Osc52.MAX_BASE64 + + /** An OSC 52 terminal.js refused as over [copyLimit]; the payload itself never crossed. */ + @JavascriptInterface + fun onCopyTooLarge() { + main.post { toast(COPY_TOO_LARGE, Toast.LENGTH_LONG) } + } + + /** A whole OSC 52 sequence (`;`), parsed here on the bridge thread. */ + @JavascriptInterface + fun onCopy(data: String) { + when (val r = Osc52.parse(data)) { + is Osc52.Result.Copy -> main.post { writeClipboard(r.text) } + Osc52.Result.TooLarge -> main.post { toast(COPY_TOO_LARGE, Toast.LENGTH_LONG) } + // A read query, an empty write or a malformed one: the desktop ignores these too. + Osc52.Result.Ignored, Osc52.Result.Invalid -> Unit } } } + /** + * The clipboard write is a binder call into the system server, and a failed one (too large for + * the shared transaction buffer, or refused) is rethrown here as a RuntimeException. Uncaught, + * pane output could crash the app (A53); caught, the user is told the copy did not happen. + */ + private fun writeClipboard(text: String) { + val ctx = webView?.context ?: return + val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (cm == null) { + toast(COPY_FAILED, Toast.LENGTH_LONG) + return + } + try { + cm.setPrimaryClip(ClipData.newPlainText("nodeterm", text)) + } catch (e: Exception) { + val tooLarge = generateSequence(e) { it.cause }.take(8).any { it is TransactionTooLargeException } + toast(if (tooLarge) COPY_REJECTED_SIZE else COPY_FAILED, Toast.LENGTH_LONG) + return + } + val lines = text.count { it == '\n' } + 1 + toast("Copied $lines line${if (lines == 1) "" else "s"}", Toast.LENGTH_SHORT) + } + + private fun toast(message: String, length: Int) { + val ctx = webView?.context ?: return + Toast.makeText(ctx, message, length).show() + } + @SuppressLint("SetJavaScriptEnabled") fun createWebView(context: Context): WebView = WebView(context).apply { setBackgroundColor(Color.BLACK) @@ -440,6 +479,10 @@ class TerminalController( "The computer didn't add this session to the project, so it won't appear on the canvas. It keeps running; " + "end it here when you are done, or find it in nodeterm's session list (the RAM pill) on the computer." private const val CHUNK = 192 * 1024 + private val COPY_TOO_LARGE = + "Too large to copy: nodeterm copies up to %,d characters to the clipboard.".format(Osc52.MAX_TEXT_CHARS) + private const val COPY_REJECTED_SIZE = "Could not copy: too large for the clipboard." + private const val COPY_FAILED = "Could not copy to the clipboard." private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) } } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt new file mode 100644 index 0000000000..3ed3aa06b0 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt @@ -0,0 +1,140 @@ +package dev.nodeterm.protocol.model + +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction + +/** + * An OSC 52 clipboard write from the pane (tmux copy-mode with `set-clipboard on`, or a program + * such as vim `"+y`) turned into clipboard text. Mirrors the desktop's `parseOsc52` + * (src/renderer/terminal/osc52.ts): the payload is `;` and the `;` is + * required; it is WRITE-ONLY, so a `?` read query is refused (a remote program must never read the + * phone's clipboard); the base64 is decoded the way the desktop's `atob` decodes it (ASCII + * whitespace and missing padding tolerated, anything else outside the alphabet refused) and the + * bytes as strict UTF-8 (`TextDecoder` with `fatal: true`, including its removal of one leading + * byte-order mark). + * + * The size cap is the one place the phone must be STRICTER than the desktop (audit A53). The + * clipboard write is a binder transaction into the system server, whose buffer is about 1 MB and + * shared by every transaction in flight, and `ClipData` parcels its text as UTF-16. The desktop's + * cap of 1,000,000 base64 characters does not keep a copy under that: tmux 3.4 forwards a + * 986,675-character payload (measured), about 740 KB of text and about 1.5 MB as a parcel, over the + * binder limit; a failed transaction is rethrown from `setPrimaryClip` as a RuntimeException, which + * the app did not catch (not reproduced on a device). So there are two caps here: + * [MAX_TEXT_CHARS] is the real one, and [MAX_BASE64] is the most base64 such a text can take, + * checked first so an oversized payload is refused before anything is decoded. terminal.js reads + * [MAX_BASE64] over the bridge and applies it before the sequence crosses it. + */ +object Osc52 { + /** + * The most text the phone hands the clipboard, in UTF-16 code units (a Kotlin `String`'s + * length). At two bytes a unit the parcel stays around 200 KB: a fifth of the shared binder + * buffer, and far more than a screen of copy-mode selection. + */ + const val MAX_TEXT_CHARS = 100_000 + + /** + * The most base64 that can still decode to a text within [MAX_TEXT_CHARS]. A UTF-16 unit takes + * at most three UTF-8 bytes (a surrogate pair is two units for four bytes), and base64 spends + * four characters per three bytes, so this cap never refuses a text the text cap would take. + * It is measured on the raw payload, before whitespace is removed, exactly as the desktop does. + */ + const val MAX_BASE64 = MAX_TEXT_CHARS * 4 + + sealed interface Result { + /** Put [text] on the clipboard. Never empty and never longer than [MAX_TEXT_CHARS]. */ + data class Copy(val text: String) : Result + + /** + * Nothing to copy, and nothing wrong: no `;` separator, an empty payload, a `?` read query, + * or a payload that decodes to no text. (An empty decode writes nothing, where the desktop + * writes an empty string: a "Copied" toast over an emptied clipboard would be a lie.) + */ + data object Ignored : Result + + /** The payload is not base64, or its bytes are not UTF-8. The desktop ignores these too. */ + data object Invalid : Result + + /** Over [MAX_BASE64] or [MAX_TEXT_CHARS]. The user asked for a copy and should be told. */ + data object TooLarge : Result + } + + fun parse(data: String): Result { + val i = data.indexOf(';') + if (i < 0) return Result.Ignored + // Length first: xterm.js accepts OSC payloads up to 10,000,000 characters, and there is no + // reason to copy one out of `data` only to refuse it. + if (data.length - i - 1 > MAX_BASE64) return Result.TooLarge + val payload = data.substring(i + 1) + if (payload.isEmpty() || payload == "?") return Result.Ignored + val bytes = decodeBase64(payload) ?: return Result.Invalid + val text = decodeUtf8(bytes) ?: return Result.Invalid + if (text.isEmpty()) return Result.Ignored + if (text.length > MAX_TEXT_CHARS) return Result.TooLarge + return Result.Copy(text) + } + + /** + * The WHATWG forgiving-base64 decode, which is what `atob` runs: drop ASCII whitespace, drop at + * most two trailing `=` when the length is a multiple of four, refuse a length of 1 mod 4 and + * any character outside `A-Z a-z 0-9 + /`, and discard the leftover bits of a short last group. + * Null = not base64. (android.util.Base64, used before, silently SKIPS characters it does not + * know, so garbage decoded to something.) + */ + internal fun decodeBase64(s: String): ByteArray? { + val chars = CharArray(s.length) + var n = 0 + for (c in s) { + if (c == ' ' || c == '\t' || c == '\n' || c == '\u000C' || c == '\r') continue + chars[n++] = c + } + if (n % 4 == 0 && n > 0 && chars[n - 1] == '=') { + n-- + if (n > 0 && chars[n - 1] == '=') n-- + } + if (n % 4 == 1) return null + val out = ByteArray(n / 4 * 3 + maxOf(0, n % 4 - 1)) + var bits = 0 + var count = 0 + var o = 0 + for (k in 0 until n) { + val v = sextet(chars[k]) + if (v < 0) return null + bits = (bits shl 6) or v + count += 6 + if (count >= 8) { + count -= 8 + out[o++] = (bits shr count).toByte() + bits = bits and ((1 shl count) - 1) + } + } + return out + } + + private fun sextet(c: Char): Int = when (c) { + in 'A'..'Z' -> c - 'A' + in 'a'..'z' -> c - 'a' + 26 + in '0'..'9' -> c - '0' + 52 + '+' -> 62 + '/' -> 63 + else -> -1 + } + + /** + * Strict UTF-8: malformed input (bad or truncated sequences, overlong forms, encoded + * surrogates) is an error, never a replacement character. One leading U+FEFF is dropped, as + * `TextDecoder` drops it. Null = not UTF-8. + */ + internal fun decodeUtf8(bytes: ByteArray): String? { + val text = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString() + } catch (_: CharacterCodingException) { + return null + } + return if (text.startsWith('')) text.substring(1) else text + } +} diff --git a/android/protocol/src/test/interop/terminal-js-driver.cjs b/android/protocol/src/test/interop/terminal-js-driver.cjs new file mode 100644 index 0000000000..13fbe13813 --- /dev/null +++ b/android/protocol/src/test/interop/terminal-js-driver.cjs @@ -0,0 +1,88 @@ +// Runs the Android app's REAL terminal page script (android/app/src/main/assets/terminal/terminal.js) +// in node, against stubs of xterm.js, the DOM and the WebView bridge, so a JVM test can check what +// the page hands the Kotlin side. Only the OSC 52 handler is driven today (audit A53). +// +// Usage: node terminal-js-driver.cjs +// stdin: {"copyLimit": , "osc52": ["", ...]} +// stdout: one JSON object: +// {"copyLimitCalls": n, "osc52": [{"returned": , "calls": [[name, arg?], ...]}]} +// where an onCopy call's argument is reported as {"length": n, "sameAsInput": bool}, so a payload of +// several hundred thousand characters never has to be echoed back. +'use strict' +const fs = require('fs') +const vm = require('vm') + +const scriptPath = process.argv[2] +const input = JSON.parse(fs.readFileSync(0, 'utf8')) + +let current = null +let copyLimitCalls = 0 +const bridge = { + copyLimit() { + copyLimitCalls++ + return input.copyLimit + }, + onCopy(data) { + current.calls.push(['onCopy', { length: data.length, sameAsInput: data === current.input }]) + }, + onCopyTooLarge() { + current.calls.push(['onCopyTooLarge']) + }, + onResize() {}, + onReady() {}, + onInput() {}, + onScroll() {} +} + +const oscHandlers = {} +class Terminal { + constructor() { + this.cols = 80 + this.rows = 24 + this.modes = {} + this.options = {} + this.parser = { + registerOscHandler(ident, fn) { + oscHandlers[ident] = fn + } + } + } + loadAddon() {} + open() {} + onData() {} + onBinary() {} + write() {} + reset() {} + focus() {} + blur() {} + paste() {} +} +class FitAddonStub { + fit() {} +} +const element = { addEventListener() {} } + +const sandbox = { + Terminal, + FitAddon: { FitAddon: FitAddonStub }, + document: { getElementById: () => element }, + window: { NodetermBridge: bridge, addEventListener() {} }, + setTimeout: () => 0, + TextDecoder, + atob +} +vm.createContext(sandbox) +vm.runInContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath }) + +const handler = oscHandlers[52] +if (typeof handler !== 'function') { + process.stdout.write(JSON.stringify({ error: 'terminal.js registered no OSC 52 handler' }) + '\n') + process.exit(0) +} +const results = [] +for (const data of input.osc52) { + current = { input: data, calls: [] } + const returned = handler(data) + results.push({ returned, calls: current.calls }) +} +process.stdout.write(JSON.stringify({ copyLimitCalls, osc52: results }) + '\n') diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt new file mode 100644 index 0000000000..b89ac8103c --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt @@ -0,0 +1,134 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Osc52 +import dev.nodeterm.protocol.model.Osc52.Result +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** A53: OSC 52 copy, parsed like the desktop's parseOsc52 but capped for the Android clipboard. */ +class Osc52Test { + private fun b64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + private fun b64(text: String): String = b64(text.toByteArray(Charsets.UTF_8)) + private fun bytes(vararg b: Int) = ByteArray(b.size) { b[it].toByte() } + + @Test + fun `a normal payload decodes to its text`() { + assertEquals(Result.Copy("Hello, world"), Osc52.parse("c;" + b64("Hello, world"))) + val multi = "héllo ✓ 你好 😀\nsecond line" + assertEquals(Result.Copy(multi), Osc52.parse("c;" + b64(multi))) + // Any selection name, including none at all, as tmux and vim both send. + assertEquals(Result.Copy("x"), Osc52.parse(";" + b64("x"))) + assertEquals(Result.Copy("x"), Osc52.parse("pc;" + b64("x"))) + } + + @Test + fun `the selection separator is required`() { + // A bare base64 string used to be copied as if it were the payload. + assertEquals(Result.Ignored, Osc52.parse(b64("Hello"))) + assertEquals(Result.Ignored, Osc52.parse("")) + } + + @Test + fun `a read query and an empty payload copy nothing`() { + assertEquals(Result.Ignored, Osc52.parse("c;?")) + assertEquals(Result.Ignored, Osc52.parse(";?")) + assertEquals(Result.Ignored, Osc52.parse("c;")) + // Whitespace is valid base64 for nothing: no "Copied" over an emptied clipboard. + assertEquals(Result.Ignored, Osc52.parse("c; \n")) + } + + @Test + fun `invalid base64 is refused, not skipped over`() { + // android.util.Base64 skipped the characters it did not know and decoded the rest. + assertEquals(Result.Invalid, Osc52.parse("c;not_base64!!")) + assertEquals(Result.Invalid, Osc52.parse("c;SGVs-bG8=")) + assertEquals(Result.Invalid, Osc52.parse("c;SG=VsbG8=")) // padding in the middle + assertEquals(Result.Invalid, Osc52.parse("c;SGVsbG8===")) // three '=' + assertEquals(Result.Invalid, Osc52.parse("c;A")) // 1 mod 4 + assertEquals(Result.Invalid, Osc52.parse("c;SGVsbG8=A")) + } + + @Test + fun `base64 is as forgiving as atob and no more`() { + assertEquals(Result.Copy("Hello"), Osc52.parse("c;SGVsbG8=")) // padded + assertEquals(Result.Copy("Hello"), Osc52.parse("c;SGVsbG8")) // unpadded + assertEquals(Result.Copy("Hello"), Osc52.parse("c;SGVs\r\n bG8\t=")) // ASCII whitespace + assertEquals(Result.Copy("Hi"), Osc52.parse("c;SGk=")) // one '=' + assertEquals(Result.Copy("H"), Osc52.parse("c;SA==")) // two '=' + // Leftover bits of a short last group are discarded, not checked ("SB" and "SA" both = "H"). + assertEquals(Result.Copy("H"), Osc52.parse("c;SB")) + // Non-ASCII whitespace is not whitespace to atob. + assertEquals(Result.Invalid, Osc52.parse("c;SGVs bG8=")) + } + + @Test + fun `invalid UTF-8 is refused, never replaced`() { + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0xC3, 0x28)))) // bad continuation + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0x61, 0xE2, 0x82)))) // truncated + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0xC0, 0xAF)))) // overlong '/' + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0xED, 0xA0, 0x80)))) // a surrogate + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0xFF)))) + } + + @Test + fun `one leading byte-order mark is dropped, as TextDecoder drops it`() { + assertEquals(Result.Copy("hi"), Osc52.parse("c;" + b64("hi"))) + assertEquals(Result.Copy("hi"), Osc52.parse("c;" + b64("hi"))) + assertEquals(Result.Ignored, Osc52.parse("c;" + b64(""))) + } + + @Test + fun `the payload the desktop's cap lets through is refused before decoding`() { + // tmux 3.4 forwards a 986,675-character OSC 52 (measured by the audit's verifier): under the + // desktop's 1,000,000, about 1.5 MB as a UTF-16 parcel, over the binder buffer. + assertEquals(Result.TooLarge, Osc52.parse("c;" + "A".repeat(986_675))) + assertEquals(Result.TooLarge, Osc52.parse("c;" + "A".repeat(Osc52.MAX_BASE64 + 1))) + // Even when it is not base64 at all: the length is checked first. + assertEquals(Result.TooLarge, Osc52.parse("c;" + "!".repeat(Osc52.MAX_BASE64 + 1))) + assertEquals(Result.Invalid, Osc52.parse("c;" + "!".repeat(Osc52.MAX_BASE64))) + } + + @Test + fun `the text cap is exact`() { + val atCap = "a".repeat(Osc52.MAX_TEXT_CHARS) + assertEquals(Result.Copy(atCap), Osc52.parse("c;" + b64(atCap))) + // Well under the base64 cap, over the text cap. + val overCap = "a".repeat(Osc52.MAX_TEXT_CHARS + 1) + assertTrue(b64(overCap).length < Osc52.MAX_BASE64) + assertEquals(Result.TooLarge, Osc52.parse("c;" + b64(overCap))) + } + + @Test + fun `the base64 cap never refuses a text the text cap takes`() { + // Three UTF-8 bytes per UTF-16 unit is the worst case, and it lands exactly on the cap. + val cjk = "你".repeat(Osc52.MAX_TEXT_CHARS) + val payload = b64(cjk) + assertEquals(Osc52.MAX_BASE64, payload.length) + assertEquals(Result.Copy(cjk), Osc52.parse("c;$payload")) + // Four-byte characters are two units each: denser in units, so the text cap binds first. + val emoji = "😀".repeat(Osc52.MAX_TEXT_CHARS / 2) + assertEquals(Result.Copy(emoji), Osc52.parse("c;" + b64(emoji))) + assertEquals(Result.TooLarge, Osc52.parse("c;" + b64(emoji + "a"))) + } + + @Test + fun `the clip stays far below the shared binder buffer`() { + // ClipData parcels its text as UTF-16; the system server's buffer is about 1 MB, shared. + assertTrue(Osc52.MAX_TEXT_CHARS * 2 <= 256 * 1024) + } + + @Test + fun `decodeBase64 matches the JDK on well-formed input`() { + for (len in 0..20) { + val data = ByteArray(len) { (it * 37 + 11).toByte() } + val padded = b64(data) + assertContentEquals(data, Osc52.decodeBase64(padded)) + assertContentEquals(data, Osc52.decodeBase64(padded.trimEnd('='))) + } + assertNull(Osc52.decodeBase64("AAAAA")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt new file mode 100644 index 0000000000..c35b2a41c7 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt @@ -0,0 +1,103 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Osc52 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * A53: the app's real terminal.js, run in node against stub xterm/bridge objects + * (src/test/interop/terminal-js-driver.cjs). It must apply the size cap it reads from the bridge + * BEFORE a copy crosses it, and otherwise hand over the WHOLE sequence so [Osc52.parse] can apply + * the desktop's rules (the `;` separator, the `?` refusal). Skips without node. + */ +class TerminalJsOsc52Test { + private data class Call(val name: String, val length: Int? = null, val sameAsInput: Boolean? = null) + private data class Outcome(val returned: Boolean, val calls: List) + + private fun run(copyLimit: Int, sequences: List): Pair> { + val nodeOk = runCatching { ProcessBuilder("node", "--version").start().waitFor() == 0 }.getOrDefault(false) + assumeTrue(nodeOk, "node is needed to run terminal.js") + val root = InteropHarness.repoRoot + val script = File(root, "android/app/src/main/assets/terminal/terminal.js") + val driver = File(root, "android/protocol/src/test/interop/terminal-js-driver.cjs") + val proc = ProcessBuilder("node", driver.path, script.path).directory(root).start() + val request = buildJsonObject { + put("copyLimit", copyLimit) + putJsonArray("osc52") { sequences.forEach { add(JsonPrimitive(it)) } } + } + proc.outputStream.bufferedWriter().use { it.write(request.toString()) } + val out = proc.inputStream.bufferedReader().readText() + val err = proc.errorStream.bufferedReader().readText() + assertTrue(proc.waitFor(30, TimeUnit.SECONDS), "driver timed out") + assertEquals(0, proc.exitValue(), "driver failed: $err") + val reply = Json.parseToJsonElement(out.trim()).jsonObject + reply["error"]?.let { fail(it.jsonPrimitive.content) } + val outcomes = reply["osc52"]!!.jsonArray.map { r -> + val o = r.jsonObject + Outcome( + o["returned"]!!.jsonPrimitive.boolean, + o["calls"]!!.jsonArray.map { c -> + val call = c.jsonArray + val arg = call.getOrNull(1) as? JsonObject + Call( + call[0].jsonPrimitive.content, + arg?.get("length")?.jsonPrimitive?.int, + arg?.get("sameAsInput")?.jsonPrimitive?.boolean + ) + } + ) + } + return reply["copyLimitCalls"]!!.jsonPrimitive.int to outcomes + } + + private fun copied(data: String) = Call("onCopy", data.length, true) + private val tooLarge = Call("onCopyTooLarge") + + @Test + fun `the cap comes from the bridge and is applied before anything crosses it`() { + val atLimit = "c;" + "A".repeat(8) + val overLimit = "c;" + "A".repeat(9) + val (limitCalls, results) = run(copyLimit = 8, listOf(atLimit, overLimit)) + assertTrue(limitCalls >= 1, "terminal.js must read the cap from the bridge, not keep its own") + assertEquals(Outcome(true, listOf(copied(atLimit))), results[0]) + assertEquals(Outcome(true, listOf(tooLarge)), results[1]) + } + + @Test + fun `the whole sequence crosses, so Kotlin sees the selection and the separator`() { + val normal = "c;SGVsbG8=" + val query = "c;?" + val noSeparator = "SGVsbG8=" + val (_, results) = run(copyLimit = Osc52.MAX_BASE64, listOf(normal, query, noSeparator)) + assertEquals(Outcome(true, listOf(copied(normal))), results[0]) + assertEquals(Outcome(true, listOf(copied(query))), results[1]) // Osc52.parse refuses it + assertEquals(Outcome(true, emptyList()), results[2]) // no ';': nothing to parse + } + + @Test + fun `the payload tmux forwards under the desktop's cap never reaches onCopy`() { + // 986,675 base64 characters: what the audit's verifier measured tmux 3.4 forwarding. + val huge = "c;" + "A".repeat(986_675) + val atCap = "c;" + "A".repeat(Osc52.MAX_BASE64) + val (_, results) = run(copyLimit = Osc52.MAX_BASE64, listOf(huge, atCap)) + assertEquals(Outcome(true, listOf(tooLarge)), results[0]) + assertEquals(Outcome(true, listOf(copied(atCap))), results[1]) + } +} diff --git a/docs/android.md b/docs/android.md index bfe048f3a4..9605d97540 100644 --- a/docs/android.md +++ b/docs/android.md @@ -116,7 +116,13 @@ Inbox quick answer needs (`A38`): a ticketed approval is judged by its card, so the node still shows WAITING for a held question; keys are typed only for a ticketless approval on a BLOCKED node or a question on a WAITING one. `UsagePaceTest` pins the Usages pace line (`A58`): no line without a reset time, 300/10080-minute defaults for the session/weekly kinds (Claude reports no -window length), none for an unknown kind, and an injected clock. +window length), none for an unknown kind, and an injected clock. `Osc52Test` pins OSC 52 copy +(`A53`): parsed like the desktop's `parseOsc52` (the `;` is required, a `?` read query is refused, +base64 and UTF-8 are decoded strictly), but capped at 100,000 characters, because the clipboard +write is a binder call whose buffer (about 1 MB, shared) the desktop's 1,000,000-character base64 +cap does not respect. `TerminalJsOsc52Test` runs the app's real `terminal.js` in node against stub +xterm/bridge objects and checks that it applies that cap before a copy crosses the WebView bridge. +The app also catches a failing `setPrimaryClip` and says so in a toast; that part is not tested. ## Known gaps From 32330df6c681308bb28ba2391378c80c47d6d163 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:11:32 +0000 Subject: [PATCH 042/284] fix(android): bound the /pair answer's size and time (A54) PairingClient read the pairing answer from whatever host:pairPort the scanned code (or a nodeterm://pair link from any web page) names, and trusted it: `ByteArray(contentLength)` with no sign or size check, an unbounded read-to-EOF when no length was sent, and only a per-read timeout, which a server trickling one byte at a time never trips. A huge length was an OutOfMemoryError that took the app down, a length too large for an Int silently became the unbounded EOF read, and a negative one reached PairScreen as NegativeArraySizeException's bare "-1". Now: - A Content-Length is checked before anything is allocated: negative, non-numeric, or above MAX_RESPONSE_BYTES (64 KiB) is a PairingException with a sentence. The header cap is the same 64 KiB and now also a sentence instead of "Couldn't reach ...". - With no Content-Length the body is read to EOF but never past 64 KiB. - The whole exchange, connect included, has a deadline (45 s default, constructor parameter). A watchdog coroutine closes the socket when it runs out, and also when the caller is cancelled, because cancellation alone never unblocks a thread parked in a socket read. It starts UNDISPATCHED so a cancellation that lands before it would have been scheduled still closes the socket. - PairingException.userMessage frames anything that is not a PairingException as a sentence; PairScreen shows that and rethrows CancellationException. The desktop's largest real answer (sealed, relay leg + pin) measures 606 bytes including headers through a counting proxy against the real createPairingService; a new interop test pins it under 4 KiB, so 64 KiB (also the desktop's own cap on the request, MAX_BODY_BYTES) leaves wide room. No desktop code or wire contract changed. Verification: PairingClientBoundsTest drives scripted local servers: Content-Length 2147483647 / 99999999999 / 26 digits / 65537, -1, "abc", an endless body without a length, answers exactly at the cap (with and without a length), a trickle past a 1.5 s deadline with a 5 s read timeout, and caller cancellation against a silent server. Mutation checks: with the HEAD read logic (constructor param kept so it compiles) 5 of 7 fail (OOM, bare -1, read timeouts, "not JSON" after reading everything, the cancel test waiting out the read timeout); dropping the close-on-cancel fails the cancel test (5009 ms); `>=` instead of `>` at either cap fails the at-cap test. Full protocol suite (135 tests, interop included, none skipped) and the offline app type-check pass. Nothing ran on a device. Departures from the audit: the deadline is a socket-closing watchdog rather than a bare withTimeout (withTimeout cannot interrupt the blocking read); a non-numeric Content-Length is refused rather than read as "no length"; the header-size refusal became a sentence too; PairScreen frames any non-PairingException as a sentence rather than relying only on the protocol layer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 2 +- .../dev/nodeterm/android/ui/PairScreen.kt | 7 +- .../protocol/pairing/PairingClient.kt | 185 +++++++++++-- .../protocol/PairingClientBoundsTest.kt | 245 ++++++++++++++++++ .../nodeterm/protocol/PairingInteropTest.kt | 80 ++++++ docs/android.md | 7 +- 6 files changed, 497 insertions(+), 29 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt diff --git a/android/README.md b/android/README.md index 63853522a2..7489f0ccbf 100644 --- a/android/README.md +++ b/android/README.md @@ -91,6 +91,6 @@ and the phone remembers the ones it has announced or you have read for a day aft replays), no re-key once ready, and the host key pinned from pairing. - The SSH host key is pinned on first connect; a changed key is refused, never routed around. - No cleartext HTTP anywhere; the LAN `/pair` POST runs over a raw socket and is sealed to the host - key from the QR. + key from the QR. Its answer is read as untrusted: at most 64 KiB, within 45 seconds. Design notes, the protocol mapping and known gaps: [`docs/android.md`](../docs/android.md). diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt index bb2b397176..d796561cde 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt @@ -46,6 +46,7 @@ import dev.nodeterm.android.Route import dev.nodeterm.android.data.SecureStore import dev.nodeterm.protocol.model.PairedHost import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingException import dev.nodeterm.protocol.pairing.PairingPayload import kotlinx.coroutines.launch @@ -172,8 +173,12 @@ fun PairScreen(nav: Navigator, initialCode: String? = null) { ).show() nav.replaceAll(Route.Hosts) nav.push(Route.Host(host.id)) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { - error = e.message ?: "Pairing failed." + // Always a sentence: a bare exception message can be a lone token + // (audit A54 saw "-1"). + error = PairingException.userMessage(e) } finally { busy = false } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt index 1d563207c1..6d119d5462 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt @@ -7,7 +7,12 @@ import dev.nodeterm.protocol.model.J import dev.nodeterm.protocol.model.J.b import dev.nodeterm.protocol.model.J.o import dev.nodeterm.protocol.model.J.s +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -16,8 +21,23 @@ import java.io.IOException import java.io.InputStream import java.net.InetSocketAddress import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean -class PairingException(message: String) : Exception(message) +class PairingException(message: String) : Exception(message) { + companion object { + /** + * What the pairing screen shows for a failed [PairingClient.pair]: a PairingException's own + * sentence as it is, and anything else framed as one — a bare exception message can be a + * lone token (audit A54: a negative Content-Length used to reach the screen as "-1"). + */ + fun userMessage(e: Throwable): String { + val own = e.message?.trim().orEmpty() + if (e is PairingException && own.isNotEmpty()) return own + val detail = if (own.isNotEmpty()) "${e.javaClass.simpleName}: ${own.take(200)}" else e.javaClass.simpleName + return "Pairing failed unexpectedly ($detail). Scan the code again." + } + } +} /** * The phone side of `src/main/pairing-service.ts`'s `/pair` listener. @@ -34,8 +54,39 @@ class PairingException(message: String) : Exception(message) * URL is cleartext traffic Android's network security policy blocks by default for HTTP stacks, * and the desktop frames every response with an explicit Content-Length precisely so a minimal * reader works (pairing-service.ts `send`). A non-2xx body is plain text meant for the user. + * + * The endpoint is whatever `host:pairPort` the payload names, and a `nodeterm://pair` link from any + * web page can supply one, so the answer is read as untrusted (audit A54): its size is capped at + * [MAX_RESPONSE_BYTES] whether or not it declares a Content-Length, and the whole exchange — + * connect included — is bounded by [deadlineMs]. Every refusal is a [PairingException] whose + * message is a sentence, because PairScreen shows the message as it is. */ -class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readTimeoutMs: Int = 20_000) { +class PairingClient( + private val connectTimeoutMs: Int = 8_000, + /** Bounds SILENCE only: a server that trickles a byte at a time never trips it. */ + private val readTimeoutMs: Int = 20_000, + /** Bounds the whole exchange. Enforced by closing the socket, the one thing that unblocks a read. */ + private val deadlineMs: Long = DEFAULT_DEADLINE_MS +) { + companion object { + /** + * Largest header block or body accepted. The desktop's biggest real answer — the sealed + * `{box}` around `{ok, deviceId, agentToken, relay, relayDeviceToken, relayPinned}` — + * measured 606 bytes, headers included, with the interop fixture's short relay token + * (PairingInteropTest counts it through a proxy and fails past 4 KiB). 64 KiB is also the + * cap the desktop puts on the request it reads from us (pairing-service.ts `MAX_BODY_BYTES`). + */ + const val MAX_RESPONSE_BYTES = 64 * 1024 + + /** + * The desktop's slowest honest answer waits behind an in-flight revoke (8 s) and then a + * relay-device mint (8 s) before it replies; 45 s leaves room for a slow network on top. + */ + const val DEFAULT_DEADLINE_MS = 45_000L + + private const val SCAN_AGAIN = + "Make sure the code came from nodeterm on your computer, then scan it again." + } suspend fun pair( payload: PairingPayload, @@ -69,7 +120,7 @@ class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readT inner } - val (status, text) = postRaw(payload.host, payload.pairPort, "/pair", body) + val (status, text) = exchange(payload.host, payload.pairPort, "/pair", body) if (status !in 200..299) { throw PairingException( if (text.isNotBlank()) "The computer rejected pairing: ${text.trim()}" @@ -95,33 +146,75 @@ class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readT ) } - private fun postRaw(host: String, port: Int, path: String, body: String): Pair { - val bytes = body.toByteArray(Charsets.UTF_8) - try { - Socket().use { sock -> - sock.connect(InetSocketAddress(host, port), connectTimeoutMs) - sock.soTimeout = readTimeoutMs - val hostHeader = if (host.contains(':')) "[$host]:$port" else "$host:$port" - val head = "POST $path HTTP/1.1\r\n" + - "Host: $hostHeader\r\n" + - "Content-Type: application/json\r\n" + - "Content-Length: ${bytes.size}\r\n" + - "Connection: close\r\n\r\n" - val out = sock.getOutputStream() - out.write(head.toByteArray(Charsets.US_ASCII)) - out.write(bytes) - out.flush() - return readResponse(sock.getInputStream()) + /** + * One POST under the overall deadline. A watchdog closes the socket when [deadlineMs] runs out + * — and when the caller is cancelled (PairScreen left), since cancellation alone does not + * unblock a thread parked in a socket read. + */ + private suspend fun exchange(host: String, port: Int, path: String, body: String): Pair = coroutineScope { + val sock = Socket() + val expired = AtomicBoolean(false) + // UNDISPATCHED: the `try` is entered before the socket is used, so even a cancellation that + // lands before the watchdog would otherwise have been scheduled still closes the socket. + val watchdog = launch(start = CoroutineStart.UNDISPATCHED) { + try { + delay(deadlineMs) + expired.set(true) + } finally { + runCatching { sock.close() } } + } + try { + postRaw(sock, host, port, path, body) } catch (e: IOException) { + ensureActive() // the caller walked away: report that, not a network failure + if (expired.get()) { + throw PairingException( + "The computer at $host:$port did not finish answering within ${seconds(deadlineMs)}, " + + "so pairing stopped. Check that the phone is on the same network and the pairing code " + + "is still on screen, then scan it again." + ) + } throw PairingException( "Couldn't reach the computer at $host:$port (${e.message ?: e.javaClass.simpleName}). " + "Is the phone on the same network, and is the pairing code still on screen?" ) + } finally { + watchdog.cancel() + } + } + + private fun seconds(ms: Long): String { + val n = (ms + 999) / 1000 + return if (n == 1L) "1 second" else "$n seconds" + } + + private fun postRaw(sock: Socket, host: String, port: Int, path: String, body: String): Pair { + val bytes = body.toByteArray(Charsets.UTF_8) + sock.use { + sock.connect(InetSocketAddress(host, port), connectTimeoutMs) + sock.soTimeout = readTimeoutMs + val hostHeader = if (host.contains(':')) "[$host]:$port" else "$host:$port" + val head = "POST $path HTTP/1.1\r\n" + + "Host: $hostHeader\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: ${bytes.size}\r\n" + + "Connection: close\r\n\r\n" + val out = sock.getOutputStream() + out.write(head.toByteArray(Charsets.US_ASCII)) + out.write(bytes) + out.flush() + return readResponse(sock.getInputStream()) } } - /** Minimal HTTP/1.1 response reader: status line, headers, then Content-Length (or EOF) body. */ + /** + * Minimal HTTP/1.1 response reader: status line, headers, then a Content-Length (or EOF) body, + * each capped at [MAX_RESPONSE_BYTES]. A declared length is checked BEFORE anything is + * allocated for it: a negative one used to surface as the bare "-1" of a + * NegativeArraySizeException, and a huge one (or none, with an endless body) as an + * OutOfMemoryError that took the app down. + */ internal fun readResponse(input: InputStream): Pair { val headBytes = ByteArrayOutputStream() var last4 = 0 @@ -131,19 +224,22 @@ class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readT headBytes.write(c) last4 = (last4 shl 8) or c if (last4 == 0x0d0a0d0a) break - if (headBytes.size() > 64 * 1024) throw IOException("response header too large") + if (headBytes.size() > MAX_RESPONSE_BYTES) { + throw PairingException("The computer's answer had more than 64 KiB of headers, so pairing stopped. $SCAN_AGAIN") + } } val head = headBytes.toString(Charsets.ISO_8859_1.name()) val lines = head.split("\r\n") val status = lines.firstOrNull()?.split(' ')?.getOrNull(1)?.toIntOrNull() ?: throw IOException("malformed HTTP response") - val length = lines.drop(1).firstNotNullOfOrNull { line -> + val declared = lines.drop(1).firstNotNullOfOrNull { line -> val idx = line.indexOf(':') if (idx > 0 && line.substring(0, idx).trim().equals("content-length", ignoreCase = true)) { - line.substring(idx + 1).trim().toIntOrNull() + line.substring(idx + 1).trim() } else null } - val body = if (length != null) { + val body = if (declared != null) { + val length = declaredLength(declared) val buf = ByteArray(length) var off = 0 while (off < length) { @@ -153,8 +249,45 @@ class PairingClient(private val connectTimeoutMs: Int = 8_000, private val readT } buf.copyOf(off) } else { - input.readBytes() + readToEof(input) } return status to String(body, Charsets.UTF_8) } + + /** A Content-Length value, refused with a sentence unless it is a count from 0 to the cap. */ + private fun declaredLength(raw: String): Int { + val n = raw.toLongOrNull() + return when { + n != null && n < 0 -> throw PairingException( + "The computer's answer declared a negative length ($n bytes), so pairing stopped. $SCAN_AGAIN" + ) + n != null && n > MAX_RESPONSE_BYTES -> tooLarge("it declared $n bytes") + n != null -> n.toInt() + // Too many digits for a Long is still a length, just an absurd one. + raw.isNotEmpty() && raw.all { it in '0'..'9' } -> tooLarge("it declared a ${raw.length}-digit length") + else -> throw PairingException( + "The computer's answer declared a length that is not a number, so pairing stopped. $SCAN_AGAIN" + ) + } + } + + private fun tooLarge(what: String): Nothing = throw PairingException( + "The computer's answer is larger than any pairing answer ($what; the limit is 64 KiB), so pairing stopped. $SCAN_AGAIN" + ) + + /** No Content-Length: read to EOF, but never past the cap. */ + private fun readToEof(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + val chunk = ByteArray(8 * 1024) + while (true) { + val n = input.read(chunk) + if (n < 0) return out.toByteArray() + out.write(chunk, 0, n) + if (out.size() > MAX_RESPONSE_BYTES) { + throw PairingException( + "The computer's answer ran past 64 KiB without ending, so pairing stopped. $SCAN_AGAIN" + ) + } + } + } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt new file mode 100644 index 0000000000..1600352ebd --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt @@ -0,0 +1,245 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingException +import dev.nodeterm.protocol.pairing.PairingPayload +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Audit A54: the `/pair` answer comes from whatever `host:pairPort` the scanned (or linked) code + * names, so [PairingClient] must bound it — a declared length is checked before anything is + * allocated, a body with no length stops at the cap, and the whole exchange has a deadline that a + * trickling server cannot outlast. Each case is a scripted server on a local socket. + */ +class PairingClientBoundsTest { + private val cleanup = ArrayList() + + @AfterTest + fun tearDown() = cleanup.asReversed().forEach { runCatching { it.close() } } + + /** Accepts ONE connection, drains the request, then hands the socket to [script]. */ + private inner class ScriptedServer(private val script: ScriptedServer.(Socket) -> Unit) : AutoCloseable { + private val server = ServerSocket(0, 50, InetAddress.getLoopbackAddress()) + private val accepted = CopyOnWriteArrayList() + val port: Int get() = server.localPort + /** Counted down when the client's end of the connection is seen closed (read hits EOF). */ + val clientClosed = CountDownLatch(1) + + init { + cleanup += this + Thread({ + try { + val sock = server.accept() + accepted += sock + sock.use { + drainRequest(it.getInputStream()) + script(it) + } + } catch (_: IOException) { + // The client hung up mid-script: expected for every refusal. + } + }, "pair-bounds-server").apply { isDaemon = true }.start() + } + + /** Holds the connection open without sending more, until the client hangs up (or 10 s). */ + fun holdUntilClientCloses(sock: Socket) { + sock.soTimeout = 10_000 + try { + while (sock.getInputStream().read() >= 0) Unit + clientClosed.countDown() + } catch (_: SocketTimeoutException) { + // The client never hung up. + } catch (_: IOException) { + // A reset is also the client going away. + clientClosed.countDown() + } + } + + override fun close() { + accepted.forEach { runCatching { it.close() } } + server.close() + } + } + + private fun drainRequest(input: InputStream) { + val head = ByteArrayOutputStream() + var last4 = 0 + while (last4 != 0x0d0a0d0a) { + val c = input.read() + if (c < 0) return + head.write(c) + last4 = (last4 shl 8) or c + } + val length = head.toString(Charsets.ISO_8859_1.name()).split("\r\n") + .firstNotNullOfOrNull { line -> + if (line.startsWith("Content-Length:", ignoreCase = true)) line.substringAfter(':').trim().toInt() else null + } ?: 0 + var left = length + val buf = ByteArray(4096) + while (left > 0) { + val n = input.read(buf, 0, minOf(buf.size, left)) + if (n < 0) return + left -= n + } + } + + private fun payloadFor(port: Int) = PairingPayload( + host = "127.0.0.1", port = 22, user = "u", token = "t", pairPort = port, name = "Test", + hostKey = null, relay = null, sshAvailable = true + ) + + private fun pair(port: Int, client: PairingClient = PairingClient(readTimeoutMs = 5_000)) = runBlocking { + client.pair(payloadFor(port), "ssh-ed25519 AAAA test", "Pixel", "android-test") + } + + private fun Socket.send(text: String) { + getOutputStream().write(text.toByteArray(Charsets.ISO_8859_1)) + getOutputStream().flush() + } + + private val okBody = """{"ok":true,"deviceId":"dev-1","agentToken":"tok"}""" + + /** Every refusal the pairing screen can show must read as a sentence, never a bare token. */ + private fun assertSentence(e: PairingException) { + val msg = e.message!! + assertEquals(msg, PairingException.userMessage(e), "PairScreen shows a PairingException's message as it is") + assertTrue(msg.length > 40 && msg.trim().endsWith("."), "not a sentence: $msg") + } + + @Test + fun `a huge declared length is refused before anything is allocated`() { + // Int.MAX_VALUE used to be `ByteArray(length)` = OutOfMemoryError; the two longer ones did + // not parse as an Int and fell through to an unbounded read-to-EOF. + for (declared in listOf("2147483647", "99999999999", "99999999999999999999999999", "65537")) { + val srv = ScriptedServer { sock -> + sock.send("HTTP/1.1 200 OK\r\nContent-Length: $declared\r\n\r\n$okBody") + holdUntilClientCloses(sock) + } + val started = System.nanoTime() + val e = assertFailsWith("Content-Length $declared") { pair(srv.port) } + val tookMs = (System.nanoTime() - started) / 1_000_000 + assertTrue(e.message!!.contains("larger than any pairing answer"), e.message) + assertTrue(e.message!!.contains("64 KiB"), e.message) + assertSentence(e) + assertTrue(tookMs < 3_000, "refused from the header, not after a read timeout (${tookMs} ms)") + } + } + + @Test + fun `a negative or non-numeric length is refused with a sentence, not the bare number`() { + // A negative length used to reach PairScreen as NegativeArraySizeException's message: "-1". + val negative = ScriptedServer { sock -> sock.send("HTTP/1.1 200 OK\r\nContent-Length: -1\r\n\r\n$okBody") } + val e = assertFailsWith { pair(negative.port) } + assertNotEquals("-1", e.message) + assertTrue(e.message!!.contains("negative length (-1 bytes)"), e.message) + assertSentence(e) + + // Not a number at all used to be read as "no length" — and paired on a body RFC 9112 calls + // an unrecoverable framing error. + val garbage = ScriptedServer { sock -> sock.send("HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n$okBody") } + val g = assertFailsWith { pair(garbage.port) } + assertTrue(g.message!!.contains("not a number"), g.message) + assertSentence(g) + } + + @Test + fun `a body with no Content-Length stops at the cap instead of reading until EOF`() { + // Writes until the client hangs up; the 32 MiB bound only keeps a regressed client from + // reading forever. The deadline is set far away so the CAP is what has to stop it. + val srv = ScriptedServer { sock -> + sock.send("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n") + val chunk = ByteArray(8 * 1024) { 'x'.code.toByte() } + val out = sock.getOutputStream() + repeat(32 * 1024 * 1024 / chunk.size) { out.write(chunk) } + } + val started = System.nanoTime() + val e = assertFailsWith { pair(srv.port, PairingClient(readTimeoutMs = 5_000, deadlineMs = 30_000)) } + val tookMs = (System.nanoTime() - started) / 1_000_000 + assertTrue(e.message!!.contains("ran past 64 KiB"), e.message) + assertSentence(e) + assertTrue(tookMs < 5_000, "stopped by the cap, not by a timeout (${tookMs} ms)") + } + + @Test + fun `an answer exactly at the cap is still read, with or without a length`() { + // The boundary is inclusive: pad a real answer with JSON whitespace to exactly 64 KiB. + val padded = okBody + " ".repeat(PairingClient.MAX_RESPONSE_BYTES - okBody.length) + assertEquals(PairingClient.MAX_RESPONSE_BYTES, padded.toByteArray().size) + val withLength = ScriptedServer { sock -> + sock.send("HTTP/1.1 200 OK\r\nContent-Length: ${padded.length}\r\n\r\n$padded") + } + assertEquals("dev-1", pair(withLength.port).deviceId) + val toEof = ScriptedServer { sock -> sock.send("HTTP/1.1 200 OK\r\n\r\n$padded") } + assertEquals("dev-1", pair(toEof.port).deviceId) + } + + @Test + fun `a server that trickles bytes is cut off at the overall deadline`() { + // One byte every 100 ms never trips the 5 s per-read timeout; only the deadline can end + // this. The server gives up after 10 s so a regressed client fails instead of hanging. + val srv = ScriptedServer { sock -> + sock.send("HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n") + val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < until) { + sock.send(" ") + Thread.sleep(100) + } + } + val started = System.nanoTime() + val e = assertFailsWith { + pair(srv.port, PairingClient(readTimeoutMs = 5_000, deadlineMs = 1_500)) + } + val tookMs = (System.nanoTime() - started) / 1_000_000 + assertTrue(e.message!!.contains("did not finish answering within 2 seconds"), e.message) + assertSentence(e) + assertTrue(tookMs in 1_400..4_500, "cut off by the 1.5 s deadline, not the 5 s read timeout (${tookMs} ms)") + } + + @Test + fun `cancelling the caller closes the socket so a blocked read returns at once`() { + // A silent server: the client is parked in a read. Cancellation alone cannot unblock it; + // closing the socket can. Without that the call outlives its caller by the read timeout. + val holder = ScriptedServer { sock -> holdUntilClientCloses(sock) } + val started = System.nanoTime() + assertFailsWith { + runBlocking { + withTimeout(500) { + PairingClient(readTimeoutMs = 5_000, deadlineMs = 30_000) + .pair(payloadFor(holder.port), "ssh-ed25519 AAAA test", "Pixel", "android-test") + } + } + } + val tookMs = (System.nanoTime() - started) / 1_000_000 + assertTrue(tookMs < 3_000, "returned with its caller, not after the 5 s read timeout (${tookMs} ms)") + assertTrue(holder.clientClosed.await(3, TimeUnit.SECONDS), "the server saw the phone hang up") + } + + @Test + fun `anything that is not a PairingException is framed as a sentence`() { + val framed = PairingException.userMessage(NegativeArraySizeException("-1")) + assertEquals("Pairing failed unexpectedly (NegativeArraySizeException: -1). Scan the code again.", framed) + assertEquals( + "Pairing failed unexpectedly (IllegalStateException). Scan the code again.", + PairingException.userMessage(IllegalStateException()) + ) + assertEquals("The computer rejected pairing: bad token", PairingException.userMessage(PairingException("The computer rejected pairing: bad token"))) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt index eeeb475487..8d5e2d066b 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt @@ -1,6 +1,7 @@ package dev.nodeterm.protocol import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair import dev.nodeterm.protocol.pairing.PairingClient import dev.nodeterm.protocol.pairing.PairingException import dev.nodeterm.protocol.pairing.PairingPayload @@ -9,7 +10,17 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -107,6 +118,75 @@ class PairingInteropTest { assertEquals(box.publicKeyB64, h.awaitEvent("pin")["pub"]!!.jsonPrimitive.content) } + @Test + fun `the desktop's largest real answer sits far below the client's response cap`() = runBlocking { + // A54 caps the /pair answer at PairingClient.MAX_RESPONSE_BYTES. This measures the biggest + // answer the desktop's real service gives — sealed, with the relay leg and the pin — through + // a byte-counting proxy, so a desktop change that grows it toward the cap is caught here. + val (h, _) = start(withRelay = true) + val payload = payloadOf(h) + val proxy = CountingProxy(payload.host, payload.pairPort) + cleanup += proxy + val result = PairingClient().pair( + payload.copy(host = "127.0.0.1", pairPort = proxy.port), + SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-5", + boxPublicKeyB64 = BoxKeyPair.generate().publicKeyB64 + ) + assertTrue(result.relayPinned) + val answered = proxy.awaitDownstreamBytes() + println("[pair] the desktop's sealed answer with relay leg + pin: $answered bytes, headers included") + assertTrue(answered in 1..(PairingClient.MAX_RESPONSE_BYTES / 16).toLong(), "answer was $answered bytes") + } + + /** Forwards one connection to [host]:[targetPort] and counts the bytes coming back. */ + private class CountingProxy(host: String, targetPort: Int) : AutoCloseable { + private val server = ServerSocket(0, 5, InetAddress.getLoopbackAddress()) + private val downstream = AtomicLong() + private val done = CountDownLatch(1) + private val sockets = CopyOnWriteArrayList() + val port: Int get() = server.localPort + + init { + Thread({ + try { + val client = server.accept().also { sockets += it } + val upstream = Socket(host, targetPort).also { sockets += it } + Thread({ pump(client.getInputStream(), upstream.getOutputStream(), null) }, "proxy-up") + .apply { isDaemon = true }.start() + pump(upstream.getInputStream(), client.getOutputStream(), downstream) + } catch (_: IOException) { + } finally { + sockets.forEach { runCatching { it.close() } } + done.countDown() + } + }, "proxy-down").apply { isDaemon = true }.start() + } + + private fun pump(from: InputStream, to: OutputStream, count: AtomicLong?) { + val buf = ByteArray(8192) + try { + while (true) { + val n = from.read(buf) + if (n < 0) break + to.write(buf, 0, n) + to.flush() + count?.addAndGet(n.toLong()) + } + } catch (_: IOException) { + } + } + + fun awaitDownstreamBytes(): Long { + done.await(10, TimeUnit.SECONDS) + return downstream.get() + } + + override fun close() { + sockets.forEach { runCatching { it.close() } } + server.close() + } + } + @Test fun `without a relay leg nothing is pinned and the answer says so`() = runBlocking { val (h, _) = start(withRelay = false) diff --git a/docs/android.md b/docs/android.md index 9605d97540..764d5348ad 100644 --- a/docs/android.md +++ b/docs/android.md @@ -88,7 +88,12 @@ unchanged. dropped, replayed/reordered sequence numbers dropped, boxes under a foreign key dropped. - **Pairing** — against the desktop's real `createPairingService` with HOME in a temp dir: the E2EE-sealed exchange, the key landing in `authorized_keys` under `nodeterm-ios-`, the - relay leg and its `/v1/relay/device` body, a refused wrong token. + relay leg and its `/v1/relay/device` body, a refused wrong token, and the size of the desktop's + largest answer (606 bytes, counted through a proxy). Scripted local servers pin the client's + bounds on an answer from whatever `host:pairPort` a code names: a declared length is refused + above 64 KiB or below zero before anything is allocated, a body with no length stops at 64 KiB, + and the whole exchange ends at a 45 s deadline (or when the caller is cancelled) by closing the + socket, so a server that trickles bytes cannot hold the pairing screen. - **SSH** — against Apache MINA sshd running every command through `/bin/sh`, with real tmux on a private `TMUX_TMPDIR`: v3 index resolution, attach with keystrokes both ways, cold-start detection, literal `send-keys` (a leading `-` is text), answer files, read-acks, host-key pinning. From a40d11b277c7ff3e760ee2826ba2def14ca4d3b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:19:11 +0000 Subject: [PATCH 043/284] fix(android): pin the SSH host key only after auth; a changed key falls through to the relay in Auto (A49, A74) What changed: - SshHostConnection: the host-key verifier no longer persists the TOFU pin during key exchange. It records the presented fingerprint and calls HostKeyPin.pin only after authPublickey succeeds, so an sshd that answers at the paired address and refuses the phone's key (another machine holding the old DHCP lease, or the same private range on another network) never becomes the pin. Within one connection a re-key must present the same key as the first exchange, pinned or not. The HostKeyPin KDoc no longer claims the pin is null only "before the first successful connect"; it now says what is true (null until a connect has authenticated). - New pure SshFallback.afterFailure (protocol): what a connect does after the SSH leg failed. A HostKeyChangedException is never used over SSH; in Auto (relay allowed and configured) it goes on to the relay leg with a warning, on "Only on my network" it stops, and with no relay leg at all it stops and says how to get one. Ordinary SSH failures keep their previous behaviour and wording. - HostSession (app) applies it. The relay dial still goes through RelayApprovalGate.decide exactly as before, so a background check still never makes a first relay handshake. A new HostSession.sshWarning keeps the warning visible on the host screen while connected through the relay; it is cleared by an SSH connect and on a failed connect (whose message already carries it). - Error text: the exception states the fact and the likely causes (another machine has the address, a reinstall) before interception; the advice points at Settings -> How to reach each computer -> "Only through the relay", and mentions pairing again only as the way to trust a reinstalled computer's new key, instead of "remove and re-pair" as the only way out. - docs/android.md and android/README.md no longer say a changed key is a hard stop in Auto; the known-gaps list records that the pin is not anchored in pairing and the LAN address is frozen. Why: an auth-failed key exchange pinned whatever answered (A49), and after a correct pin a private-IP collision on another network made Auto fail with an interception warning and never try the independently authenticated relay (A74; the verifiers' refinement). Verified: - New SshTransportTest case: a server that completes key exchange and then refuses our key (and a refused user) leaves the pin null; the first authenticated connect pins exactly the server's key. Mutation check: pinning inside the verifier again makes it fail at the first assertNull. - New SshFallbackTest (5 cases). Mutation check: making the host-key branch always Stop fails two. - Full protocol suite: gradle test --offline, 141 tests, 0 skipped, 0 failures. - Offline app type-check: gradle compileKotlin --offline (--rerun-tasks) passes. - The app wiring (HostSession, HostScreen banner) is type-checked only; nothing ran on a device. Departures from the audit's proposal: anchoring the pin in the sealed /pair answer (desktop returning its SSH host key fingerprints) is left as a follow-up, as scoped; "do not background-dial a host whose SSH never authenticated" is not done (the verifier showed the unpinned window is narrow). The pin is still not cleared or replaced automatically after a relay connect: that needs the relay-authenticated computer to vouch for its new SSH key, which is desktop work. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 4 +- .../android/conn/ConnectionManager.kt | 42 +++++++-- .../dev/nodeterm/android/ui/HostScreen.kt | 12 +++ .../dev/nodeterm/protocol/ssh/SshFallback.kt | 66 ++++++++++++++ .../protocol/ssh/SshHostConnection.kt | 46 ++++++++-- .../dev/nodeterm/protocol/SshFallbackTest.kt | 88 +++++++++++++++++++ .../dev/nodeterm/protocol/SshTransportTest.kt | 22 +++++ docs/android.md | 30 ++++++- 8 files changed, 291 insertions(+), 19 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshFallback.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt diff --git a/android/README.md b/android/README.md index 7489f0ccbf..3f24cbb7fa 100644 --- a/android/README.md +++ b/android/README.md @@ -89,7 +89,9 @@ and the phone remembers the ones it has announced or you have read for a day aft - Relay traffic is end-to-end encrypted (NaCl box under a per-session HKDF key) and checked exactly as the desktop checks it: role byte (no reflections), strictly increasing sequence numbers (no replays), no re-key once ready, and the host key pinned from pairing. -- The SSH host key is pinned on first connect; a changed key is refused, never routed around. +- The SSH host key is pinned on the first connect that authenticates (a server that refuses the + phone's key is never pinned). A changed key is never used over SSH; in Auto the phone goes on to + the relay, which verifies the computer separately, and shows a warning. - No cleartext HTTP anywhere; the LAN `/pair` POST runs over a raw socket and is sealed to the host key from the QR. Its answer is read as untrusted: at most 64 KiB, within 45 seconds. diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index b942a40899..d74ddadadd 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -16,8 +16,8 @@ import dev.nodeterm.protocol.model.ProjectsSnapshot import dev.nodeterm.protocol.pairing.PairingPayload import dev.nodeterm.protocol.pairing.RelayBlock import dev.nodeterm.protocol.relay.RelayApi -import dev.nodeterm.protocol.ssh.HostKeyChangedException import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.SshFallback import dev.nodeterm.protocol.ssh.SshHostConnection import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -54,6 +54,15 @@ class HostSession(val hostId: String, private val graph: AppGraph) { val snapshot: StateFlow = _snapshot.asStateFlow() private val _lastError = MutableStateFlow(null) val lastError: StateFlow = _lastError.asStateFlow() + private val _sshWarning = MutableStateFlow(null) + + /** + * Why the current connection is NOT the direct-SSH one although the route would have preferred + * it: the server at the paired address presented a different host key, so the Auto route went on + * to the relay (audit A49/A74). Set per connect; shown while connected, so a changed key never + * disappears behind a relay that worked. + */ + val sshWarning: StateFlow = _sshWarning.asStateFlow() private val mutex = Mutex() @Volatile private var conn: HostConnection? = null @@ -74,6 +83,7 @@ class HostSession(val hostId: String, private val graph: AppGraph) { val host = graph.hosts.get(hostId) ?: throw HostException("This computer is no longer paired.") val route = graph.hosts.route(hostId) val errors = ArrayList() + var sshWarning: String? = null if (route != RoutePreference.RELAY_ONLY && host.sshAvailable) { _state.value = ConnState.Connecting("Connecting on your network…") @@ -88,6 +98,7 @@ class HostSession(val hostId: String, private val graph: AppGraph) { connectTimeoutMs = if (route == RoutePreference.AUTO) 4_000 else 10_000 ).also { dialed = it } } + _sshWarning.value = null adopt(ssh) scope.launch { runCatching { adoptRelayIfAdvertised(ssh, host) } } return ssh @@ -95,14 +106,22 @@ class HostSession(val hostId: String, private val graph: AppGraph) { dialed?.let { c -> scope.launch(Dispatchers.IO) { runCatching { c.close() } } } _state.value = ConnState.Idle throw e - } catch (e: HostKeyChangedException) { - // A changed host key is a security signal, not a network hiccup: say so, and do not - // quietly route around it. - val msg = e.message ?: "The computer's SSH host key changed." - _state.value = ConnState.Failed(msg) - throw HostException(msg) } catch (e: Exception) { - errors += "On your network: ${e.message ?: e.javaClass.simpleName}" + // A changed host key is refused for SSH and said out loud, but in Auto it does not + // stop the relay leg, which authenticates the computer on its own (audit A49/A74): + // most often another machine simply has the paired address now. "Only on my + // network" has nothing else to try, so there it stops. + when (val next = SshFallback.afterFailure(e, route != RoutePreference.SSH_ONLY, relayConfigured(host))) { + is SshFallback.Next.Stop -> { + _sshWarning.value = null + _state.value = ConnState.Failed(next.message) + throw HostException(next.message) + } + is SshFallback.Next.TryRelay -> { + errors += next.error + sshWarning = next.warning + } + } } } @@ -126,6 +145,7 @@ class HostSession(val hostId: String, private val graph: AppGraph) { } graph.relayGate.onConnected(hostId) _snapshot.value = connected.first + _sshWarning.value = sshWarning adopt(connected.connection) return connected.connection } catch (e: kotlinx.coroutines.CancellationException) { @@ -143,10 +163,16 @@ class HostSession(val hostId: String, private val graph: AppGraph) { } val msg = errors.joinToString("\n").ifEmpty { "Couldn't connect." } + _sshWarning.value = null // the failure message already carries it _state.value = ConnState.Failed(msg) throw HostException(msg) } + /** The phone holds a relay leg for [host]: the same three facts the relay block needs to dial. */ + private fun relayConfigured(host: PairedHost): Boolean = + host.relay != null && host.relayHostKeyB64 != null && + graph.secure.getString(SecureStore.relayTokenKey(host.id)) != null + private suspend fun dialRelay( relay: RelayBlock, token: String, diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index 8727308ef2..167c9f6863 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -108,6 +108,18 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { ConnectionBanner(state, session) + // Connected through the relay because the server at the paired address presented a + // different SSH host key (audit A49/A74): the relay proves the computer, but the change + // itself must stay visible. + val sshWarning by session.sshWarning.collectAsState() + val warn = sshWarning + if (state is ConnState.Connected && warn != null) { + Text( + warn, + Modifier.fillMaxWidth().background(NtColors.warning.copy(alpha = 0.12f)).padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) + } // A listing that failed on a connection that is still up (e.g. nodeterm's data not found // over SSH) used to be invisible: the screen just stayed empty (audit A31). val listError by session.lastError.collectAsState() diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshFallback.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshFallback.kt new file mode 100644 index 0000000000..ee00ff4305 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshFallback.kt @@ -0,0 +1,66 @@ +package dev.nodeterm.protocol.ssh + +/** + * What a connect does after the direct-SSH leg failed (audit A49/A74). + * + * The LAN leg dials the address the computer had when it was paired: usually a DHCP lease on a + * private range. After the lease moved, or with the phone on another network that uses the same + * range, a DIFFERENT machine running sshd can answer there, and its host key does not match the pin. + * That machine is never used over SSH. But it is no reason to skip the relay: the relay leg + * authenticates the computer on its own (the relay host key from pairing, then the SAS approval), + * so going on to it loses no trust. Before this, a changed key stopped the Auto route outright, and + * the only way out it named was re-pairing. + * + * Only the "Only on my network" route keeps the hard stop, because there is nothing else it may + * try. Whether the relay leg may DIAL is not decided here: the caller still asks its + * [dev.nodeterm.protocol.host.RelayApprovalGate], so a background check never makes a first relay + * handshake because of this. + * + * Pure, so it is JVM-tested; the app's `HostSession` applies it. + */ +object SshFallback { + sealed interface Next { + /** + * Go on to the relay leg. [error] joins what is shown if that fails too; [warning] is shown + * even when it succeeds (a changed host key must not disappear behind a working relay). + */ + data class TryRelay(val error: String, val warning: String? = null) : Next + + /** Try nothing else: fail with [message]. */ + data class Stop(val message: String) : Next + } + + /** Where the per-computer route setting lives, as Settings names it. */ + const val RELAY_ONLY_SETTING = + "choose “Only through the relay” for this computer in Settings → How to reach each computer." + + /** Re-pairing is one way out, not the only one: it resets the pin to the computer's current key. */ + const val REPAIR_NOTE = "If the computer was reinstalled, pairing it again trusts its new key." + + /** The relay is not an option yet: say how to get one. */ + const val NO_RELAY_ADVICE = + "This phone has no relay connection to it yet: turn on remote access in nodeterm on the computer " + + "(Settings → Phone) and pair again, which also trusts the computer's current key." + + /** + * @param relayAllowed the route lets this connect use the relay (anything but "Only on my network"). + * @param relayConfigured the phone holds a relay leg for this computer (endpoint, relay host key and + * device token), i.e. a relay dial is possible at all. + */ + fun afterFailure(error: Throwable, relayAllowed: Boolean, relayConfigured: Boolean): Next { + if (error is HostKeyChangedException) { + val fact = error.message ?: "This computer's SSH host key changed." + return when { + !relayConfigured -> Next.Stop("$fact $NO_RELAY_ADVICE") + !relayAllowed -> Next.Stop("$fact To reach it through the relay instead, $RELAY_ONLY_SETTING $REPAIR_NOTE") + else -> Next.TryRelay( + error = "On your network: $fact To reach it through the relay only, $RELAY_ONLY_SETTING $REPAIR_NOTE", + warning = "$fact The phone connected through the relay instead, which checks the computer's " + + "identity separately. To stop trying your network for it, $RELAY_ONLY_SETTING $REPAIR_NOTE" + ) + } + } + val line = "On your network: ${error.message ?: error.javaClass.simpleName}" + return if (relayAllowed) Next.TryRelay(line) else Next.Stop(line) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index 9afbbf5ab1..87d3003b9a 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -50,15 +50,33 @@ import java.util.concurrent.RejectedExecutionException import java.util.concurrent.TimeUnit import javax.net.SocketFactory -/** Trust-on-first-use pin for the computer's SSH host key (`SHA256:`, OpenSSH's format). */ +/** + * Trust-on-first-use pin for the computer's SSH host key (`SHA256:`, OpenSSH's format). + * + * [pin] is called only once the server has ACCEPTED this phone's key (audit A49), never during the + * key exchange: a machine that merely answered at the paired address — another computer that now + * has that DHCP lease, or the same private range on another network — refuses our key, and it must + * not become the pin. A server that accepts our key is the one the pairing installed it on (or one + * that accepts any key; anchoring the pin in the pairing itself is still to come). + */ interface HostKeyPin { - /** The pinned fingerprint, or null before the first successful connect. */ + /** The pinned fingerprint, or null until a connect has authenticated with this phone's key. */ fun pinned(): String? fun pin(fingerprint: String) } +/** + * The server at the paired address presented a key other than the pinned one. The message states + * the fact and its likely causes; what the user can do about it depends on the route, and + * [SshFallback] adds it. + */ class HostKeyChangedException(val expected: String, val actual: String) : - Exception("This computer's SSH host key changed (expected $expected, got $actual). If you reinstalled it, remove and re-pair it; otherwise someone may be intercepting the connection.") + Exception( + "This computer's SSH host key changed (expected $expected, got $actual), so the phone did not connect " + + "to it over your network. Another machine may now have its network address (a different Wi-Fi, or " + + "a reassigned address), or the computer was reinstalled; if neither, someone may be intercepting " + + "the connection." + ) /** * [HostConnection] over direct SSH (the LAN leg a pairing installs a key for). Everything is POSIX @@ -480,18 +498,26 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos val client = SSHClient(DefaultConfig().apply { keepAliveProvider = KeepAliveProvider.KEEP_ALIVE }) if (socketFactory != null) client.socketFactory = socketFactory var mismatch: HostKeyChangedException? = null + // The key this connection's server presented, held until authentication proves it is + // the computer we paired with; only then does it become the pin (audit A49). + var presented: String? = null client.addHostKeyVerifier(object : HostKeyVerifier { override fun verify(hostname: String, port: Int, key: PublicKey): Boolean { val fp = fingerprint(key) - val pinned = pin.pinned() + // One server per connection: a re-key must present the same key as the first + // exchange, pinned or not. + val expected = pin.pinned() ?: presented return when { - pinned == null -> { - pin.pin(fp) + expected == null -> { + presented = fp + true + } + expected == fp -> { + presented = fp true } - pinned == fp -> true else -> { - mismatch = HostKeyChangedException(pinned, fp) + mismatch = HostKeyChangedException(expected, fp) false } } @@ -509,6 +535,10 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos override fun getPublic(): PublicKey = kp.public override fun getType(): KeyType = KeyType.ED25519 }) + // Authenticated: this server accepted the key the pairing installed, so its host key + // is the computer's. A server that refused us never got here and pinned nothing. + val seen = presented + if (seen != null && pin.pinned() == null) pin.pin(seen) client.connection.keepAlive.keepAliveInterval = KEEPALIVE_INTERVAL_SEC (client.connection.keepAlive as? KeepAliveRunner)?.maxAliveCount = KEEPALIVE_MAX_MISSED } catch (e: Exception) { diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt new file mode 100644 index 0000000000..777da8a2da --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt @@ -0,0 +1,88 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.SshFallback +import dev.nodeterm.protocol.ssh.SshFallback.Next +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Audit A49/A74: what a connect does after the direct-SSH leg failed. */ +class SshFallbackTest { + private val changed = HostKeyChangedException("SHA256:paired", "SHA256:whoever-has-the-address-now") + private val relaySetting = "“Only through the relay”" + + @Test + fun `in Auto a changed host key goes on to the relay, and says so even when the relay works`() { + val next = SshFallback.afterFailure(changed, relayAllowed = true, relayConfigured = true) + assertIs(next) + // Shown if the relay fails as well… + assertTrue(next.error.startsWith("On your network: ")) + assertTrue(next.error.contains("SHA256:whoever-has-the-address-now")) + // …and while connected through it, so the change never disappears behind a working relay. + val warning = assertNotNull(next.warning) + assertTrue(warning.contains("SHA256:paired") && warning.contains("SHA256:whoever-has-the-address-now")) + assertTrue(warning.contains("through the relay instead")) + assertTrue(warning.contains(relaySetting), warning) + } + + @Test + fun `Only on my network keeps the hard stop, and names the way out`() { + val next = SshFallback.afterFailure(changed, relayAllowed = false, relayConfigured = true) + assertIs(next) + assertTrue(next.message.contains("SHA256:whoever-has-the-address-now")) + assertTrue(next.message.contains(relaySetting), next.message) + assertTrue(next.message.contains("Settings → How to reach each computer")) + } + + @Test + fun `with no relay leg a changed key stops, and says how to get one`() { + for (allowed in listOf(true, false)) { + val next = SshFallback.afterFailure(changed, relayAllowed = allowed, relayConfigured = false) + assertIs(next) + // The relay-only setting would not help here: do not send the user to it. + assertFalse(next.message.contains(relaySetting), next.message) + assertTrue(next.message.contains("turn on remote access")) + } + } + + @Test + fun `re-pairing is offered as one way out, not as the only one`() { + val texts = listOf( + SshFallback.afterFailure(changed, relayAllowed = true, relayConfigured = true).let { it as Next.TryRelay } + .let { listOf(it.error, it.warning!!) }, + listOf((SshFallback.afterFailure(changed, relayAllowed = false, relayConfigured = true) as Next.Stop).message) + ).flatten() + for (t in texts) { + assertFalse(t.contains("remove and re-pair", ignoreCase = true), t) + assertTrue(t.contains(relaySetting), t) + } + // The bare exception names the likely causes before the alarming one. + val fact = changed.message!! + assertTrue(fact.indexOf("network address") < fact.indexOf("intercepting"), fact) + } + + @Test + fun `an ordinary SSH failure falls through in Auto and stops on the SSH-only route, as before`() { + val down = HostException("Couldn't connect over SSH to dev@10.0.0.2:22 (timeout).") + assertEquals( + Next.TryRelay("On your network: Couldn't connect over SSH to dev@10.0.0.2:22 (timeout)."), + SshFallback.afterFailure(down, relayAllowed = true, relayConfigured = true) + ) + assertNull((SshFallback.afterFailure(down, relayAllowed = true, relayConfigured = false) as Next.TryRelay).warning) + assertEquals( + Next.Stop("On your network: Couldn't connect over SSH to dev@10.0.0.2:22 (timeout)."), + SshFallback.afterFailure(down, relayAllowed = false, relayConfigured = true) + ) + // No message at all still says what failed. + assertEquals( + Next.TryRelay("On your network: IllegalStateException"), + SshFallback.afterFailure(IllegalStateException(), relayAllowed = true, relayConfigured = true) + ) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index aa0db4e5b0..e5796edf31 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -33,6 +33,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -474,6 +475,27 @@ class SshTransportTest { assertFailsWith { connect(MemoryPin("SHA256:not-this-host")) } } + @Test + fun `a server that refuses our key never becomes the pin (A49)`() { + // What answers at the paired address after the DHCP lease moved, or on another network that + // uses the same private range: an sshd that completes the key exchange and then refuses the + // phone's key. Pinning its host key during the exchange made the real computer "changed" on + // the next connect. + val pin = MemoryPin() + assertFailsWith { + SshHostConnection.connect("127.0.0.1", port, "dev", SshIdentity.generate(), pin).close() + } + assertNull(pin.value, "a key exchange whose authentication failed must not pin") + assertFailsWith { + SshHostConnection.connect("127.0.0.1", port, "someone-else", identity, pin).close() + } + assertNull(pin.value, "a refused user must not pin either") + // The first connect that AUTHENTICATES pins exactly the server's host key. + connect(pin).close() + val serverKey = server.keyPairProvider.loadKeys(null).first().public + assertEquals(SshHostConnection.fingerprint(serverKey), pin.value) + } + @Test fun `a key the computer does not know is refused`() { assertFailsWith { diff --git a/docs/android.md b/docs/android.md index 764d5348ad..3f59dde88f 100644 --- a/docs/android.md +++ b/docs/android.md @@ -22,8 +22,20 @@ A phone reaches a paired computer one of two ways, and the app tries them in the relay token (`POST /v1/relay/join`), runs the handshake, and — the first time only — waits while the desktop shows the SAS approval dialog (pin-once). -`Auto` tries SSH with a 4 s budget and falls back to the relay; a changed SSH host key is a hard -stop, never a fallback. Per-computer overrides live in Settings. +`Auto` tries SSH with a 4 s budget and falls back to the relay. Per-computer overrides live in +Settings ("How to reach each computer"). + +**SSH host key.** Trust on first use, and the pin is saved only once the server has accepted the +phone's key (audit `A49`): a machine that merely answers at the paired address and refuses us never +becomes the pin. A key that differs from the pin is never used over SSH. In `Auto` the connect then +goes on to the relay (`SshFallback`, audit `A74`), which authenticates the computer on its own (the +relay host key from pairing, then the SAS approval), and the host screen keeps a warning up while +connected that way; the relay dial still goes through `RelayApprovalGate`, so a background check +never makes a first relay handshake because of it. "Only on my network" stops with the warning. The +usual cause is benign: the LAN leg dials the DHCP address the computer had at pairing, and another +SSH-running machine now has it (or the phone is on another network using the same range). The +message points at "Only through the relay"; pairing again is the way to trust a reinstalled +computer's new key. **Relay approval.** The standing host raises its SAS dialog as soon as an unpinned phone completes the handshake, so the phone decides *before dialing* (`RelayApprovalGate`): the background worker @@ -129,6 +141,12 @@ cap does not respect. `TerminalJsOsc52Test` runs the app's real `terminal.js` in xterm/bridge objects and checks that it applies that cap before a copy crosses the WebView bridge. The app also catches a failing `setPrimaryClip` and says so in a toast; that part is not tested. +An SSH test pins that a server which completes the key exchange and then refuses the phone's key +(or user) leaves the host-key pin empty (`A49`). `SshFallbackTest` pins what follows a failed SSH +leg (`A74`): a changed key goes on to the relay in Auto with a warning, stops on the SSH-only route, +and its text names "Only through the relay" rather than only re-pairing. The app's use of it (the +relay dial behind `RelayApprovalGate`, the warning on the host screen) is only type-checked. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's @@ -159,4 +177,12 @@ The app also catches a failing `setPrimaryClip` and says so in a toast; that par - **Codex/Gemini/… launch flags.** A phone-started non-Claude agent launches bare (its own default approval mode): the per-agent approval table needs host facts (codex's vocabulary moved between releases, #785) the mirror only partly publishes. +- **The SSH pin is not anchored in pairing, and the LAN address is frozen at pairing** (audit + `A49`/`A74`). Neither the QR nor the sealed `/pair` answer carries the computer's SSH host key, so + the first connect is trust on first use (on the pairing LAN, right after the QR, so normally the + real computer). A server that accepts any key could still become the pin. The fix is desktop-side + as well (return the host key fingerprints inside the sealed `/pair` answer and store them in + `PairedHost.from`; it would serve iOS too). Nothing refreshes `PairedHost.host` either: the desktop + could publish its current LAN address over the relay, letting the phone update it after a relay + connect. - **Instrumented UI tests** and a store listing do not exist yet. From ea19490aa27d244a5c903710c73355a92cf591ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:27:02 +0000 Subject: [PATCH 044/284] fix(android): address review of A39+A75 Correct a false claim written in three places by the A39+A75 fix: that only the desktop shell publishes the mirror's `usage` block. The Server Edition publishes it too: registerCoreHandlers (src/server/handlers/ index.ts) calls setMirrorUsageProvider, and src/server/index.ts boots it and runs initAgentStatusMirror. The claim contradicted docs/mobile-usage-inbox.md's own "A host that runs nodeterm itself (server edition) writes its own mirror with its own usage." The design decision stands on its two remaining reasons, which are now the only ones stated: `usage` is dropped from SSH slices, and it is empty until the first usage poll. - src/core/agent-status-mirror.ts (MirrorClaudeAccount doc comment) - android/protocol/.../model/AccountNames.kt (resolver source order); also reworded source (1) from "every desktop shell" to "both shells (desktop and Server Edition) and the desktop's per-host SSH slice", which is what mirrorClaudeAccount's callers are - docs/mobile-usage-inbox.md (settings.claudeAccounts paragraph) Comment and doc text only, so there is no behaviour to regression-test. Verified: android/protocol gradle test --offline (141 tests, 0 failures), android/tools/typecheck gradle compileKotlin --offline, npm run typecheck, and npx vitest run src/core/agent-status-mirror (198 passed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../kotlin/dev/nodeterm/protocol/model/AccountNames.kt | 9 +++++---- docs/mobile-usage-inbox.md | 4 ++-- src/core/agent-status-mirror.ts | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt index 721da8a1cb..c57b4c988a 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt @@ -6,10 +6,11 @@ package dev.nodeterm.protocol.model * sessions row — so the two cannot disagree about the same id. * * Where a name comes from, in order: - * 1. `settings.claudeAccounts[].label` — written by every desktop shell and the per-host SSH slice - * (`mirrorClaudeAccount`), so it is the one source that is there whenever the account is; - * 2. `usage.accounts[].label` — a desktop older than (1) still has it, but only the desktop shell - * publishes it, only for local accounts, and only after the first usage poll; + * 1. `settings.claudeAccounts[].label` — written by both shells (desktop and Server Edition) and + * the desktop's per-host SSH slice (`mirrorClaudeAccount`), so it is the one source that is + * there whenever the account is; + * 2. `usage.accounts[].label` — a host older than (1) still has it, but only for local accounts + * (it is dropped from SSH slices) and only after the first usage poll; * 3. the email, from either block; * 4. a short form of the id ([fallbackName]) — never the whole UUID. * diff --git a/docs/mobile-usage-inbox.md b/docs/mobile-usage-inbox.md index 75096b11bc..2ca499ea06 100644 --- a/docs/mobile-usage-inbox.md +++ b/docs/mobile-usage-inbox.md @@ -33,8 +33,8 @@ export interface MirrorFile { `mirrorClaudeAccount` in both shells and in the desktop's per-host SSH slice. `label` and `email` are display-only, and absent when blank or on an older desktop. A reader names a managed account by that `label`, else the `usage` row's label, else an email, else a short form of the id — never by -the raw id (Android: `AccountNames`). The `usage` block alone is not enough for this: only the -desktop shell publishes it, it is dropped from SSH slices, and it is empty until the first poll. +the raw id (Android: `AccountNames`). The `usage` block alone is not enough for this: it is +dropped from SSH slices, and it is empty until the first poll. ### Usage diff --git a/src/core/agent-status-mirror.ts b/src/core/agent-status-mirror.ts index 71cf73391a..8a60aa7aa9 100644 --- a/src/core/agent-status-mirror.ts +++ b/src/core/agent-status-mirror.ts @@ -201,8 +201,8 @@ export interface MirrorSettings { * One managed Claude account as the mirror advertises it. `id` + `dir` are what a phone launches * with (`CLAUDE_CONFIG_DIR`); `label` + `email` are what it SHOWS, so a picker or a session row * never has to print the account's raw UUID. The `usage` block also carries a label per account, - * but it is local-only (dropped from SSH slices), only the desktop shell publishes it, and it is - * empty until the first usage poll — so the name has to ride here too. + * but it is local-only (dropped from SSH slices) and empty until the first usage poll — so the + * name has to ride here too. * * `label` and `email` are additive (absent on files written before them; old readers ignore * them) and absent when blank. From 45e21ec476f71770f603a5af0c4b19eef745e58a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:29:06 +0000 Subject: [PATCH 045/284] docs(android): address review of A56 Raise the iOS side of the A56 finding instead of framing it as an Android-only rationale. The earlier caveat on the iOS `2` line ended "Android therefore has no 'Always allow'", so a reader of the desktop PR had no signal that the iOS button is exposed to the same prompt-layout hazard. CLAUDE.md's three-surfaces rule says a mobile implication is raised in the desktop PR with @eneskirca mentioned. Re-verified against the Claude Code 2.1.283 binary installed here (a static read of MYe(), not a live prompt): the Bash prompt pushes `Yes`, then a suggestion / "don't ask again" row only under the XB() gate and only when there is a suggestion, then "Yes, and switch to auto mode" when showEnableAutoModeOption is set, then `No`. So option 2 can be `No` or the auto-mode switch. - docs/hook-reply-approvals.md: the caveat now spells out the full option order, states that the iOS "Always allow" path carries the hazard and is an iOS follow-up for nodeterm-ios (@eneskirca), says this repo cannot see whether the iOS code guards the digit, and names the shared fix (hook `updatedPermissions`, which changes the answer file and approvals.answer on all three surfaces). - docs/android.md, Known gaps: the same iOS follow-up line. Not done here: the reviewer also asked to add A56 to the WP7 list of @eneskirca mobile implications in docs/android-handover.md. That file is maintained by the orchestrator, so the entry is left to it. Docs only, no behaviour change, so there is no test to add. Verified: android/protocol `gradle test --offline` (141 tests, 0 failures) and android/tools/typecheck `gradle compileKotlin --offline`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android.md | 15 ++++++++------- docs/hook-reply-approvals.md | 14 ++++++++++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/android.md b/docs/android.md index 3f59dde88f..c79fddaf7c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -163,17 +163,18 @@ relay dial behind `RelayApprovalGate`, the warning on the host screen) is only t one itself, so it can see it). A phone attached over direct SSH is detached (exit 0), and so is a relay phone on a desktop older than that change; the app checks that the session is still live and reattaches. -- **No "Always allow" on approval cards** (audit `A56`; iOS has one). iOS types `2`, and +- **No "Always allow" on approval cards** (audit `A56`). iOS has one that types `2`, and docs/hook-reply-approvals.md is the only place the repo states that digit. Read from the Claude Code 2.1.283 bundle (not measured on a live prompt), option 2 depends on the ask: the Bash prompt offers `Yes`, then a "don't ask again" row only when Claude has one for this ask, then an optional "Yes, and switch to auto mode", then `No`. So a blind `2` can deny the request or switch - the session to auto mode, and the phone cannot see which. A held ticket (the default, since - `hookReplyApprovals` is on) has no prompt on screen at all. The layout-independent route is the - hook's own: answer `allow` with `updatedPermissions` taken from the request's - `permission_suggestions` (both fields exist in that CLI's hook schema). That changes the - `~/.nodeterm/pending` answer file and `approvals.answer`, so it needs the desktop, iOS and Android - together. + the session to auto mode, and the phone cannot see which. The same hazard applies to iOS's `2`: + an iOS follow-up for @eneskirca (this repo cannot see whether the iOS code guards the digit). + A held ticket (the default, since `hookReplyApprovals` is on) has no prompt on screen at all. + The layout-independent route is the hook's own: answer `allow` with `updatedPermissions` taken + from the request's `permission_suggestions` (both fields exist in that CLI's hook schema). That + changes the `~/.nodeterm/pending` answer file and `approvals.answer`, so it needs the desktop, + iOS and Android together. - **Codex/Gemini/… launch flags.** A phone-started non-Claude agent launches bare (its own default approval mode): the per-agent approval table needs host facts (codex's vocabulary moved between releases, #785) the mirror only partly publishes. diff --git a/docs/hook-reply-approvals.md b/docs/hook-reply-approvals.md index 61a556bd5d..168c914882 100644 --- a/docs/hook-reply-approvals.md +++ b/docs/hook-reply-approvals.md @@ -37,10 +37,16 @@ must be a **no-op** when the env var is absent (user's own terminals, older node **Answerers** (all write the same one-line answer file, atomically `printf > tmp && mv`): - **Phone (SSH)** — `InboxApproval` writes it over the connection when the approval event carries `pendingId`; else falls back to send-keys. Digit `2`/"Always allow" keeps using - send-keys in v1 (hook `updatedPermissions` is out of scope). Caveat: option 2 is not fixed. - In the Claude Code 2.1.283 bundle (read, not measured live) the Bash prompt shows a "don't ask - again" row only when Claude offers one for that ask, so `2` can be `No` or "Yes, and switch to - auto mode". Android therefore has no "Always allow" (docs/android.md, Known gaps). + send-keys in v1 (hook `updatedPermissions` is out of scope). Caveat: option 2 is not fixed. In + the Claude Code 2.1.283 bundle (read, not measured live) the Bash prompt is `Yes`, then a + "don't ask again" row only when Claude offers one for that ask (and behind a feature gate), + then an optional "Yes, and switch to auto mode", then `No`. So `2` can answer `No` or switch + the whole session to auto mode. **The iOS "Always allow" above carries this hazard; it is not + only an Android concern**: an iOS follow-up for nodeterm-ios (@eneskirca), and this repo + cannot see whether the iOS code guards the digit (for example by reading the pane before + typing). Android has no "Always allow" for the same reason (docs/android.md, Known gaps). The + fix for both phones is the hook's own `updatedPermissions` (see Out of scope), which changes + this answer file and `approvals.answer`, so the desktop, iOS and Android change together. - **Desktop canvas** — the NEEDS-YOU badge gains Approve/Deny buttons (approval events with `pendingId` only), routed over IPC to a main-side writer: local project → local fs; SSH project → write via the project's ControlMaster. So desktop users are not left staring at From 144bd40d78775077a52cc7fa054de10e6252f04f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:34:10 +0000 Subject: [PATCH 046/284] fix(android): address review of A58 The Usages pace line compared a usedPercent measured at the desktop's last fetch with the share of the window elapsed at the phone's CURRENT time. Usage only grows inside a window, so any lag between measurement and display could only push the verdict toward "slower". The desktop polls Claude usage every 15 minutes (POLL_MS, usage-service.ts), which is five points of a 5h window, the whole on-pace band; a mirror read over SSH while the desktop app is not running is older still. Someone burning through the window could therefore be told they were on pace. What changed: - UsagePace.of now takes measuredAt (the account's updatedAt, which buildMirrorUsage stamps with the fetch time, and which an error snapshot never pairs with old limits) and now, as required parameters so a caller cannot forget the measurement time. The elapsed share is taken at measuredAt; now still decides the "already reset" refusal. measurementTime() falls back to now when updatedAt is 0 (the parser's default for a missing field), negative, or later than now (a desktop clock ahead of the phone's). A measurement more than one window before the reset is refused like any other length that does not fit. - UsageBar passes account.updatedAt. - UsagePaceTest: a stale snapshot one poll old stays "on pace" where the draw-time reading would say "slower"; a fast burn stays "faster" for as long as a fossil snapshot is drawn and gives no verdict once it has reset; the fallbacks; a pre-window measurement; the parser test now reads the pace at the parsed account's updatedAt. - docs/android.md: the UsagePaceTest sentence says what is measured. Verified: gradle test --offline in android/protocol (144 tests, all pass) and the offline app type-check. Mutation checks: measuring elapsed at now again fails 3 of the new/updated tests, and dropping the updatedAt fallback fails the fallback test. The type-check also rejects the old two-argument call. Not run on a device. The reviewer's alternative (refuse a verdict past a staleness limit) was not taken: judged at its own measurement time, a stale snapshot's line describes the same moment as the bar above it, and the tab already prints its age. docs/mobile-usage-inbox.md describes the iOS app and was left alone; iOS may carry the same draw-time bias. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/InboxTab.kt | 9 +- .../dev/nodeterm/protocol/model/UsagePace.kt | 39 +++++- .../dev/nodeterm/protocol/UsagePaceTest.kt | 117 +++++++++++++----- docs/android.md | 4 +- 4 files changed, 129 insertions(+), 40 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index 1a960bc938..7a5df8ce5f 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -294,12 +294,12 @@ private fun UsageCard(account: UsageAccount, status: AgentStatusFile?) { if (account.status != "ok" && account.limits.isEmpty()) { Text(if (account.status == "error") "Could not read usage." else "Usage unavailable.", color = MaterialTheme.colorScheme.onSurfaceVariant) } - account.limits.forEach { UsageBar(it) } + account.limits.forEach { UsageBar(it, measuredAt = account.updatedAt) } } } @Composable -private fun UsageBar(limit: UsageLimit, now: Long = System.currentTimeMillis()) { +private fun UsageBar(limit: UsageLimit, measuredAt: Long, now: Long = System.currentTimeMillis()) { val pct = limit.usedPercent.coerceIn(0.0, 100.0) val color: Color = when (limit.severity) { "critical", "error", "red" -> NtColors.attention @@ -324,8 +324,9 @@ private fun UsageBar(limit: UsageLimit, now: Long = System.currentTimeMillis()) trackColor = NtColors.panel2, modifier = Modifier.fillMaxWidth().height(6.dp) ) - // Only when the window's end and length are known (UsagePace says why it would refuse). - UsagePace.of(limit, now)?.let { reading -> + // Only when the window's end and length are known (UsagePace says why it would refuse), + // judged at the time the percentage was measured, not now (UsagePace says why). + UsagePace.of(limit, measuredAt = measuredAt, now = now)?.let { reading -> Text( reading.line, style = MaterialTheme.typography.labelSmall, diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt index 789890809f..6388a74707 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt @@ -15,7 +15,16 @@ package dev.nodeterm.protocol.model * - more time left than the whole window: the length we used is wrong for this limit (a plan whose * session window is not five hours), so any verdict built on it would be too. * - * `now` is always passed in, so the verdict is a pure function of its inputs. + * The elapsed share is taken at the moment `usedPercent` was MEASURED (the account's `updatedAt`), + * not at the moment the phone draws it. The desktop fetches Claude usage every 15 minutes + * (`POLL_MS`, usage-service.ts), and a mirror read over SSH can be far older than that when the + * desktop app is not running. Usage only grows inside a window, so comparing an old percentage with + * the elapsed share NOW can only push the verdict toward "slower": 15 minutes is five points of a + * 5h window, the whole [ON_PACE_BAND], so someone burning through the window would be told they are + * on pace. Measured at the snapshot's own time, the line describes the same moment as the bar above + * it. `now` is still what decides the "already reset" refusal. + * + * Both clocks are passed in, so the verdict is a pure function of its inputs. */ object UsagePace { /** Claude's session window, the only length its `/api/oauth/usage` implies by `kind`. Claude @@ -34,7 +43,8 @@ object UsagePace { /** * One limit's pace. [usedPercent] and [elapsedPercent] are both 0–100 shares of the SAME - * window ([windowMinutes] long), which is what makes them comparable. + * window ([windowMinutes] long), taken at the SAME moment (when the percentage was measured), + * which is what makes them comparable. */ data class Reading( val pace: Pace, @@ -72,15 +82,32 @@ object UsagePace { } } - /** The pace of [limit] at [now] (unix ms), or null when there is nothing honest to say. */ - fun of(limit: UsageLimit, now: Long): Reading? { + /** + * When a limit's `usedPercent` was measured, given the snapshot's [updatedAt]: that time, unless + * it is unknown (0, the parser's default for a missing field, or a hostile negative) or later + * than [now] (a desktop clock ahead of the phone's), in which case [now]: with no usable + * measurement time there is nothing to correct the drift with. + */ + fun measurementTime(updatedAt: Long, now: Long): Long = if (updatedAt in 1..now) updatedAt else now + + /** + * The pace of [limit] as measured at [measuredAt] (unix ms; pass the account's `updatedAt`, + * see [measurementTime] for how an unusable one is treated), read at [now] (unix ms), or + * null when there is nothing honest to say. + */ + fun of(limit: UsageLimit, measuredAt: Long, now: Long): Reading? { val resetsAt = limit.resetsAt ?: return null val minutes = windowMinutes(limit) ?: return null + val at = measurementTime(measuredAt, now) // Doubles, not Longs: both values come from a hand-editable file, and `resetsAt - now` or // `minutes * 60_000` on a hostile value would wrap around instead of being refused. val windowMs = minutes.toDouble() * 60_000.0 - val remainingMs = resetsAt.toDouble() - now.toDouble() - if (remainingMs <= 0.0 || remainingMs > windowMs) return null + // The window has reset by the time the phone draws it: the percentage is the old window's. + if (resetsAt.toDouble() - now.toDouble() <= 0.0) return null + // `at <= now < resetsAt`, so this is positive. More than the whole window means the length + // is wrong for this limit, or the measurement time does not belong to this window. + val remainingMs = resetsAt.toDouble() - at.toDouble() + if (remainingMs > windowMs) return null val elapsedPercent = (1.0 - remainingMs / windowMs) * 100.0 val used = limit.usedPercent if (used.isNaN()) return null diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt index fc8f924ccb..aa63b613a9 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt @@ -23,15 +23,18 @@ class UsagePaceTest { group: String? = null ) = UsageLimit(kind, group, used, null, resetsAt, windowMinutes, null, false) + /** A snapshot measured at [at] and drawn at the same instant. */ + private fun fresh(l: UsageLimit, at: Long = now) = UsagePace.of(l, measuredAt = at, now = at) + @Test fun `no reset time means no pace line`() { - assertNull(UsagePace.of(limit(resetsAt = null), now)) + assertNull(fresh(limit(resetsAt = null))) } @Test fun `claude reports no window length, so session and weekly kinds default to 5h and 7d`() { // Halfway through a 5h window (150 of 300 minutes left) with half of it used. - val session = assertNotNull(UsagePace.of(limit(kind = "session", used = 50.0, resetsAt = now + 150 * minute), now)) + val session = assertNotNull(fresh(limit(kind = "session", used = 50.0, resetsAt = now + 150 * minute))) assertEquals(300L, session.windowMinutes) assertEquals(50.0, session.elapsedPercent, 1e-9) assertEquals(Pace.ON_PACE, session.pace) @@ -39,7 +42,7 @@ class UsagePaceTest { // One day into a week: 1/7 elapsed; 60% used is far ahead of that. for (kind in listOf("weekly_all", "weekly_scoped")) { - val weekly = assertNotNull(UsagePace.of(limit(kind = kind, used = 60.0, resetsAt = now + 6 * 1_440 * minute), now), kind) + val weekly = assertNotNull(fresh(limit(kind = kind, used = 60.0, resetsAt = now + 6 * 1_440 * minute)), kind) assertEquals(10_080L, weekly.windowMinutes, kind) assertEquals(100.0 / 7, weekly.elapsedPercent, 1e-9, kind) assertEquals(Pace.FASTER, weekly.pace, kind) @@ -51,7 +54,7 @@ class UsagePaceTest { fun `a reported window length wins over the kind's default and names the line`() { // Codex reports a 7h session window: 210 of 420 minutes left = 50% elapsed. Against the // 5h default the same resetsAt would be 30% elapsed and 50% used would read "faster". - val r = assertNotNull(UsagePace.of(limit(kind = "session", used = 50.0, resetsAt = now + 210 * minute, windowMinutes = 420), now)) + val r = assertNotNull(fresh(limit(kind = "session", used = 50.0, resetsAt = now + 210 * minute, windowMinutes = 420))) assertEquals(420L, r.windowMinutes) assertEquals(Pace.ON_PACE, r.pace) assertEquals("7h usage on pace", r.line) @@ -60,7 +63,7 @@ class UsagePaceTest { @Test fun `an unknown kind gets no guessed window, but its group or a reported length is used`() { assertNull(UsagePace.windowMinutes(limit(kind = "monthly_credits"))) - assertNull(UsagePace.of(limit(kind = "monthly_credits", resetsAt = now + minute), now)) + assertNull(fresh(limit(kind = "monthly_credits", resetsAt = now + minute))) assertEquals(300L, UsagePace.windowMinutes(limit(kind = "session_opus", group = "session"))) assertEquals(10_080L, UsagePace.windowMinutes(limit(kind = "opus_week", group = "weekly"))) assertEquals(43_200L, UsagePace.windowMinutes(limit(kind = "monthly_credits", windowMinutes = 43_200))) @@ -72,47 +75,99 @@ class UsagePaceTest { @Test fun `slower and faster are decided outside the on-pace band, inclusive at its edge`() { // 150 of 300 minutes left: 50% elapsed. - fun paceAt(used: Double) = UsagePace.of(limit(used = used), now)!!.pace + fun paceAt(used: Double) = fresh(limit(used = used))!!.pace assertEquals(Pace.ON_PACE, paceAt(55.0), "exactly +band is still on pace") assertEquals(Pace.ON_PACE, paceAt(45.0), "exactly -band is still on pace") assertEquals(Pace.FASTER, paceAt(55.1)) assertEquals(Pace.SLOWER, paceAt(44.9)) - assertEquals("5h usage pace slower", UsagePace.of(limit(used = 10.0), now)!!.line) + assertEquals("5h usage pace slower", fresh(limit(used = 10.0))!!.line) } @Test fun `a window that has reset, or is longer than the length we assumed, gets no verdict`() { - assertNull(UsagePace.of(limit(resetsAt = now), now), "resets now: the percentage is the old window's") - assertNull(UsagePace.of(limit(resetsAt = now - minute), now), "already reset") + assertNull(fresh(limit(resetsAt = now)), "resets now: the percentage is the old window's") + assertNull(fresh(limit(resetsAt = now - minute)), "already reset") // 301 minutes left of a "5h" window: the window is not five hours, so the verdict would be wrong. - assertNull(UsagePace.of(limit(resetsAt = now + 301 * minute), now)) + assertNull(fresh(limit(resetsAt = now + 301 * minute))) // Exactly the whole window left is the window's first instant: valid, 0% elapsed. - val start = assertNotNull(UsagePace.of(limit(used = 0.0, resetsAt = now + 300 * minute), now)) + val start = assertNotNull(fresh(limit(used = 0.0, resetsAt = now + 300 * minute))) assertEquals(0.0, start.elapsedPercent, 1e-9) assertEquals(Pace.ON_PACE, start.pace) // The last millisecond: almost all of it elapsed, and 100% used is on pace. - assertEquals(Pace.ON_PACE, UsagePace.of(limit(used = 100.0, resetsAt = now + 1), now)!!.pace) + assertEquals(Pace.ON_PACE, fresh(limit(used = 100.0, resetsAt = now + 1))!!.pace) } @Test - fun `the clock is injected, so the same limit reads differently as time passes`() { + fun `the clocks are injected, so the same percentage measured later in the window reads differently`() { val resetsAt = now + 300 * minute // a fresh 5h window, 60% already used val l = limit(used = 60.0, resetsAt = resetsAt) - assertEquals(Pace.FASTER, UsagePace.of(l, now)!!.pace) - assertEquals(Pace.ON_PACE, UsagePace.of(l, now + 180 * minute)!!.pace, "60% elapsed, 60% used") - assertEquals(Pace.SLOWER, UsagePace.of(l, now + 270 * minute)!!.pace, "90% elapsed, 60% used") - assertNull(UsagePace.of(l, resetsAt), "and nothing once it has reset") + assertEquals(Pace.FASTER, fresh(l)!!.pace) + assertEquals(Pace.ON_PACE, fresh(l, now + 180 * minute)!!.pace, "60% elapsed, 60% used") + assertEquals(Pace.SLOWER, fresh(l, now + 270 * minute)!!.pace, "90% elapsed, 60% used") + assertNull(fresh(l, resetsAt), "and nothing once it has reset") + } + + @Test + fun `a stale snapshot is judged when it was measured, so it never drifts toward slower`() { + // Measured with 150 of 300 minutes left: 50% elapsed. Usage only grows inside a window, so + // comparing this percentage with the elapsed share at a LATER instant would understate the + // burn more the older the snapshot gets. + val measured = now + val resetsAt = measured + 150 * minute + + // One desktop poll (POLL_MS, 15 min) later: 55% elapsed now, but still 50% when measured. + val onPace = limit(used = 49.0, resetsAt = resetsAt) + val nextPoll = measured + 15 * minute + val atPoll = assertNotNull(UsagePace.of(onPace, measuredAt = measured, now = nextPoll)) + assertEquals(50.0, atPoll.elapsedPercent, 1e-9) + assertEquals(Pace.ON_PACE, atPoll.pace, "judged at the draw time instead, 49 vs 55 would read slower") + assertEquals(Pace.SLOWER, fresh(onPace, nextPoll)!!.pace, "the same number freshly measured then IS slower") + + // A fast burn stays "faster" for as long as the snapshot is drawn, fossil mirror included. + val burning = limit(used = 70.0, resetsAt = resetsAt) + for (age in listOf(0L, 15L, 60L, 149L)) { + val r = assertNotNull(UsagePace.of(burning, measuredAt = measured, now = measured + age * minute), "age $age") + assertEquals(Pace.FASTER, r.pace, "age $age min") + assertEquals(50.0, r.elapsedPercent, 1e-9, "age $age min") + } + // ...until the window it describes has reset. + assertNull(UsagePace.of(burning, measuredAt = measured, now = resetsAt)) + } + + @Test + fun `a measurement time that is unknown, hostile or ahead of the phone falls back to now`() { + assertEquals(now - minute, UsagePace.measurementTime(now - minute, now)) + assertEquals(now, UsagePace.measurementTime(now, now)) + assertEquals(now, UsagePace.measurementTime(0, now), "0 is the parser's default for a missing updatedAt") + assertEquals(now, UsagePace.measurementTime(-1, now)) + assertEquals(now, UsagePace.measurementTime(Long.MIN_VALUE, now)) + assertEquals(now, UsagePace.measurementTime(now + minute, now), "a desktop clock ahead of the phone's") + assertEquals(now, UsagePace.measurementTime(Long.MAX_VALUE, now)) + val l = limit(used = 49.0) // 150 of 300 minutes left at `now` + for (at in listOf(0L, -1L, now + 10 * minute)) { + assertEquals(fresh(l), UsagePace.of(l, measuredAt = at, now = now), "measuredAt $at") + } + } + + @Test + fun `a measurement from before the window began gets no verdict`() { + // 350 minutes between the measurement and the reset: longer than the 5h window, so either + // the length is wrong for this limit or the timestamp is not this window's. + assertNull(UsagePace.of(limit(resetsAt = now + 150 * minute), measuredAt = now - 200 * minute, now = now)) + // Exactly the whole window before the reset is the window's first instant. + val start = assertNotNull(UsagePace.of(limit(used = 0.0, resetsAt = now + 150 * minute), measuredAt = now - 150 * minute, now = now)) + assertEquals(0.0, start.elapsedPercent, 1e-9) } @Test fun `hostile values from the hand-editable mirror are clamped or refused, never wrapped`() { - assertEquals(100.0, UsagePace.of(limit(used = 250.0), now)!!.usedPercent) - assertEquals(0.0, UsagePace.of(limit(used = -20.0), now)!!.usedPercent) - assertNull(UsagePace.of(limit(used = Double.NaN), now)) + assertEquals(100.0, fresh(limit(used = 250.0))!!.usedPercent) + assertEquals(0.0, fresh(limit(used = -20.0))!!.usedPercent) + assertNull(fresh(limit(used = Double.NaN))) // Long arithmetic would wrap `resetsAt - now` and `minutes * 60000` to plausible values. - assertNull(UsagePace.of(limit(resetsAt = Long.MIN_VALUE), now)) - assertNull(UsagePace.of(limit(resetsAt = Long.MAX_VALUE), now)) - val huge = assertNotNull(UsagePace.of(limit(resetsAt = now + minute, windowMinutes = Long.MAX_VALUE), now)) + assertNull(fresh(limit(resetsAt = Long.MIN_VALUE))) + assertNull(fresh(limit(resetsAt = Long.MAX_VALUE))) + val huge = assertNotNull(fresh(limit(resetsAt = now + minute, windowMinutes = Long.MAX_VALUE))) assertEquals(Pace.SLOWER, huge.pace, "one minute left of an enormous window: nearly all of it has elapsed") } @@ -125,18 +180,22 @@ class UsagePaceTest { } @Test - fun `a mirror limit without windowMinutes still gets a pace line`() { + fun `a mirror limit without windowMinutes still gets a pace line, measured at its account's updatedAt`() { // The desktop's buildMirrorUsage writes `windowMinutes: null` for every Claude limit - // (agent-status-mirror.test.ts), which is why the kind defaults exist at all. + // (agent-status-mirror.test.ts), which is why the kind defaults exist at all, and stamps each + // account with the time its numbers were fetched. val resetsAt = now + 60 * minute + val fetchedAt = now - 30 * minute val snap = ProjectsParser.parseBlob( "--NT-STATUS-SPLIT--\n" + - """{"v":1,"updatedAt":1,"nodes":{},"usage":{"updatedAt":1,"accounts":[{"accountId":null,"label":null,"email":null,"agentId":"claude","status":"ok","updatedAt":1,"limits":[{"kind":"session","group":"session","usedPercent":95,"severity":null,"resetsAt":$resetsAt,"windowMinutes":null,"scopeLabel":null,"isActive":true}]}]}}""" + """{"v":1,"updatedAt":$fetchedAt,"nodes":{},"usage":{"updatedAt":$fetchedAt,"accounts":[{"accountId":null,"label":null,"email":null,"agentId":"claude","status":"ok","updatedAt":$fetchedAt,"limits":[{"kind":"session","group":"session","usedPercent":95,"severity":null,"resetsAt":$resetsAt,"windowMinutes":null,"scopeLabel":null,"isActive":true}]}]}}""" ) - val l = snap.status!!.usage!!.accounts.single().limits.single() + val account = snap.status!!.usage!!.accounts.single() + val l = account.limits.single() assertNull(l.windowMinutes) - val r = assertNotNull(UsagePace.of(l, now)) - assertEquals(80.0, r.elapsedPercent, 1e-9) + val r = assertNotNull(UsagePace.of(l, measuredAt = account.updatedAt, now = now)) + // 90 of 300 minutes were left when it was fetched: 70% elapsed then (80% by now). + assertEquals(70.0, r.elapsedPercent, 1e-9) assertEquals("5h usage pace faster", r.line) } diff --git a/docs/android.md b/docs/android.md index c79fddaf7c..0883e059e6 100644 --- a/docs/android.md +++ b/docs/android.md @@ -133,7 +133,9 @@ Inbox quick answer needs (`A38`): a ticketed approval is judged by its card, so the node still shows WAITING for a held question; keys are typed only for a ticketless approval on a BLOCKED node or a question on a WAITING one. `UsagePaceTest` pins the Usages pace line (`A58`): no line without a reset time, 300/10080-minute defaults for the session/weekly kinds (Claude reports no -window length), none for an unknown kind, and an injected clock. `Osc52Test` pins OSC 52 copy +window length), none for an unknown kind, and injected clocks; the elapsed share is taken at the +account's `updatedAt` (when the percentage was measured), so a stale snapshot never drifts toward +"slower". `Osc52Test` pins OSC 52 copy (`A53`): parsed like the desktop's `parseOsc52` (the `;` is required, a `?` read query is refused, base64 and UTF-8 are decoded strictly), but capped at 100,000 characters, because the clipboard write is a binder call whose buffer (about 1 MB, shared) the desktop's 1,000,000-character base64 From 143543c0270d9e98aff66a2a399eb88a56c522fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:43:13 +0000 Subject: [PATCH 047/284] docs(pty): the #427 agent pin comment no longer claims relay attaches carry no sshRemote Since A09 the relay host attaches an SSH-project node over its ControlMaster, so "the relay host's detached callers pass no sshRemote at all" was false. That attach does not need the probe here: its conn comes from SshProjectManager.sshRemoteFor, which the manager annotated with the same probe at connect. Comment only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- src/core/pty-manager.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/pty-manager.ts b/src/core/pty-manager.ts index e6502cb54a..639953b24a 100644 --- a/src/core/pty-manager.ts +++ b/src/core/pty-manager.ts @@ -2282,9 +2282,11 @@ export class PtyManager { // gives that session the same agent-gated hook env a canvas spawn gets, and `attachDetached` // records the owner. const projectOverrides = await this.projectSpawnOverrides(options) - // Resolved HERE for the same synchronous-spawnSession reason as projectOverrides — and the - // relay host's detached callers pass no `sshRemote` at all, so this is the one path that needs - // it. Re-derive the login-agent pin for the endpoint (issue #427): same memoized `ssh -G` + // Resolved HERE for the same synchronous-spawnSession reason as projectOverrides. The relay + // host's detached SSH attach (audit A09) does not come through here, and does not need to: it + // takes its `sshRemote` from `SshProjectManager.sshRemoteFor`, whose `conn` the manager already + // annotated with this same probe when it connected (never from the wire), so this is the one + // path that still has to. Re-derive the login-agent pin for the endpoint (issue #427): same memoized `ssh -G` // probe the ControlMaster's connect uses, and it OVERWRITES any inbound value — `sshRemote` is // renderer-built and its conn can descend from a shareable project file, so only the local // probe may decide which agent socket the argv builders pin. The annotated conn is what the From ec203dbc183bd96bb66f95113dcec81e527bf7cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:43:13 +0000 Subject: [PATCH 048/284] fix(android): bound the OSC 52 selection field and a pairing refusal's text Review follow-ups for A53, A54 and A49: - OSC 52: only the payload after the ';' was capped, so a sequence with a multi-megabyte selection field and a tiny payload still crossed the WebView bridge whole. terminal.js and Osc52.parse now ignore a selection field longer than 16 characters (xterm's own are a few letters). - /pair: a non-2xx body was shown verbatim on the pairing screen. The endpoint is whatever the payload names, so its text is untrusted: control characters are dropped, whitespace collapsed, and it is cut at 300 characters. The desktop's own one-sentence refusals are unchanged. - SshFallbackTest: an ordering assertion passed when the expected text was missing (indexOf answers -1); it now asserts the text is present first. Tests: Osc52Test, TerminalJsOsc52Test (the real terminal.js under node), PairingClientBoundsTest; both new guards mutation-checked. Full protocol suite and the offline type-check pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../app/src/main/assets/terminal/terminal.js | 3 +++ .../dev/nodeterm/protocol/model/Osc52.kt | 9 ++++++- .../protocol/pairing/PairingClient.kt | 17 +++++++++++- .../kotlin/dev/nodeterm/protocol/Osc52Test.kt | 7 +++++ .../protocol/PairingClientBoundsTest.kt | 27 +++++++++++++++++++ .../dev/nodeterm/protocol/SshFallbackTest.kt | 4 ++- .../nodeterm/protocol/TerminalJsOsc52Test.kt | 9 +++++++ 7 files changed, 73 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/assets/terminal/terminal.js b/android/app/src/main/assets/terminal/terminal.js index 2d15f4bdd6..cf56d4c945 100644 --- a/android/app/src/main/assets/terminal/terminal.js +++ b/android/app/src/main/assets/terminal/terminal.js @@ -60,6 +60,9 @@ term.parser.registerOscHandler(52, function (data) { var idx = data.indexOf(';') if (idx < 0) return true + // The selection field before the ';' is a few letters ('c', 'p', 's0'…); a long one is not a + // clipboard write we understand, and it must not carry megabytes across the bridge either. + if (idx > 16) return true // = Osc52.MAX_SELECTION if (data.length - idx - 1 > copyLimit) bridge.onCopyTooLarge() else bridge.onCopy(data) return true diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt index 3ed3aa06b0..3182f0b829 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt @@ -26,6 +26,13 @@ import java.nio.charset.CodingErrorAction * [MAX_BASE64] over the bridge and applies it before the sequence crosses it. */ object Osc52 { + /** + * The longest selection field (the part before the `;`) accepted: xterm's own are `c`, `p`, + * `q`, `s` and `0`-`7`, possibly several together. A longer field is not a clipboard write we + * understand, and terminal.js drops it before the bridge so it cannot carry megabytes across. + */ + const val MAX_SELECTION = 16 + /** * The most text the phone hands the clipboard, in UTF-16 code units (a Kotlin `String`'s * length). At two bytes a unit the parcel stays around 200 KB: a fifth of the shared binder @@ -61,7 +68,7 @@ object Osc52 { fun parse(data: String): Result { val i = data.indexOf(';') - if (i < 0) return Result.Ignored + if (i < 0 || i > MAX_SELECTION) return Result.Ignored // Length first: xterm.js accepts OSC payloads up to 10,000,000 characters, and there is no // reason to copy one out of `data` only to refuse it. if (data.length - i - 1 > MAX_BASE64) return Result.TooLarge diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt index 6d119d5462..617dba1707 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt @@ -36,6 +36,20 @@ class PairingException(message: String) : Exception(message) { val detail = if (own.isNotEmpty()) "${e.javaClass.simpleName}: ${own.take(200)}" else e.javaClass.simpleName return "Pairing failed unexpectedly ($detail). Scan the code again." } + + /** The longest refusal text from the computer shown on the pairing screen. The desktop's + * own refusals are one sentence (under 200 characters); the endpoint is whatever the + * pairing payload names, so what it sends is untrusted and must not fill the screen. */ + const val MAX_REFUSAL_CHARS = 300 + + /** A non-2xx body as the pairing screen may show it: control characters dropped (a line + * break or an escape sequence has no business in a one-line error), whitespace collapsed, + * and cut at [MAX_REFUSAL_CHARS]. Empty when nothing printable is left. */ + fun refusalText(body: String): String { + val flat = body.map { if (it.isISOControl() || it == '\u2028' || it == '\u2029') ' ' else it } + .joinToString("").trim().replace(Regex("\\s+"), " ") + return if (flat.length <= MAX_REFUSAL_CHARS) flat else flat.take(MAX_REFUSAL_CHARS).trimEnd() + "…" + } } } @@ -122,8 +136,9 @@ class PairingClient( val (status, text) = exchange(payload.host, payload.pairPort, "/pair", body) if (status !in 200..299) { + val said = PairingException.refusalText(text) throw PairingException( - if (text.isNotBlank()) "The computer rejected pairing: ${text.trim()}" + if (said.isNotEmpty()) "The computer rejected pairing: $said" else "The computer rejected pairing (HTTP $status)." ) } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt index b89ac8103c..75873ac372 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt @@ -32,6 +32,13 @@ class Osc52Test { assertEquals(Result.Ignored, Osc52.parse("")) } + @Test + fun `a selection field longer than any xterm uses is not a clipboard write`() { + val sel = "c".repeat(Osc52.MAX_SELECTION) + assertEquals(Result.Copy("x"), Osc52.parse(sel + ";" + b64("x"))) + assertEquals(Result.Ignored, Osc52.parse(sel + "c;" + b64("x"))) + } + @Test fun `a read query and an empty payload copy nothing`() { assertEquals(Result.Ignored, Osc52.parse("c;?")) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt index 1600352ebd..f52aa7af67 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt @@ -232,6 +232,33 @@ class PairingClientBoundsTest { assertTrue(holder.clientClosed.await(3, TimeUnit.SECONDS), "the server saw the phone hang up") } + @Test + fun `a refusal body is shown as one bounded line`() { + // The desktop's own refusals are one plain sentence and reach the screen unchanged. + val desktop = "remote access is off on the computer. On Windows the phone connects only through remote access." + val plain = ScriptedServer { sock -> sock.send("HTTP/1.1 409 Conflict\r\nContent-Length: ${desktop.length}\r\n\r\n$desktop") } + assertEquals("The computer rejected pairing: $desktop", assertFailsWith { pair(plain.port) }.message) + + // Whatever else answers on the pairing port may send anything: line breaks, escape + // sequences, pages of text. The screen gets one line, cut at the cap. + val hostile = "line one\r\n\u001b[31mred\u001b[0m\tand " + "x".repeat(10_000) + val bytes = hostile.toByteArray(Charsets.UTF_8) + val noisy = ScriptedServer { sock -> + sock.send("HTTP/1.1 403 Forbidden\r\nContent-Length: ${bytes.size}\r\n\r\n") + sock.getOutputStream().write(bytes) + sock.getOutputStream().flush() + } + val msg = assertFailsWith { pair(noisy.port) }.message!! + assertTrue(msg.startsWith("The computer rejected pairing: line one [31mred [0m and xxx"), msg) + assertTrue(msg.none { it.isISOControl() }, "control characters reached the screen") + assertTrue(msg.endsWith("…"), msg) + assertEquals("The computer rejected pairing: ".length + PairingException.MAX_REFUSAL_CHARS + 1, msg.length) + + // Nothing printable left: the status code, not an empty quote. + val blank = ScriptedServer { sock -> sock.send("HTTP/1.1 403 Forbidden\r\nContent-Length: 3\r\n\r\n\r\n\u0007") } + assertEquals("The computer rejected pairing (HTTP 403).", assertFailsWith { pair(blank.port) }.message) + } + @Test fun `anything that is not a PairingException is framed as a sentence`() { val framed = PairingException.userMessage(NegativeArraySizeException("-1")) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt index 777da8a2da..f576e1e556 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt @@ -64,7 +64,9 @@ class SshFallbackTest { } // The bare exception names the likely causes before the alarming one. val fact = changed.message!! - assertTrue(fact.indexOf("network address") < fact.indexOf("intercepting"), fact) + val causes = fact.indexOf("network address") + assertTrue(causes >= 0, fact) // indexOf answers -1 for a missing text, which is "earlier" + assertTrue(causes < fact.indexOf("intercepting"), fact) } @Test diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt index c35b2a41c7..8c38d5dcf1 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt @@ -91,6 +91,15 @@ class TerminalJsOsc52Test { assertEquals(Outcome(true, emptyList()), results[2]) // no ';': nothing to parse } + @Test + fun `a long selection field never crosses the bridge, whatever the payload`() { + val atMax = "c".repeat(Osc52.MAX_SELECTION) + ";SGVsbG8=" + val tooLong = "c".repeat(2_000_000) + ";SGVsbG8=" + val (_, results) = run(copyLimit = Osc52.MAX_BASE64, listOf(atMax, tooLong)) + assertEquals(Outcome(true, listOf(copied(atMax))), results[0]) + assertEquals(Outcome(true, emptyList()), results[1]) + } + @Test fun `the payload tmux forwards under the desktop's cap never reaches onCopy`() { // 986,675 base64 characters: what the audit's verifier measured tmux 3.4 forwarding. From f0f39d58b426f260b5bbc7efe79af3ce6ca231b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:44:19 +0000 Subject: [PATCH 049/284] docs(android): batch A progress (A13 desktop, A72, A38, A39/A75, A48, A49/A74, A53, A54, A56, A58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handover: a progress-log table for the batch, and "What is still open" rewritten (A13 for direct-SSH phones, A56's hook-level design, A49 anchoring at pairing, A72 project overrides, the per-host seen log). Audit index: the batch's findings marked, A56 marked as deliberately not built, and the status note explains the 📝 marker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 27 ++++++++++++----------- docs/android-handover.md | 41 ++++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index 3ef15b94d7..10baf6de2f 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -6,7 +6,8 @@ Severity is the impact judge's rating, not the auditor's claim. **BLOCK** means Prioritised plan and handover: [`android-handover.md`](android-handover.md). -**Status of fixes.** A fixed finding is marked `✅ fixed in ` in the index; its section below +**Status of fixes.** A fixed finding is marked `✅ fixed in ` in the index, and a finding +deliberately not built is marked `📝` with the reason; its section below keeps the original audit text (line numbers still refer to `2f58918`). Where the fix departs from the audit's proposal, the handover's progress log says how and why. @@ -26,7 +27,7 @@ the audit's proposal, the handover's progress log says how and why. | [A10](#a10) | medium | BLOCK | small | ci-docs/risk | ✅ fixed in `fcda932` · Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | | [A11](#a11) | medium | | small | build/bug | ✅ fixed in `de1eded` · Tapping an Inbox notification while the app is in the background does not open that computer | | [A12](#a12) | medium | | medium | protocol/bug | ✅ fixed in `ab1335c` · Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | -| [A13](#a13) | medium | | medium | protocol/bug | ✅ phone side fixed in `68d0925` (desktop root cause open) · Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | +| [A13](#a13) | medium | | medium | protocol/bug | ✅ fixed in `68d0925` (phone), `b718a04` (desktop, relay phones) · Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | | [A14](#a14) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned | | [A15](#a15) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found' | | [A16](#a16) | medium | | small | protocol/gap | ✅ fixed in `c58ad65` · The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | @@ -51,8 +52,8 @@ the audit's proposal, the handover's progress log says how and why. | [A35](#a35) | medium | | small | critic/bug | ✅ fixed in `16706f4` · approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.' | | [A36](#a36) | medium | | small | critic/gap | ✅ fixed in `2a273a7` · After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself | | [A37](#a37) | low | | small | build/risk | proguard-rules.pro would not survive turning on minification (R8 missing-class errors) | -| [A38](#a38) | low | | small | protocol/bug | Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question | -| [A39](#a39) | low | | small | protocol/bug | The account chip reads `account.label`, which the mirror never writes, so it falls back to the raw account UUID | +| [A38](#a38) | low | | small | protocol/bug | ✅ fixed in `5ff8f6c` · Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question | +| [A39](#a39) | low | | small | protocol/bug | ✅ fixed in `437e359` · The account chip reads `account.label`, which the mirror never writes, so it falls back to the raw account UUID | | [A40](#a40) | low | | small | runtime/bug | The pending-launch path and the attach hand-off never re-check `disposed`: a stream can stay attached forever, or a phone-started node gets registered with no agent launched | | [A41](#a41) | low | | small | runtime/bug | The composed prompt is cleared even when no stream is attached, so the text is silently lost | | [A42](#a42) | low | | small | runtime/bug | NewSessionDialog crashes if the selected project disappears while the dialog is open | @@ -61,17 +62,17 @@ the audit's proposal, the handover's progress log says how and why. | [A45](#a45) | low | | small | runtime/risk | No onRenderProcessGone handler on the terminal WebView | | [A46](#a46) | low | | small | runtime/bug | The ⌨ key-row chip only focuses the DOM textarea, which cannot raise the soft keyboard | | [A47](#a47) | low | | small | runtime/bug | The Keystore decrypt runs on the main thread in the host list's composition, once per row per recomposition | -| [A48](#a48) | low | | small | runtime/bug | The seen-events set is trimmed in hash order and updated without synchronization, which can produce duplicate notifications | -| [A49](#a49) | low | | small | security/risk | SSH host-key TOFU pin is saved during key exchange (before auth) and is not tied to the pairing | +| [A48](#a48) | low | | small | runtime/bug | ✅ fixed in `d383e76` · The seen-events set is trimmed in hash order and updated without synchronization, which can produce duplicate notifications | +| [A49](#a49) | low | | small | security/risk | ✅ fixed in `a40d11b` · SSH host-key TOFU pin is saved during key exchange (before auth) and is not tied to the pairing | | [A50](#a50) | low | | medium | security/risk | The only distributable build is a debuggable APK, so Keystore-protected secrets can be pulled over adb/JDWP | | [A51](#a51) | low | | small | security/gap | allowBackup=false does not stop device-to-device migration at targetSdk 35: hosts, pins and deviceId are cloned | | [A52](#a52) | low | | small | security/gap | Approval and finish notifications put command text and the agent's last message on the lock screen | -| [A53](#a53) | low | | small | security/bug | OSC 52 handler has no size cap (the desktop caps at 1,000,000) and setPrimaryClip is unguarded | -| [A54](#a54) | low | | small | security/bug | PairingClient trusts an unbounded Content-Length / EOF body from the pairing endpoint | +| [A53](#a53) | low | | small | security/bug | ✅ fixed in `af587ac` · OSC 52 handler has no size cap (the desktop caps at 1,000,000) and setPrimaryClip is unguarded | +| [A54](#a54) | low | | small | security/bug | ✅ fixed in `32330df` · PairingClient trusts an unbounded Content-Length / EOF body from the pairing endpoint | | [A55](#a55) | low | | medium | parity/gap | Inbox and Usage are per computer; iOS merges them across all paired computers | -| [A56](#a56) | low | | small | parity/gap | Approval cards lack iOS's "Always allow" answer | +| [A56](#a56) | low | | small | parity/gap | 📝 not built, by decision (`38fa6c4`): a blind `2` can answer No or switch to auto mode on current Claude Code · Approval cards lack iOS's "Always allow" answer | | [A57](#a57) | low | | small | parity/gap | Multi-select AskUserQuestion cards fall back to "Open session" | -| [A58](#a58) | low | | small | parity/gap | Usage and feed cards omit iOS's pace line and the context indicator on event cards | +| [A58](#a58) | low | | small | parity/gap | ✅ fixed in `a44f16c` · Usage and feed cards omit iOS's pace line and the context indicator on event cards | | [A59](#a59) | low | | small | parity/gap | No built-in dictation (iOS has on-device Whisper plus a Cloud engine) | | [A60](#a60) | low | | small | ci-docs/bug | Interop fixture needs the Electron binary, which is downloaded at test time inside the 20 s ready window, despite the workflow saying 'not Electron' | | [A61](#a61) | low | | small | ci-docs/bug | Following CONTRIBUTING / android/README (`npm ci --ignore-scripts`) wipes a desktop developer's patched node_modules | @@ -85,10 +86,10 @@ the audit's proposal, the handover's progress log says how and why. | [A69](#a69) | low | | small | ci-docs/gap | The Gradle/Kotlin code has no dependency-update, CodeQL or wrapper-validation coverage | | [A70](#a70) | low | | small | ci-docs/bug | On Windows the interop tests fail with CreateProcess instead of skipping | | [A71](#a71) | low | | small | ci-docs/gap | android/README says 'JDK 17+', but the pinned Gradle 8.14.3 cannot run on JDK 25 | -| [A72](#a72) | low | | medium | critic/gap | Phone-started sessions are created without the agent-specific env, so for their whole life they get no hook-reply approvals, no canvas control and no pane ownership | +| [A72](#a72) | low | | medium | critic/gap | ✅ fixed in `71290de` · Phone-started sessions are created without the agent-specific env, so for their whole life they get no hook-reply approvals, no canvas control and no pane ownership | | [A73](#a73) | low | | small | critic/bug | Notifications are documented as 'live every 8 seconds while a computer is open', but the in-app poll never posts a notification | -| [A74](#a74) | low | | small | critic/bug | The LAN leg dials a DHCP IPv4 frozen at pairing time; when another SSH host answers at that address, the host-key 'hard stop' also blocks the relay fallback | -| [A75](#a75) | low | | small | critic/gap | The New session account picker lists managed Claude accounts by raw UUID | +| [A74](#a74) | low | | small | critic/bug | ✅ fixed in `a40d11b` · The LAN leg dials a DHCP IPv4 frozen at pairing time; when another SSH host answers at that address, the host-key 'hard stop' also blocks the relay fallback | +| [A75](#a75) | low | | small | critic/gap | ✅ fixed in `437e359` · The New session account picker lists managed Claude accounts by raw UUID | | [A76](#a76) | low | | small | critic/gap | Over direct SSH, opening a Sleeping (Eco-hibernated) session lands on a bare shell with no wake or resume offer | | [A77](#a77) | low | | small | critic/bug | IME insets are not handled for Android 15's enforced edge-to-edge (targetSdk 35): the terminal gets double bottom padding when the keyboard opens, and other screens have no IME padding at all | diff --git a/docs/android-handover.md b/docs/android-handover.md index 68f5fc2e47..3beeb0a6c0 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -25,6 +25,21 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr Newest first. Each entry says what landed, how it was checked, and where the fix differs from the audit's proposal. +### WP4 remainder, WP5/WP6 batch A (desktop root causes, protocol-testable items): done on the branch, not device-verified + +| Finding | Commit | What changed | Checked by | +|---|---|---|---| +| A13 (desktop) | `b718a04` | The app's own tmux client attaches with `-A` and no `-D` while a relay-served client of the same node is live in this process (found by walking the session table at spawn time: local tmux, sink attached, not over SSH, not the session host). With no phone attached the argv is unchanged. The remote (SSH-project) attach never used `-D`; now pinned by a test. | vitest `pty-relay-coattach.test.ts` (argv, both orders, remote) and `relay-coattach.realtmux.test.ts` (real tmux in the sandbox: `-A -D` kicks a control-mode client with exit 0, `-A` keeps both). Mutation-checked. | +| A72 | `71290de` | A phone-started session (A33's `projectId`/`agentId` on `pty.attach`) now gets the agent-gated hook env (`NODETERM_AGENT_ID`, the approval wait, canvas control by `canControlCanvas`) and a proven pane owner, resolved on the host from the index entry the project id matched. Resolver moved to `host-new-sessions.ts`. | vitest `host-new-sessions.test.ts`, `pty-relay-create.test.ts`, `remote-security.test.ts`. | +| A39 / A75 | `437e359`, `ea19490` | Additive `label`/`email` on the mirror's `settings.claudeAccounts`, written by one shared builder in both shells and the SSH slice. One protocol helper names accounts: settings label, usage label, email, then `Account ` — never the full UUID; an unlinked config dir shows its last path segment. Used by the picker, the sessions row and the usage card. | Protocol `AccountNamesTest`; vitest mirror test; the interop fixture's settings entry is built by the real desktop builder. | +| A38 | `5ff8f6c` | A ticketed approval is answered while the node is WAITING on a held parent question (the desktop's "gone" still guards an expired hold). The keyed paths keep their exact-state gates. | Protocol QuickActions tests. | +| A56 | `38fa6c4`, `45e21ec` | **Not built, by decision.** A static read of Claude Code 2.1.283 shows option 2 of the permission prompt is conditional: when there is no "don't ask again" row, `2` is "Yes, and switch to auto mode" or "No". A blind `2` can therefore deny or widen permissions. Documented in `docs/android.md` (Known gaps) and `docs/hook-reply-approvals.md`, including that **the iOS "Always allow" has the same hazard** (for @eneskirca). The safe route is a hook-level allow with `updatedPermissions`, which needs a cross-surface design. | Docs only. | +| A58 | `a44f16c`, `144bd40` | Usage pace line ("5h usage pace slower/faster", "on pace" within ±5 points), computed at the usage snapshot's own time, refusing stale or unknown windows; a context ring on approval, question and done cards. | Protocol `UsagePaceTest` (15 cases). | +| A48 | `d383e76` | The seen log is id → time, pruned by age past the 6 h announce window (+18 h margin), claimed and marked under one lock; the old string set migrates as "seen now". | Protocol `SeenLogTest`. | +| A53 | `af587ac`, `ec203db` | OSC 52: the `;` is required, a 16-character selection field at most, 100,000 characters of text (400,000 base64) at most, strict base64 and UTF-8; terminal.js applies the cap before the bridge; a failed or too-large clipboard write shows a toast instead of crashing. | Protocol `Osc52Test`; `TerminalJsOsc52Test` runs the real terminal.js under node. | +| A54 | `32330df`, `ec203db` | `/pair`: a negative, non-numeric or over-64 KiB Content-Length is refused, a length-less body stops at 64 KiB, a 45 s watchdog closes the socket (also on cancellation), and a refusal body is shown as one line of at most 300 characters. | Protocol `PairingClientBoundsTest`; an interop test measures the real answer (606 bytes with the fixture's short token). | +| A49 / A74 | `a40d11b` | The SSH host-key pin is persisted only after public-key auth succeeds. In Auto, a changed host key refuses SSH, shows a warning, and falls through to the relay leg; SSH_ONLY keeps the hard stop. The message points at Settings → "Only through the relay". | SSH transport test (a failed auth does not pin); protocol `SshFallbackTest`. | + ### WP4 (medium bugs): done on the branch, not device-verified | Finding | Commit | What changed | Checked by | @@ -44,11 +59,19 @@ audit's proposal. ### What is still open -- **A13, desktop root cause.** When the renderer mounts a node whose session a relay-served client - holds, pty-manager attaches with `-A -D` (relay ptys are not indexed by persistKey), detaching the - phone. The phone now reattaches, but the right fix is on the desktop (attach without `-D` when a - detached relay session for that persistKey is live). It touches pty-manager's attach flags, so it - needs its own careful change and tests; iOS benefits too. +- **A13 for direct-SSH phones.** The desktop fix covers phones attached through the relay. A phone + attached over direct SSH (Android or iOS) is still detached by the app's `-D`, because its tmux + client is not spawned by this process; the Android exit-0 reattach (`68d0925`) covers it. +- **A56 "Always allow".** Needs a layout-independent answer: the hook replies `allow` with + `updatedPermissions`, carried by the answer file and `approvals.answer`. That spans the desktop, + the `~/.nodeterm/pending` contract, iOS and Android, so it needs a design decision. The iOS app's + blind `2` should be re-checked by @eneskirca. +- **A49 anchoring.** The SSH pin is still trust-on-first-use. Anchoring it needs the desktop to + return its SSH host key fingerprints in the sealed `/pair` answer (desktop + iOS + fixture), and + refreshing a stale LAN address needs the desktop to publish its current one. +- **A72 project overrides.** A phone-started session gets the agent env and a proven owner, but not + the project's `.nodeterm/settings.json` env/shell overrides (that read is async and may raise a + trust dialog). - **A12 for SSH-project nodes.** `node.sendKeys` answers `sent:false` for them (background writes do not reach a remote host), so the phone opens the session instead of answering in one tap. - **A33 on an older desktop.** An older desktop ignores the new attach fields, so a Windows host @@ -56,10 +79,12 @@ audit's proposal. - **A07 edges.** Late relay adoption does not pin; revoking a device unpins but does not cut a relay session open at that moment. - **A10 trade-off.** The debug key is public by the user's decision; a release key does not exist. +- **Seen log per host.** The notification seen log is phone-global; two computers could in theory + mint the same event id in the same millisecond. - **Server-e2e and native-module vitest suites** could not run in this sandbox (`npm ci - --ignore-scripts` skips the native builds); the same failures occur on the pre-session commit. - Desktop CI does not run on branch pushes here, so the desktop changes are checked by targeted - vitest files + `npm run typecheck` only. + --ignore-scripts` skips the native builds, and there is no `ssh` client); the same 14 tests and 31 + files fail identically on the pre-session commit. Desktop CI does not run on branch pushes here, + so the desktop changes are checked by targeted vitest files + `npm run typecheck` only. ### WP2 (blockers with decisions): done on the branch, not device-verified From a38d39ddd5fdae00d0abd3bf8c9963884e47bb96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:56:17 +0000 Subject: [PATCH 050/284] fix(android): re-check the screen at the attach hand-off, and finish a phone launch after it leaves (A40) The terminal screen handed a new stream to the main thread by a posted runnable after checking `disposed`/`stopped` once on the background thread. A screen that left in between got the stream installed anyway, and nothing ever detached it. A session the phone had just started ran its launch (900 ms settle, launch line, `projects.registerNode`) inside the attach job with no check at all: leaving the screen detached the stream first, so the line was dropped while the node was still registered with a bare shell; going to the background cancelled the job, so the launch was dropped entirely. The request had already been consumed (`PendingLaunches.take`), so nothing could retry it. The rules now live in android/protocol (`host/TerminalHandoff.kt`), where they are JVM-tested: - `StreamLease`: a stream with holders (the screen, and a launch); the last release detaches it, exactly once. - `ViewerSlot`: every attach takes a ticket; leaving (ON_STOP), closing (dispose) and a newer attach invalidate older tickets, and the hand-off installs a stream only for the current ticket. Any other hand-off releases the screen's hold at once, so the stream is detached instead of shown. - `PhoneLaunch.start`: takes its own hold BEFORE the stream is handed to the screen, runs in the app scope rather than the attach job, types the line after the settle delay, registers, then releases. TerminalController uses them: one sink per attach, gated by its ticket, so a superseded stream, or one only a launch still holds, neither paints into the terminal nor reports its exit as this screen's. The failure/NeedsRelay posts of a stale attach are ignored, and an attach's `finally` clears `attachJob` only if it is still its own. The A13 exit-0 reattach, A36 auto-reattach and A08/A09 relay offer are unchanged (the exit of the current stream still drives them). Departures from the audit's proposal: - (b) Of the verifier's two options, the launch finishes regardless of the viewer instead of ending the session on abort: the user asked for a session, leaving the screen is not cancelling it, and `endSession` would destroy exactly what they asked for. - (a) Instead of @Volatile `disposed`, the background thread no longer reads `disposed`/`stopped` at all: the main-thread hand-off decides by ticket, which also covers a superseded attach (ON_STOP then ON_START before the first attach landed installed the old stream, and the new one replaced it without a detach). dispose() still does not cancel the attach job: an attach that lands afterwards is let go of by the hand-off, and a session it created still gets its launch. - The pending launch is taken only when the attach sent the create hint, i.e. only for the session that attach asked the host to create. - A launch whose write throws still registers (neither transport throws from write today): a visible node with a bare shell is better than an invisible one. Verified: `TerminalHandoffTest` (13 tests), mutation-checked: without the launch's own hold 6 fail, a hand-off that ignores the ticket fails 4, `ended`/`leave` that keep the ticket fail 1 each. Full protocol suite (`gradle test --offline`, 160 tests) and the offline app type-check pass. The controller itself is type-checked only; nothing was run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/android/ui/TerminalController.kt | 104 ++++--- .../nodeterm/protocol/host/TerminalHandoff.kt | 177 ++++++++++++ .../nodeterm/protocol/TerminalHandoffTest.kt | 253 ++++++++++++++++++ docs/android.md | 8 + 4 files changed, 504 insertions(+), 38 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalHandoff.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 4b57e804a6..9f222a07e9 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -29,8 +29,11 @@ import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.NewNode import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.PhoneLaunch +import dev.nodeterm.protocol.host.StreamLease import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.ViewerSlot import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind @@ -80,7 +83,12 @@ class TerminalController( private var webView: WebView? = null private var pageReady = false private val pendingJs = ArrayList() - private var stream: TerminalStream? = null + /** + * The attach hand-off (audit A40): which attach may still install its stream, and the stream this + * screen shows. Thread-safe; [stream] is also read on the WebView's bridge thread. + */ + private val slot = ViewerSlot() + private val stream: TerminalStream? get() = slot.stream private var attachJob: Job? = null private var cols = 0 private var rows = 0 @@ -101,12 +109,18 @@ class TerminalController( } } - private val sink = object : TerminalSink { + /** + * One sink per attach, tied to its [ViewerSlot] ticket: a stream this screen no longer shows (a + * superseded attach, or one a launch still holds after the screen left) must not paint into it, + * and its exit is not this screen's to report. + */ + private fun sinkFor(ticket: Long) = object : TerminalSink { override fun onPaint(text: String) { - main.post { js("nt.paint('${b64(text.toByteArray(Charsets.UTF_8))}')") } + main.post { if (slot.isCurrent(ticket)) js("nt.paint('${b64(text.toByteArray(Charsets.UTF_8))}')") } } override fun onOutput(bytes: ByteArray) { + if (!slot.isCurrent(ticket)) return synchronized(outBuf) { outBuf.write(bytes) if (!flushScheduled) { @@ -117,14 +131,17 @@ class TerminalController( } override fun onResized(cols: Int, rows: Int) { - main.post { sizedElsewhere = if (cols != this@TerminalController.cols || rows != this@TerminalController.rows) cols to rows else null } + main.post { + if (!slot.isCurrent(ticket)) return@post + sizedElsewhere = if (cols != this@TerminalController.cols || rows != this@TerminalController.rows) cols to rows else null + } } override fun onExit(code: Int?) { main.post { - // Our own detach on ON_STOP ends the stream too; that is not a drop to recover from. - if (stopped) return@post - stream = null + // Not the stream this screen shows: our own detach on ON_STOP or on leaving (it ends the + // stream too), or a stream a superseded attach or a launch held. Not a drop to recover from. + if (!slot.ended(ticket)) return@post // exit 0 with the session still running = another client attached with -D and // detached us — audit A13. A current desktop no longer does this to a relay-attached // phone, but an older desktop does, and any desktop still does it to a phone attached @@ -305,7 +322,10 @@ class TerminalController( if (disposed || stopped) return state = TermState.Connecting resumeOffer = null + val ticket = slot.begin() + val sink = sinkFor(ticket) attachJob = graph.scope.launch { + val job = coroutineContext[Job] try { val conn = if (useRelay) { session.viaRelay { st -> @@ -320,50 +340,58 @@ class TerminalController( // the chosen account (audit A33). val hint = PendingLaunches.peek(nodeId)?.let { NewSessionHint(it.projectId, it.accountId, it.agentId) } val s = conn.attach(nodeId, c, r, sink, hint) - if (disposed || stopped) { - s.detach() - return@launch - } + val lease = StreamLease(s) { st -> graph.scope.launch { runCatching { st.detach() } } } + // The host created this session for the launch just now, and the request is consumed + // here: the launch holds the stream until it is done, whatever this screen does next + // (audit A40). Started BEFORE the hand-off, so a screen that already left cannot + // detach the stream under it. + val launch = if (hint != null) PendingLaunches.take(nodeId) else null + if (launch != null) startLaunch(launch, lease, conn) + // The hand-off re-checks the screen: it may have left, or started a newer attach, + // since this one began. Then the stream is let go of instead of installed (A40). main.post { - stream = s + if (!slot.accept(ticket, lease)) return@post attachedAt = System.currentTimeMillis() state = TermState.Attached if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) } - afterAttach(s, conn) + if (launch == null && slot.isCurrent(ticket)) afterAttach(s, conn) } catch (e: NeedsRelayException) { val msg = e.message ?: "This session opens through the relay." main.post { + if (!slot.isCurrent(ticket)) return@post state = if (session.hasRelay) TermState.RelayOffer(msg) else TermState.Ended("$msg Remote access isn't set up for this computer, so open it in nodeterm on the computer.") } } catch (e: Exception) { - main.post { state = TermState.Ended(e.message ?: "Couldn't open the terminal.") } + // Includes our own cancel on ON_STOP: the ticket is stale by then, so nothing is shown. + main.post { if (slot.isCurrent(ticket)) state = TermState.Ended(e.message ?: "Couldn't open the terminal.") } } finally { - main.post { attachJob = null } + // Only this attach's own reference: a newer attach may have replaced it meanwhile. + main.post { if (attachJob === job) attachJob = null } } } } - private suspend fun afterAttach(s: TerminalStream, conn: HostConnection) { - val launch = PendingLaunches.take(nodeId) - if (launch != null) { - // A session this phone just started: type its launch line once the shell has settled - // (a line delivered across the rc-file tty flush comes out mangled), THEN put it on the - // canvas — registering first would let the desktop mount it cold and launch the agent too. - delay(900) - launch.command?.let { s.write(it + "\r") } - if (conn.capabilities.registerNode) { - // A refusal is an ANSWER (host-service: the session stays open, just unregistered): - // say so, instead of leaving a session no canvas shows (audit A14). - val registered = runCatching { - conn.registerNode(launch.projectId, NewNode(nodeId, launch.title, launch.agentId, launch.accountId)) - }.getOrDefault(false) - if (!registered) main.post { notice = UNREGISTERED_NOTICE } - } + /** + * A session this phone just started: type its launch line once the shell has settled, THEN put + * it on the canvas (registering first would let the desktop mount it cold and launch the agent + * too). Runs in the app scope, not in [attachJob], so neither ON_STOP nor leaving the screen + * drops it half-done (audit A40). + */ + private fun startLaunch(launch: LaunchRequest, lease: StreamLease, conn: HostConnection) { + val register: (suspend () -> Boolean)? = if (conn.capabilities.registerNode) { + { conn.registerNode(launch.projectId, NewNode(nodeId, launch.title, launch.agentId, launch.accountId)) } + } else null + PhoneLaunch.start(graph.scope, lease, launch.command, register) { outcome -> + // A refusal is an ANSWER (host-service: the session stays open, just unregistered): say + // so, instead of leaving a session no canvas shows (audit A14). + if (outcome == PhoneLaunch.Outcome.REFUSED) main.post { notice = UNREGISTERED_NOTICE } session.refreshNow() - return } + } + + private suspend fun afterAttach(s: TerminalStream, conn: HostConnection) { val snap = session.snapshot.value val node = snap.findNode(nodeId)?.second val status = snap.statusOf(nodeId) @@ -446,9 +474,8 @@ class TerminalController( stopped = true attachJob?.cancel() attachJob = null - val s = stream - stream = null - if (s != null) graph.scope.launch { runCatching { s.detach() } } + // Detaches the stream, unless a launch still holds it: then once that launch is done (A40). + slot.leave() webView?.onPause() state = TermState.Connecting } @@ -463,9 +490,10 @@ class TerminalController( fun dispose() { disposed = true - val s = stream - stream = null - if (s != null) graph.scope.launch { runCatching { s.detach() } } + // Detaches the stream (after a launch that still holds it, A40), and refuses every attach + // still on its way. attachJob is deliberately not cancelled: an attach that lands now is + // let go of by the hand-off, and a session it created still gets its launch. + slot.close() main.removeCallbacks(flush) webView?.let { it.removeJavascriptInterface("NodetermBridge") diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalHandoff.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalHandoff.kt new file mode 100644 index 0000000000..adc5281b6c --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalHandoff.kt @@ -0,0 +1,177 @@ +package dev.nodeterm.protocol.host + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A terminal stream with more than one reason to stay attached (audit A40). + * + * The screen showing the stream is one holder (every lease starts with that hold). A session the + * phone is starting is another, from the moment its attach returns until the launch line is typed + * and the node is registered ([PhoneLaunch]). The stream is detached when the LAST holder lets go, + * exactly once, on whichever thread that happens. + */ +class StreamLease(val stream: TerminalStream, private val detach: (TerminalStream) -> Unit) { + private var holds = 1 + + /** Take another hold. False once the stream has been let go of (it is detached, or ended). */ + @Synchronized + fun acquire(): Boolean { + if (holds == 0) return false + holds++ + return true + } + + /** Let go of one hold. The last one detaches the stream; a release after that does nothing. */ + fun release() { + val last = synchronized(this) { + if (holds == 0) return + holds-- + holds == 0 + } + if (last) detach(stream) + } + + /** The stream ended by itself (the pane exited, or the connection went): nothing is left to detach. */ + @Synchronized + fun ended() { + holds = 0 + } +} + +/** + * The screen's side of the attach hand-off (audit A40). + * + * An attach runs off the main thread and hands its stream over afterwards, by a posted runnable. By + * the time that runs, the screen may have gone to the background, gone for good, or started a newer + * attach. So every attach takes a ticket ([begin]); [leave] and [close] invalidate every ticket + * issued so far, and [accept] installs a lease only for the CURRENT ticket. Any other hand-off lets + * go of the screen's hold at once, so a stream nobody shows is never left attached. The same ticket + * tells a superseded attach's output and exit apart from the current one's ([isCurrent], [ended]). + * + * Every method is thread-safe; the lease's detach runs outside the lock. + */ +class ViewerSlot { + private var ticket = 0L + private var closed = false + private var lease: StreamLease? = null + + /** The stream the screen is showing, or null. */ + val stream: TerminalStream? + @Synchronized get() = lease?.stream + + /** A new attach starts. Returns its ticket. A stream still shown is let go of. */ + fun begin(): Long { + val t: Long + val old: StreamLease? + synchronized(this) { + ticket++ + t = ticket + old = lease + lease = null + } + old?.release() + return t + } + + /** True while [t] is the latest attach and the screen has not left since it began. */ + @Synchronized + fun isCurrent(t: Long): Boolean = !closed && t == ticket + + /** + * The attach [t] hands over [lease]. Installed only while [t] is current; otherwise the screen's + * hold is released here, which detaches the stream unless someone else (a launch) still holds it. + */ + fun accept(t: Long, lease: StreamLease): Boolean { + val ok = synchronized(this) { + val current = !closed && t == ticket && this.lease == null + if (current) this.lease = lease + current + } + if (!ok) lease.release() + return ok + } + + /** + * The stream of attach [t] ended by itself. True when that was the screen's current stream: it is + * forgotten (there is nothing to detach) and a hand-off of [t] that has not landed yet will not + * install it. False for a stream the screen had already let go of. + */ + fun ended(t: Long): Boolean { + synchronized(this) { + if (closed || t != ticket) return false + ticket++ + lease?.ended() + lease = null + } + return true + } + + /** The screen went to the background: let go of the stream and of every hand-off still in flight. */ + fun leave() { + val old = synchronized(this) { + ticket++ + lease.also { lease = null } + } + old?.release() + } + + /** The screen is gone for good: [leave], and refuse every hand-off from now on. */ + fun close() { + synchronized(this) { closed = true } + leave() + } +} + +/** + * The second half of starting a session from the phone (audits A33, A14, A40). The attach created the + * tmux session in its project; this types the agent's launch line once the shell has settled (a line + * delivered across the rc-file tty flush comes out mangled), THEN puts the node on the canvas. + * Registering first would let the desktop mount it cold and launch the agent too. + * + * The request was consumed to get here, so nothing can retry it, and the launch finishes whatever the + * screen does meanwhile. Leaving the screen is not cancelling the session the user asked for. The + * other option, aborting and leaving the `nt-` session the attach created, is an unregistered + * shell that no canvas shows. So [start] takes its own hold on the lease BEFORE the caller hands the + * stream to the screen, and runs in the caller's long-lived scope, not in the screen's attach job + * (which the screen cancels when it goes to the background). + */ +object PhoneLaunch { + /** How long the new shell gets to settle before its launch line is typed. */ + const val SETTLE_MS = 900L + + /** [CANNOT_REGISTER]: the connection has no `projects.registerNode` (nothing was asked). */ + enum class Outcome { REGISTERED, REFUSED, CANNOT_REGISTER } + + /** + * Start the launch on [lease]'s stream. Call it before the lease is handed to anyone else: it + * throws if the stream has already been let go of. [then] runs after the hold is released. + */ + fun start( + scope: CoroutineScope, + lease: StreamLease, + command: String?, + register: (suspend () -> Boolean)?, + settle: suspend (Long) -> Unit = { delay(it) }, + then: suspend (Outcome) -> Unit = {} + ): Job { + check(lease.acquire()) { "The stream was let go of before its launch started." } + return scope.launch { + val outcome = try { + settle(SETTLE_MS) + if (command != null) runCatching { lease.stream.write(command + "\r") } + when { + register == null -> Outcome.CANNOT_REGISTER + // A refusal is an ANSWER (host-service: the session stays open, just unregistered). + runCatching { register() }.getOrDefault(false) -> Outcome.REGISTERED + else -> Outcome.REFUSED + } + } finally { + lease.release() + } + then(outcome) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt new file mode 100644 index 0000000000..fc5fa31d17 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt @@ -0,0 +1,253 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.PhoneLaunch +import dev.nodeterm.protocol.host.StreamLease +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.ViewerSlot +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Audit A40: the terminal screen's attach hand-off, and the launch of a session the phone starts. + * The screen (TerminalController) is only type-checked; these pin the rules it delegates to. + */ +class TerminalHandoffTest { + private val log: MutableList = Collections.synchronizedList(ArrayList()) + + private inner class FakeStream(val name: String = "s") : TerminalStream { + override val fresh = true + override fun write(text: String) { + log += "$name write ${text.replace("\r", "\\r")}" + } + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) {} + override suspend fun detach() {} + override suspend fun endSession() { + log += "$name end" + } + } + + private fun lease(name: String = "s") = StreamLease(FakeStream(name)) { log += "$name detach" } + + // ---- (a) the hand-off re-checks the screen ---------------------------------------------- + + @Test + fun `a stream handed over after the screen was closed is detached, not installed`() { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + slot.close() // back pressed while the attach was on its way + assertFalse(slot.accept(t, l)) + assertNull(slot.stream) + assertEquals(listOf("s detach"), log) + } + + @Test + fun `a stream handed over after the screen went to the background is detached, not installed`() { + val slot = ViewerSlot() + val t = slot.begin() + slot.leave() + assertFalse(slot.accept(t, lease())) + assertEquals(listOf("s detach"), log) + } + + @Test + fun `a superseded attach cannot install its stream over the newer one`() { + val slot = ViewerSlot() + val old = slot.begin() + slot.leave() // background ... + val new = slot.begin() // ... and back: a second attach starts before the first one lands + val newer = lease("new") + assertTrue(slot.accept(new, newer)) + assertFalse(slot.isCurrent(old)) + assertFalse(slot.accept(old, lease("old"))) + assertSame(newer.stream, slot.stream) + assertEquals(listOf("old detach"), log) + } + + @Test + fun `the current hand-off installs, and leaving detaches it exactly once`() { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + assertTrue(slot.accept(t, l)) + assertSame(l.stream, slot.stream) + assertTrue(log.isEmpty()) + slot.leave() + slot.close() + l.release() + assertNull(slot.stream) + assertEquals(listOf("s detach"), log) + } + + @Test + fun `a stream that ended by itself is forgotten without a detach, and a late hand-off of it is refused`() { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + assertTrue(slot.accept(t, l)) + assertTrue(slot.ended(t)) + assertNull(slot.stream) + // The exit of a stream the screen already let go of (its own detach) is not the screen's. + assertFalse(slot.ended(t)) + slot.leave() + assertTrue(log.isEmpty()) + + // An exit that lands before its own hand-off: the dead stream must not be shown as attached. + val t2 = slot.begin() + assertTrue(slot.ended(t2)) + assertFalse(slot.accept(t2, lease("late"))) + assertNull(slot.stream) + } + + @Test + fun `the last of several holders detaches exactly once, whatever the threads`() { + repeat(50) { + val detaches = AtomicInteger() + val l = StreamLease(FakeStream()) { detaches.incrementAndGet() } + repeat(7) { assertTrue(l.acquire()) } + val go = CountDownLatch(1) + val threads = (0 until 8).map { + thread { + go.await() + l.release() + l.release() + } + } + go.countDown() + threads.forEach { it.join() } + assertEquals(1, detaches.get()) + assertFalse(l.acquire()) + } + } + + // ---- (b) a launch outlives the screen --------------------------------------------------- + + @Test + fun `leaving during the settle delay does not drop the launch, and the stream is detached after it`() = runBlocking { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + val settled = CompletableDeferred() + var outcome: PhoneLaunch.Outcome? = null + val job = PhoneLaunch.start(this, l, "claude --permission-mode auto", + register = { log += "register"; true }, + settle = { settled.await() }, + then = { outcome = it; log += "then" }) + assertTrue(slot.accept(t, l)) + yield() // the launch is now waiting for the shell to settle + + slot.close() // the user pressed back + assertTrue(log.isEmpty(), "the stream must stay attached until the launch is done: $log") + + settled.complete(Unit) + job.join() + assertEquals(listOf("s write claude --permission-mode auto\\r", "register", "s detach", "then"), log) + assertEquals(PhoneLaunch.Outcome.REGISTERED, outcome) + } + + @Test + fun `the app going to the background mid-launch does not drop it either`() = runBlocking { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + val settled = CompletableDeferred() + val job = PhoneLaunch.start(this, l, "bash", register = { log += "register"; true }, settle = { settled.await() }) + assertTrue(slot.accept(t, l)) + yield() + slot.leave() + // Back in the foreground: a new attach of the same session; the launch still holds the old stream. + val t2 = slot.begin() + assertTrue(slot.accept(t2, lease("new"))) + settled.complete(Unit) + job.join() + assertEquals(listOf("s write bash\\r", "register", "s detach"), log) + assertTrue(slot.isCurrent(t2)) + } + + @Test + fun `a launch whose hand-off was refused still runs, then detaches`() = runBlocking { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + val settled = CompletableDeferred() + // The attach thread starts the launch, then posts the hand-off; the screen closed in between. + val job = PhoneLaunch.start(this, l, "claude", register = { log += "register"; true }, settle = { settled.await() }) + slot.close() + assertFalse(slot.accept(t, l)) + assertTrue(log.isEmpty()) + settled.complete(Unit) + job.join() + assertEquals(listOf("s write claude\\r", "register", "s detach"), log) + } + + @Test + fun `a launch on a stream the screen still shows leaves it attached`() = runBlocking { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + val job = PhoneLaunch.start(this, l, "claude", register = { log += "register"; true }, settle = {}) + assertTrue(slot.accept(t, l)) + job.join() + assertEquals(listOf("s write claude\\r", "register"), log) + assertSame(l.stream, slot.stream) + slot.close() + assertEquals(listOf("s write claude\\r", "register", "s detach"), log) + } + + @Test + fun `a refusal and a failed registration are both REFUSED, a connection without the verb is CANNOT_REGISTER`() = runBlocking { + suspend fun run(register: (suspend () -> Boolean)?): PhoneLaunch.Outcome? { + var outcome: PhoneLaunch.Outcome? = null + val l = lease() + val job = PhoneLaunch.start(this, l, null, register, settle = {}, then = { outcome = it }) + l.release() // the screen left + job.join() + return outcome + } + assertEquals(PhoneLaunch.Outcome.REFUSED, run { false }) + assertEquals(PhoneLaunch.Outcome.REFUSED, run { throw IllegalStateException("host gone") }) + assertEquals(PhoneLaunch.Outcome.CANNOT_REGISTER, run(null)) + // No command (a plain shell): nothing typed, the stream still let go of each time. + assertEquals(listOf("s detach", "s detach", "s detach"), log) + } + + @Test + fun `a write that throws does not keep the stream attached or skip the registration`() = runBlocking { + val l = StreamLease(object : TerminalStream { + override val fresh = true + override fun write(text: String) = throw IllegalStateException("socket closed") + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) {} + override suspend fun detach() {} + override suspend fun endSession() {} + }) { log += "detach" } + var outcome: PhoneLaunch.Outcome? = null + val job = PhoneLaunch.start(this, l, "claude", register = { log += "register"; true }, settle = {}, then = { outcome = it }) + l.release() // the screen left + job.join() + assertEquals(listOf("register", "detach"), log) + assertEquals(PhoneLaunch.Outcome.REGISTERED, outcome) + } + + @Test + fun `a launch cannot start on a stream that was already let go of`() { + val l = lease() + l.release() + assertFailsWith { + runBlocking { PhoneLaunch.start(this, l, "claude", register = null, settle = {}) } + } + } +} diff --git a/docs/android.md b/docs/android.md index 0883e059e6..56be652989 100644 --- a/docs/android.md +++ b/docs/android.md @@ -149,6 +149,14 @@ leg (`A74`): a changed key goes on to the relay in Auto with a warning, stops on and its text names "Only through the relay" rather than only re-pairing. The app's use of it (the relay dial behind `RelayApprovalGate`, the warning on the host screen) is only type-checked. +`TerminalHandoffTest` pins the terminal screen's attach hand-off (`A40`). A stream that arrives +after the screen left (back, or the app going to the background), or from an attach a newer one +replaced, is detached instead of shown. A session the phone starts keeps its stream until its launch +line is typed and the node is registered, even when the user leaves during the settle delay: the +request was consumed to get there, so a launch dropped half-way could not be retried, and would +leave an unregistered shell no canvas shows. The terminal screen's use of these rules is only +type-checked. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 94a558af41d427d77a337434d780ba06c658f049 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:01:14 +0000 Subject: [PATCH 051/284] fix(android): keep the input bar's draft when no terminal is attached (A41) Send cleared the composed prompt unconditionally. With no stream attached (connecting, "Disconnected.", an ended session) the text went to JS, then to Bridge.onInput and a null stream, so it was dropped and the draft was gone. The key-row chips, ^C/^D/^R/^L and the Resume offer had the same silent drop: they looked sent and reached nothing, and Resume also spent the offer. - protocol: InputBar.plan decides what Send does from (attached, Ctrl armed, text, enter): NotAttached, a lone control byte (A34), or a paste plus Enter. NotAttached leaves Ctrl armed. - TerminalController: `attached` gates on state == TermState.Attached, not on reading `stream` (per the verifier: both change in one main-thread post, and all callers are on the main thread). submit() follows the plan and returns whether it was delivered; key(), raw() and acceptResume() do nothing while not attached, and acceptResume keeps the offer. - TerminalScreen: the draft is cleared only when submit() returns true. The Send icon, the Resume button and the sending key chips are disabled while not attached. The keyboard's Send keeps the text in place; the terminal overlay already says why (connecting / disconnected). The Ctrl and keyboard chips stay enabled (they send nothing themselves), and a draft can still be typed while reconnecting. Verified: InputBarTest (5 tests) passes; with the `!attached` gate removed from InputBar.plan, 2 of them fail (mutation check done). Full protocol suite (gradle test --offline, 165 tests) and the offline app type-check (gradle compileKotlin --offline) pass. The screen wiring is only type-checked; nothing has run on a device. Departures from the audit: the decision moved into a pure protocol function so it is testable, and acceptResume/Resume were gated too (same silent drop, not named in the finding). No notice was added for the keyboard's Send; the kept draft plus the existing state overlay say it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/android/ui/TerminalController.kt | 50 ++++++++++----- .../dev/nodeterm/android/ui/TerminalScreen.kt | 61 ++++++++++--------- .../dev/nodeterm/protocol/model/InputBar.kt | 34 +++++++++++ .../dev/nodeterm/protocol/InputBarTest.kt | 42 +++++++++++++ docs/android.md | 7 +++ 5 files changed, 151 insertions(+), 43 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InputBar.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/InputBarTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 9f222a07e9..ee3e36c689 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -37,6 +37,7 @@ import dev.nodeterm.protocol.host.ViewerSlot import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.InputBar import dev.nodeterm.protocol.model.Keys import dev.nodeterm.protocol.model.Launch import dev.nodeterm.protocol.model.Osc52 @@ -68,6 +69,14 @@ class TerminalController( ) { var state by mutableStateOf(TermState.Connecting) private set + + /** + * Input can reach the pane. Read [state], not [stream]: the two change together in one main-thread + * post, and every caller of this is on the main thread (A41). Anything sent while it is false + * reached a null stream and was dropped silently. + */ + val attached: Boolean get() = state == TermState.Attached + /** Offered after a COLD attach of an agent node: its resume line (the desktop's cold restore). */ var resumeOffer by mutableStateOf?>(null) private set @@ -421,6 +430,8 @@ class TerminalController( fun acceptResume() { val offer = resumeOffer ?: return + // Not attached (the stream dropped under the offer): keep it rather than spend it on nothing. + if (!attached) return resumeOffer = null stream?.write(offer.second + "\r") } @@ -436,27 +447,38 @@ class TerminalController( } /** - * Send the input bar's text. An armed Ctrl applies to it (audit A34): the bar goes through - * xterm's bracketed paste, so the per-keystroke Ctrl in [Bridge.onInput] never saw a single - * character — arming Ctrl and sending `z` used to submit a literal `z` plus Enter. One character - * with a control byte is sent as that byte alone, with no Enter (^Z then Enter is not ^Z); - * anything else just disarms the chip and is sent as typed. + * Send the input bar's text, as [InputBar.plan] says. An armed Ctrl applies to it (audit A34): the + * bar goes through xterm's bracketed paste, so the per-keystroke Ctrl in [Bridge.onInput] never saw + * a single character — arming Ctrl and sending `z` used to submit a literal `z` plus Enter. One + * character with a control byte is sent as that byte alone, with no Enter (^Z then Enter is not + * ^Z); anything else just disarms the chip and is sent as typed. + * + * Returns whether the text went to the attached stream. False while nothing is attached: nothing + * is sent, Ctrl stays armed, and the caller keeps its draft (A41 — it used to clear it). */ - fun submit(text: String, enter: Boolean) { - if (ctrlArmed) { - ctrlArmed = false - Keys.ctrl(text)?.let { - raw(it) - return + fun submit(text: String, enter: Boolean): Boolean { + when (val send = InputBar.plan(attached, ctrlArmed, text, enter)) { + InputBar.Send.NotAttached -> return false + is InputBar.Send.Control -> { + ctrlArmed = false + raw(send.bytes) + } + is InputBar.Send.Paste -> { + ctrlArmed = false + js("nt.submit('${b64(send.text.toByteArray(Charsets.UTF_8))}', ${send.enter})") } } - js("nt.submit('${b64(text.toByteArray(Charsets.UTF_8))}', $enter)") + return true } - fun key(name: String) = js("nt.key('$name')") + /** A key-row key. Nothing while not [attached]: the key row shows disabled then (A41). */ + fun key(name: String) { + if (attached) js("nt.key('$name')") + } + /** Raw bytes (the ^C/^D/^R/^L chips). Nothing while not [attached] (A41). */ fun raw(data: String) { - stream?.write(data) + if (attached) stream?.write(data) } fun focusTerminal() = js("nt.focus()") diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index b08c44e827..05e94e0dae 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -132,7 +132,7 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String Text("This session had ended on the computer (it restarted). Resume the $agent conversation?") Text(cmd, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { controller.acceptResume() }) { Text("Resume") } + Button(onClick = { controller.acceptResume() }, enabled = controller.attached) { Text("Resume") } OutlinedButton(onClick = { controller.dismissResume() }) { Text("Not now") } } } @@ -157,6 +157,10 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String } } KeyRow(controller) + // The draft is cleared only once it was sent: while nothing is attached (connecting, + // disconnected, ended) Send is disabled and the keyboard's Send leaves the text in place, + // with the overlay above saying why (A41). Typing a draft meanwhile stays possible. + val send: () -> Unit = { if (controller.submit(draft, enter = true)) draft = "" } Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( value = draft, @@ -165,52 +169,51 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String placeholder = { Text("Type a command or a prompt") }, maxLines = 4, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { - controller.submit(draft, enter = true) - draft = "" - }) + keyboardActions = KeyboardActions(onSend = { send() }) ) - IconButton(onClick = { - controller.submit(draft, enter = true) - draft = "" - }) { Icon(Icons.AutoMirrored.Filled.Send, "Send") } + IconButton(onClick = send, enabled = controller.attached) { Icon(Icons.AutoMirrored.Filled.Send, "Send") } } } } } -/** The keys a phone keyboard does not have, one tap each. Arrows honour the pane's cursor mode. */ +/** + * The keys a phone keyboard does not have, one tap each. Arrows honour the pane's cursor mode. The + * sending keys are disabled while nothing is attached (A41): they used to look sent and reach nothing. + * Ctrl (a modifier for the next key) and ⌨ (opens the keyboard) send nothing themselves. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun KeyRow(controller: TerminalController) { + val on = controller.attached Row( Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { FilterChip(selected = controller.ctrlArmed, onClick = { controller.ctrlArmed = !controller.ctrlArmed }, label = { Text("Ctrl") }) - KeyChip("Esc") { controller.key("esc") } - KeyChip("Tab") { controller.key("tab") } - KeyChip("⇧Tab") { controller.key("stab") } - KeyChip("↑") { controller.key("up") } - KeyChip("↓") { controller.key("down") } - KeyChip("←") { controller.key("left") } - KeyChip("→") { controller.key("right") } - KeyChip("⏎") { controller.key("enter") } - KeyChip("⇧⏎") { controller.key("nl") } - KeyChip("^C") { controller.raw("\u0003") } - KeyChip("^D") { controller.raw("\u0004") } - KeyChip("^R") { controller.raw("\u0012") } - KeyChip("^L") { controller.raw("\u000c") } - KeyChip("Home") { controller.key("home") } - KeyChip("End") { controller.key("end") } - KeyChip("PgUp") { controller.key("pgup") } - KeyChip("PgDn") { controller.key("pgdn") } + KeyChip("Esc", on) { controller.key("esc") } + KeyChip("Tab", on) { controller.key("tab") } + KeyChip("⇧Tab", on) { controller.key("stab") } + KeyChip("↑", on) { controller.key("up") } + KeyChip("↓", on) { controller.key("down") } + KeyChip("←", on) { controller.key("left") } + KeyChip("→", on) { controller.key("right") } + KeyChip("⏎", on) { controller.key("enter") } + KeyChip("⇧⏎", on) { controller.key("nl") } + KeyChip("^C", on) { controller.raw("\u0003") } + KeyChip("^D", on) { controller.raw("\u0004") } + KeyChip("^R", on) { controller.raw("\u0012") } + KeyChip("^L", on) { controller.raw("\u000c") } + KeyChip("Home", on) { controller.key("home") } + KeyChip("End", on) { controller.key("end") } + KeyChip("PgUp", on) { controller.key("pgup") } + KeyChip("PgDn", on) { controller.key("pgdn") } KeyChip("⌨") { controller.focusTerminal() } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun KeyChip(label: String, onClick: () -> Unit) { - FilterChip(selected = false, onClick = onClick, label = { Text(label, fontFamily = FontFamily.Monospace) }) +private fun KeyChip(label: String, enabled: Boolean = true, onClick: () -> Unit) { + FilterChip(selected = false, onClick = onClick, enabled = enabled, label = { Text(label, fontFamily = FontFamily.Monospace) }) } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InputBar.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InputBar.kt new file mode 100644 index 0000000000..35a654e1a7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InputBar.kt @@ -0,0 +1,34 @@ +package dev.nodeterm.protocol.model + +/** + * What the terminal screen's input bar does with the text in it (audits A34, A41). The screen + * (TerminalController) is only type-checked; this pins the rules it delegates to. + */ +object InputBar { + sealed interface Send { + /** + * No stream is attached (still connecting, disconnected, ended, or awaiting approval): nothing + * is sent. The caller keeps its draft and an armed Ctrl stays armed, so the same tap works once + * the terminal is back. Sending anyway reached a null stream, dropped the bytes, and the screen + * then cleared the draft as if it had gone (A41). + */ + data object NotAttached : Send + + /** + * Ctrl armed and ONE character that has a control byte: that byte alone, with no Enter (^Z then + * Enter is not ^Z). The bar goes through xterm's bracketed paste, so the per-keystroke Ctrl in + * the bridge would never see a single character (A34). + */ + data class Control(val bytes: String) : Send + + /** The text through xterm's bracketed paste, then Enter when asked. An armed Ctrl is disarmed. */ + data class Paste(val text: String, val enter: Boolean) : Send + } + + /** What sending [text] does, given whether a stream is [attached] and whether Ctrl is [ctrlArmed]. */ + fun plan(attached: Boolean, ctrlArmed: Boolean, text: String, enter: Boolean): Send = when { + !attached -> Send.NotAttached + ctrlArmed -> Keys.ctrl(text)?.let { Send.Control(it) } ?: Send.Paste(text, enter) + else -> Send.Paste(text, enter) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InputBarTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InputBarTest.kt new file mode 100644 index 0000000000..10b46eb0af --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InputBarTest.kt @@ -0,0 +1,42 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InputBar +import dev.nodeterm.protocol.model.InputBar.Send +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The terminal input bar's Send (audits A34, A41). TerminalController is only type-checked; it sends + * what [InputBar.plan] says, clears Ctrl only when something was sent, and tells the screen to clear + * the draft only then. + */ +class InputBarTest { + @Test + fun `nothing is sent while no stream is attached, so the draft is kept`() { + assertEquals(Send.NotAttached, InputBar.plan(attached = false, ctrlArmed = false, text = "ls -la", enter = true)) + assertEquals(Send.NotAttached, InputBar.plan(attached = false, ctrlArmed = false, text = "", enter = true)) + } + + @Test + fun `an armed Ctrl is not spent while no stream is attached`() { + // Not Control("\u001a"): that would disarm the chip for a byte that reaches nothing. + assertEquals(Send.NotAttached, InputBar.plan(attached = false, ctrlArmed = true, text = "z", enter = true)) + } + + @Test + fun `an attached stream gets the text as a paste, then Enter`() { + assertEquals(Send.Paste("ls -la", true), InputBar.plan(attached = true, ctrlArmed = false, text = "ls -la", enter = true)) + // An empty bar still sends the Enter. + assertEquals(Send.Paste("", true), InputBar.plan(attached = true, ctrlArmed = false, text = "", enter = true)) + } + + @Test + fun `Ctrl and one character is that control byte alone`() { + assertEquals(Send.Control("\u001a"), InputBar.plan(attached = true, ctrlArmed = true, text = "z", enter = true)) + } + + @Test + fun `Ctrl and anything else is sent as typed`() { + assertEquals(Send.Paste("zz", true), InputBar.plan(attached = true, ctrlArmed = true, text = "zz", enter = true)) + } +} diff --git a/docs/android.md b/docs/android.md index 56be652989..2058f1d578 100644 --- a/docs/android.md +++ b/docs/android.md @@ -157,6 +157,13 @@ request was consumed to get there, so a launch dropped half-way could not be ret leave an unregistered shell no canvas shows. The terminal screen's use of these rules is only type-checked. +`InputBarTest` pins the terminal input bar's Send (`A34`, `A41`). While no stream is attached +(connecting, disconnected, ended), nothing is sent: the screen keeps the draft and an armed Ctrl +stays armed, instead of clearing text that reached nothing. Attached, Ctrl plus one character sends +that control byte alone, and anything else goes as a paste followed by Enter. The screen disables +Send, the Resume offer and the sending key chips while nothing is attached; that part is only +type-checked. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 1901939e8adbf0693b5d6c988ffc314c71adf1fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:11:56 +0000 Subject: [PATCH 052/284] fix(android): survive the terminal WebView's renderer going away (A45) The terminal's WebViewClient overrode only shouldOverrideUrlLoading. When the renderer process was killed (memory) or crashed, the default onRenderProcessGone (false) took the whole app down with it. - protocol: `host/TerminalPage.kt`, JVM-tested. - `TerminalPage`: the page by generation. `build()` per WebView, `ready(gen)` hands over queued JavaScript only to the current page, `lost()` retires the generation and drops what was queued for the dead page, and JavaScript offered while no page exists is kept for the next one (a reattach can paint before the new WebView is built). `isReplacing` holds an attach back until a replacement page has reported its size. - `RendererRecovery`: who reattaches. A kill (didCrash false) is reattached automatically; a crash offers "Reopen terminal", since the reattach repaints the same screen and could crash it again in a loop; more than two automatic reattaches within a minute fall back to the offer too. - TerminalController: onRenderProcessGone returns true and, for the current view, retires the page, cancels the attach, detaches the stream (`slot.leave()`, so a phone launch still holding it finishes, A40), drops buffered output, resets cols/rows and sizedElsewhere, removes the WebView from its parent and destroys it, and bumps `webViewKey`. Then state is Connecting (the new page's first resize or ready reattaches it, or onStart when in the background) or the new `TermState.ViewLost`. The bridge carries its page's generation; callbacks of a dead page are ignored. `pageReady`/`pendingJs` are replaced by `TerminalPage`. `setRendererPriorityPolicy(RENDERER_PRIORITY_BOUND, true)` lets Android reclaim the renderer before the app while the screen is not visible (the stream is detached then anyway). A delayed auto-reattach (A36) does not override the ViewLost offer. - TerminalScreen: the AndroidView is wrapped in key(webViewKey) so a new WebView is built; a ViewLost overlay with "Reopen terminal". Decision (the verifier left it open): reattach automatically for a kill, once the new page has reported its size (attaching earlier would claim the pty at 80x24 on a pty the desktop may share); offer the button for a crash or a kill loop. As from every other state, leaving the app and coming back reattaches, the ViewLost offer included. Beyond the proposal: dispose() now also removes the WebView from its parent before destroy() (the same helper; WebView.destroy expects a detached view), a replacement WebView built while the screen is stopped is paused like its predecessor, and copy callbacks are gated by page generation too. No typecheck stubs were needed: RenderProcessGoneDetail, onRenderProcessGone and setRendererPriorityPolicy are in android-all (API 35), and minSdk 26 covers them. Verified: TerminalPageTest (10 tests). Mutation-checked: dropping the generation check in ready(), keeping the queue in lost(), clearing it in build(), keeping the generation in lost(), not marking replacing, reattaching on a crash, and removing the kill bound each fail 1-2 tests. Full protocol suite (gradle test --offline, 175 tests) and the offline app type-check (gradle compileKotlin --offline) pass. The controller and screen are only type-checked; nothing has run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/android/ui/TerminalController.kt | 158 +++++++++++++++--- .../dev/nodeterm/android/ui/TerminalScreen.kt | 14 +- .../nodeterm/protocol/host/TerminalPage.kt | 118 +++++++++++++ .../dev/nodeterm/protocol/TerminalPageTest.kt | 130 ++++++++++++++ docs/android.md | 11 ++ 5 files changed, 408 insertions(+), 23 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index ee3e36c689..0afcdae932 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -14,7 +14,9 @@ import android.os.Handler import android.os.Looper import android.os.TransactionTooLargeException import android.util.Base64 +import android.view.ViewGroup import android.webkit.JavascriptInterface +import android.webkit.RenderProcessGoneDetail import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient @@ -27,10 +29,12 @@ import dev.nodeterm.android.conn.HostSession import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.RelayConnectStatus +import dev.nodeterm.protocol.host.RendererRecovery import dev.nodeterm.protocol.host.NewNode import dev.nodeterm.protocol.host.NewSessionHint import dev.nodeterm.protocol.host.PhoneLaunch import dev.nodeterm.protocol.host.StreamLease +import dev.nodeterm.protocol.host.TerminalPage import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TerminalStream import dev.nodeterm.protocol.host.ViewerSlot @@ -55,6 +59,11 @@ sealed interface TermState { data class RelayOffer(val message: String) : TermState /** Opening through the relay for the first time: the computer shows this code to approve. */ data class AwaitingApproval(val sas: String) : TermState + /** + * The terminal view's renderer crashed, or kept being killed (audit A45). A new view has been + * built; "Reopen terminal" reattaches to it. Not automatic: see [RendererRecovery]. + */ + data class ViewLost(val message: String) : TermState } /** @@ -88,10 +97,18 @@ class TerminalController( /** A one-line message over the terminal (dismissable), e.g. a session the desktop refused to add. */ var notice by mutableStateOf(null) + /** + * Changes each time the WebView is lost with its renderer (audit A45). The screen keys its + * AndroidView on it, so a change builds a new WebView ([createWebView]) in place of the dead one. + */ + var webViewKey by mutableStateOf(0) + private set + private val main = Handler(Looper.getMainLooper()) private var webView: WebView? = null - private var pageReady = false - private val pendingJs = ArrayList() + /** The page in [webView], by generation, and the JavaScript waiting for it (A45). */ + private val page = TerminalPage() + private val recovery = RendererRecovery() /** * The attach hand-off (audit A40): which attach may still install its stream, and the stream this * screen shows. Thread-safe; [stream] is also read on the WebView's bridge thread. @@ -165,30 +182,40 @@ class TerminalController( } } - inner class Bridge { + /** + * The page's side of the bridge, one per page ([gen], from [TerminalPage.build]). A callback of a + * page whose renderer is gone does nothing (A45): not even one posted just before the loss can + * mark the replacement page ready, resize the pty from the dead page's size, or type into it. + */ + inner class Bridge(private val gen: Int) { @JavascriptInterface fun onReady() { main.post { - pageReady = true - pendingJs.forEach { webView?.evaluateJavascript(it, null) } - pendingJs.clear() + val queued = page.ready(gen) ?: return@post + val wv = webView + queued.forEach { wv?.evaluateJavascript(it, null) } + // A replacement page after the renderer was lost (A45): reattach now that it is loaded, + // unless its first resize below already did. Nothing to do on the first page, whose + // attach started with the screen. + attachIfWaiting() } } @JavascriptInterface fun onResize(c: Int, r: Int) { main.post { - if (c <= 0 || r <= 0) return@post + if (c <= 0 || r <= 0 || !page.isCurrent(gen)) return@post cols = c rows = r sizedElsewhere = null val s = stream - if (s != null) s.resize(c, r) else if (!stopped && attachJob == null && state == TermState.Connecting) attach() + if (s != null) s.resize(c, r) else attachIfWaiting() } } @JavascriptInterface fun onInput(data: String) { + if (!page.isCurrent(gen)) return var out = data if (ctrlArmed && data.length == 1) { Keys.ctrl(data)?.let { out = it } @@ -199,6 +226,7 @@ class TerminalController( @JavascriptInterface fun onScroll(up: Boolean, notches: Int) { + if (!page.isCurrent(gen)) return val s = stream ?: return graph.scope.launch { runCatching { s.scroll(up, notches) } } } @@ -210,12 +238,14 @@ class TerminalController( /** An OSC 52 terminal.js refused as over [copyLimit]; the payload itself never crossed. */ @JavascriptInterface fun onCopyTooLarge() { + if (!page.isCurrent(gen)) return main.post { toast(COPY_TOO_LARGE, Toast.LENGTH_LONG) } } /** A whole OSC 52 sequence (`;`), parsed here on the bridge thread. */ @JavascriptInterface fun onCopy(data: String) { + if (!page.isCurrent(gen)) return when (val r = Osc52.parse(data)) { is Osc52.Result.Copy -> main.post { writeClipboard(r.text) } Osc52.Result.TooLarge -> main.post { toast(COPY_TOO_LARGE, Toast.LENGTH_LONG) } @@ -255,6 +285,7 @@ class TerminalController( @SuppressLint("SetJavaScriptEnabled") fun createWebView(context: Context): WebView = WebView(context).apply { + val gen = page.build() setBackgroundColor(Color.BLACK) settings.javaScriptEnabled = true settings.allowFileAccess = false // assets stay readable; nothing else on disk is @@ -262,7 +293,10 @@ class TerminalController( settings.setSupportZoom(false) settings.builtInZoomControls = false settings.displayZoomControls = false - addJavascriptInterface(Bridge(), "NodetermBridge") + // While the screen is not visible (its stream is detached then anyway), Android may reclaim + // this renderer before the app; onRenderProcessGone below brings the terminal back (A45). + setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_BOUND, true) + addJavascriptInterface(Bridge(gen), "NodetermBridge") webViewClient = object : WebViewClient() { // The page never navigates; a link the user taps opens in the browser, outside this bridge. override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { @@ -272,19 +306,98 @@ class TerminalController( } return true } + + // The renderer was killed or crashed. The default (false) takes the whole app down with + // it; true keeps the app, which must then stop using this WebView (A45). + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + rendererGone(view, detail.didCrash()) + return true + } } loadUrl("file:///android_asset/terminal/index.html") + // A replacement built while the screen is in the background (A45) is paused like the one it + // replaces was on ON_STOP; onStart resumes it. + if (stopped) onPause() webView = this js("nt.setFontSize(${graph.hosts.fontSize})") } private fun js(code: String) { - val wv = webView ?: return - if (!pageReady) { - pendingJs += code - return + if (disposed) return + // Queued until the page is ready, also while there is no page at all: after the renderer was + // lost, a reattach can paint before the replacement WebView is built (A45). + if (page.offer(code)) webView?.evaluateJavascript(code, null) + } + + /** + * The renderer behind [view] is gone (audit A45): Android killed it to reclaim memory, or it + * crashed. That WebView can never be used again. The stream is detached (it would paint into + * nothing), the WebView leaves the view tree and is destroyed, and [webViewKey] changes so the + * screen builds a new one. Then, per [RendererRecovery], the screen reattaches by itself once the + * new page is ready (a kill), or offers "Reopen terminal" (a crash, or kills in a loop). + */ + private fun rendererGone(view: WebView, didCrash: Boolean) { + // A view this screen already let go of (a repeated callback, or one after dispose). + if (view !== webView) return + webView = null + page.lost() + // Detached as on ON_STOP. A launch still holding the stream finishes first (A40), and the + // stream's exit is not reported: its ticket is retired. + attachJob?.cancel() + attachJob = null + slot.leave() + main.removeCallbacks(flush) + synchronized(outBuf) { + outBuf.reset() + flushScheduled = false + } + // The dead page's size. The new page reports its own before it reattaches. + cols = 0 + rows = 0 + sizedElsewhere = null + destroyWebView(view, rendererAlive = false) + webViewKey++ + state = when (recovery.onGone(didCrash, System.currentTimeMillis())) { + // Reattached by attachIfWaiting once the new page reports in (or by onStart, when the + // screen is in the background now). + RendererRecovery.Action.REATTACH -> TermState.Connecting + RendererRecovery.Action.OFFER -> TermState.ViewLost( + (if (didCrash) "The terminal view crashed." else "Android keeps closing the terminal view to free memory.") + + " The session is still running on the computer." + ) } - wv.evaluateJavascript(code, null) + } + + /** + * Take [view] out of the view tree, then destroy it: WebView.destroy() expects a view that is no + * longer attached. A view whose renderer is gone is not used for anything else. + */ + private fun destroyWebView(view: WebView, rendererAlive: Boolean) { + (view.parent as? ViewGroup)?.removeView(view) + if (rendererAlive) view.removeJavascriptInterface("NodetermBridge") + view.destroy() + } + + /** + * Attach if the screen is waiting to: started, nothing attached or attaching, and "Opening + * terminal…" showing. Run when the page reports its size or that it is ready, which is how a + * replacement page after a renderer loss gets its terminal back (A45). + */ + private fun attachIfWaiting() { + if (!disposed && !stopped && stream == null && attachJob == null && state == TermState.Connecting) attach() + } + + /** + * Attach now or, while a replacement page is still loading after a renderer loss (A45), once it + * has reported its size ([attachIfWaiting]): attaching before would claim the pty at 80×24. + */ + private fun attachWhenPageReady() { + if (page.isReplacing) state = TermState.Connecting else attach() + } + + /** "Reopen terminal", after the view was lost with its renderer (A45). */ + fun reopenTerminal() { + if (state is TermState.ViewLost) attachWhenPageReady() } private var attachedAt = 0L @@ -307,13 +420,16 @@ class TerminalController( // so the attach below does not get the stale one back. if (!useRelay || requireLive) session.refreshNow() if (requireLive && !session.snapshot.value.isLive(nodeId)) { - main.post { if (!disposed && stream == null) state = TermState.Ended("The session ended (exit 0).") } + main.post { + if (!disposed && stream == null && state !is TermState.ViewLost) state = TermState.Ended("The session ended (exit 0).") + } return@launch } val up = if (useRelay) true else withTimeoutOrNull(120_000) { session.state.first { it is ConnState.Connected } } != null main.post { - if (disposed || stream != null || attachJob != null) return@post - if (up) attach() else state = TermState.Ended("Disconnected.") + // The view was lost meanwhile and is waiting for the user's "Reopen terminal" (A45). + if (disposed || stream != null || attachJob != null || state is TermState.ViewLost) return@post + if (up) attachWhenPageReady() else state = TermState.Ended("Disconnected.") } } return true @@ -507,7 +623,8 @@ class TerminalController( if (disposed || !stopped) return stopped = false webView?.onResume() - if (stream == null && attachJob == null) attach() + // As from every other state, coming back reattaches, including after a view loss (A45). + if (stream == null && attachJob == null) attachWhenPageReady() } fun dispose() { @@ -517,10 +634,7 @@ class TerminalController( // let go of by the hand-off, and a session it created still gets its launch. slot.close() main.removeCallbacks(flush) - webView?.let { - it.removeJavascriptInterface("NodetermBridge") - it.destroy() - } + webView?.let { destroyWebView(it, rendererAlive = true) } webView = null } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 05e94e0dae..313857b780 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.lifecycle.compose.LifecycleStartEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -84,7 +85,10 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String ) { padding -> Column(Modifier.fillMaxSize().padding(padding).imePadding()) { Box(Modifier.weight(1f).fillMaxWidth().background(NtColors.canvas)) { - AndroidView(factory = { ctx -> controller.createWebView(ctx) }, modifier = Modifier.fillMaxSize()) + // A new key = a new WebView: the old one was destroyed with its renderer (audit A45). + key(controller.webViewKey) { + AndroidView(factory = { ctx -> controller.createWebView(ctx) }, modifier = Modifier.fillMaxSize()) + } when (val st = controller.state) { TermState.Connecting -> Row( Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(12.dp), @@ -110,6 +114,14 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String Text(st.message) Button(onClick = { controller.openThroughRelay() }) { Text("Open through the relay") } } + is TermState.ViewLost -> Column( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.reopenTerminal() }) { Text("Reopen terminal") } + } is TermState.AwaitingApproval -> Column( Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt new file mode 100644 index 0000000000..709371b0dd --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt @@ -0,0 +1,118 @@ +package dev.nodeterm.protocol.host + +/** + * The terminal screen's page (the WebView running xterm.js), by generation (audit A45). + * + * The renderer process behind a WebView can go away on its own: Android kills it to reclaim memory, + * or it crashes. That WebView can never be used again, so the screen destroys it and builds a new + * one, which loads a new page. Every page gets a generation from [build], and its bridge callbacks + * carry it. A callback of a page that is gone (posted to the main thread before the loss, run after + * it) therefore cannot mark the NEXT page ready ([ready] answers null), and the JavaScript queued for + * a page that never became ready is dropped with it ([lost]) instead of being replayed into the next. + * + * JavaScript for the page goes through [offer]: run it now when the current page is ready, otherwise + * it waits until that page is. What is offered while there is no page at all (after [lost], before + * the next [build]) is kept for the next page: a reattach may paint before the replacement exists. + * + * Thread-safe. The screen calls it on the main thread; [isCurrent] also from the bridge thread. + */ +class TerminalPage { + private var generation = 0 + private var ready = false + private var replacing = false + private val pending = ArrayList() + + /** The current page has loaded and runs JavaScript as it is offered. */ + val isReady: Boolean + @Synchronized get() = ready + + /** + * A page was lost and its replacement has not loaded yet. An attach started now would size the + * pty from defaults (80×24) instead of from the new page, so the screen waits for [ready]. + */ + val isReplacing: Boolean + @Synchronized get() = replacing + + /** A new page starts loading (a new WebView). Returns its generation, for its bridge. */ + @Synchronized + fun build(): Int { + generation++ + ready = false + return generation + } + + /** True while [gen] is the page being shown (or loading): its callbacks count. */ + @Synchronized + fun isCurrent(gen: Int): Boolean = gen == generation + + /** + * Page [gen] finished loading. Returns the JavaScript queued for it, in order, to run now (empty + * when nothing was queued, or when it had already said so), or null when [gen] is a page that is + * gone: it is not ready, and nothing is handed over. + */ + @Synchronized + fun ready(gen: Int): List? { + if (gen != generation) return null + if (ready) return emptyList() + ready = true + replacing = false + return pending.toList().also { pending.clear() } + } + + /** JavaScript for the page: true = run it now; false = queued until the page is ready. */ + @Synchronized + fun offer(code: String): Boolean { + if (ready) return true + pending += code + return false + } + + /** + * The current page's renderer is gone. Its generation is retired (its late callbacks are + * refused), and what was queued for it is dropped: it was meant for a screen that no longer + * exists, and the reattach that follows paints the whole screen again. + */ + @Synchronized + fun lost() { + generation++ + ready = false + replacing = true + pending.clear() + } +} + +/** + * What the terminal screen does once its page's renderer is gone (audit A45). Either way a new page + * is built; the question is only who reattaches the terminal to it. + * + * - The system KILLED the renderer (`didCrash` false): nothing is wrong with what the page showed, so + * the screen reattaches by itself once the new page is ready. With the renderer's priority waived + * while the screen is not visible, this is the normal way a backgrounded terminal loses its page. + * - The renderer CRASHED: the cause may be what the pane printed, and a reattach paints the same + * screen again, so reattaching unasked could crash it over and over. The screen offers "Reopen + * terminal" and the user decides. + * - A kill also falls back to the offer after [maxAutomatic] automatic reattaches within [windowMs]: + * a device that keeps killing a visible renderer should not have the app fight it in a loop. + * + * Thread-safe; [now] is a millisecond clock supplied by the caller. + */ +class RendererRecovery(private val windowMs: Long = 60_000L, private val maxAutomatic: Int = 2) { + enum class Action { + /** Reattach by itself once the new page is ready. */ + REATTACH, + + /** Offer "Reopen terminal"; the user decides when to reattach. */ + OFFER + } + + private val automatic = ArrayDeque() + + @Synchronized + fun onGone(didCrash: Boolean, now: Long): Action { + if (didCrash) return Action.OFFER + while (automatic.isNotEmpty() && now - automatic.first() >= windowMs) automatic.removeFirst() + if (automatic.size >= maxAutomatic) return Action.OFFER + automatic.addLast(now) + return Action.REATTACH + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt new file mode 100644 index 0000000000..816fb30d93 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt @@ -0,0 +1,130 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.RendererRecovery +import dev.nodeterm.protocol.host.RendererRecovery.Action +import dev.nodeterm.protocol.host.TerminalPage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A45: the terminal WebView's renderer can go away (killed by the system, or crashed), and the + * screen then builds a new WebView. The screen (TerminalController) is only type-checked; these pin + * the rules it delegates to: which page's callbacks count, where queued JavaScript goes, and who + * reattaches. + */ +class TerminalPageTest { + // ---- the page generation ----------------------------------------------------------------- + + @Test + fun `JavaScript offered before the page is ready is run in order once it is`() { + val page = TerminalPage() + val gen = page.build() + assertFalse(page.offer("a")) + assertFalse(page.offer("b")) + assertEquals(listOf("a", "b"), page.ready(gen)) + assertTrue(page.isReady) + assertTrue(page.offer("c"), "a ready page runs JavaScript at once") + assertEquals(emptyList(), page.ready(gen), "a second ready hands nothing over again") + } + + @Test + fun `a lost page's late ready neither marks the next page ready nor takes its queue`() { + val page = TerminalPage() + val dead = page.build() + page.lost() + val next = page.build() + assertFalse(page.offer("paint")) + // The dead page's onReady, posted before the loss and run after it. + assertNull(page.ready(dead)) + assertFalse(page.isReady) + assertFalse(page.isCurrent(dead)) + assertTrue(page.isCurrent(next)) + assertEquals(listOf("paint"), page.ready(next), "the queue is still there for the page it was meant for") + } + + @Test + fun `the renderer going away before the page was ready drops what was queued for it`() { + val page = TerminalPage() + val dead = page.build() + page.offer("nt.write(old output)") + page.lost() + assertFalse(page.isCurrent(dead), "the dead page's callbacks no longer count") + assertTrue(page.isReplacing) + val next = page.build() + page.offer("nt.setFontSize(13)") + assertEquals(listOf("nt.setFontSize(13)"), page.ready(next)) + assertFalse(page.isReplacing) + } + + @Test + fun `a ready page that is lost stops running JavaScript until its replacement is ready`() { + val page = TerminalPage() + page.ready(page.build()) + assertTrue(page.offer("x")) + page.lost() + assertFalse(page.isReady) + assertFalse(page.offer("y"), "no page to run it on: queued, not evaluated on a destroyed WebView") + } + + @Test + fun `JavaScript offered while there is no page at all is kept for the next one`() { + val page = TerminalPage() + page.ready(page.build()) + page.lost() + // A reattach can land and paint before the replacement WebView has been built. + assertFalse(page.offer("nt.paint(screen)")) + val next = page.build() + page.offer("nt.setFontSize(13)") + assertEquals(listOf("nt.paint(screen)", "nt.setFontSize(13)"), page.ready(next)) + } + + @Test + fun `only a replacement page is waited for, not the first one`() { + val page = TerminalPage() + val first = page.build() + assertFalse(page.isReplacing, "the first attach is not held back for the first page") + page.ready(first) + page.lost() + val next = page.build() + assertTrue(page.isReplacing) + page.ready(next) + assertFalse(page.isReplacing) + } + + // ---- who reattaches -------------------------------------------------------------------- + + @Test + fun `a crashed renderer is offered to the user, never reattached by itself`() { + val recovery = RendererRecovery() + assertEquals(Action.OFFER, recovery.onGone(didCrash = true, now = 0)) + assertEquals(Action.OFFER, recovery.onGone(didCrash = true, now = 3_600_000)) + } + + @Test + fun `a renderer the system killed is reattached by itself`() { + val recovery = RendererRecovery() + assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 0)) + } + + @Test + fun `repeated kills fall back to the offer, and recover once the window has passed`() { + val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 2) + assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 0)) + assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 10_000)) + assertEquals(Action.OFFER, recovery.onGone(didCrash = false, now = 20_000)) + assertEquals(Action.OFFER, recovery.onGone(didCrash = false, now = 59_999)) + // The first automatic reattach has left the window: one more is allowed. + assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 60_000)) + assertEquals(Action.OFFER, recovery.onGone(didCrash = false, now = 60_001)) + } + + @Test + fun `an offer is not counted as an automatic reattach`() { + val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 1) + assertEquals(Action.OFFER, recovery.onGone(didCrash = true, now = 0)) + assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 1)) + } +} diff --git a/docs/android.md b/docs/android.md index 2058f1d578..5c734046b9 100644 --- a/docs/android.md +++ b/docs/android.md @@ -164,6 +164,17 @@ that control byte alone, and anything else goes as a paste followed by Enter. Th Send, the Resume offer and the sending key chips while nothing is attached; that part is only type-checked. +`TerminalPageTest` pins what happens when the terminal WebView's renderer process goes away +(`A45`). The app handles `onRenderProcessGone` instead of being killed with the renderer: it +detaches the stream, destroys that WebView and builds a new one. Each page has a generation, so a +callback the dead page posted just before the loss cannot mark the new page ready, and JavaScript +queued for the dead page is dropped rather than replayed into the new one. A paint offered before +the new page exists is kept for it. A renderer the system killed (the terminal's renderer priority +is waived while the screen is not visible) is reattached automatically once the new page has +reported its size. A crash is not, because the reattach would repaint the same screen: the screen +offers "Reopen terminal" instead. More than two kills within a minute fall back to that offer too. +The WebView handling itself is only type-checked. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 17ee5262b05f6b1d0a98558d9d4bb9624df2be6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:22:11 +0000 Subject: [PATCH 053/284] =?UTF-8?q?fix(android):=20make=20the=20key=20row'?= =?UTF-8?q?s=20=E2=8C=A8=20chip=20raise=20the=20soft=20keyboard=20(A46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip ran only `term.focus()` in the terminal page. That cannot bring up the keyboard: Blink returns early from focus() on the element that already has focus (xterm's textarea, once the terminal was tapped), nothing asked Android for the keyboard, and Android's focus (which the keyboard follows) stayed with the input bar's text field or nowhere. Following the verifier's recipe, the chip now: - releases Compose's focus (LocalFocusManager.clearFocus() in the key row), so the draft field lets go of the keyboard; - gives the WebView Android's focus (webView.requestFocus()); - moves the page's focus onto the textarea with the new `nt.focusForKeyboard()`, which blurs before focusing so Blink's already-focused early return cannot swallow it; - asks InputMethodManager.showSoftInput(webView, SHOW_IMPLICIT), which does not depend on the page having seen a touch since it loaded. TerminalController.focusTerminal() is renamed showKeyboard(). Departure from the audit: the showSoftInput call is deferred by a Choreographer frame callback AND webView.post {}, not by webView.post {} alone. Compose's text input has queued its keyboard commands (including the hide for a field that lost focus) to the next frame; a bare post can run before that frame and have the keyboard hidden again. The request is dropped if the WebView was replaced meanwhile (renderer loss, A45). Tests: TerminalKeyboardChipTest. The page half runs the real terminal.js under node (terminal-js-driver.cjs gains a textarea stub that follows Blink's focus rule and a way to call window.nt functions); the Android half cannot run on a JVM, so its order (clearFocus before showKeyboard; requestFocus, then the page call, then one deferred showSoftInput with SHOW_IMPLICIT) is pinned in the source. The node runner is shared with TerminalJsOsc52Test (TerminalJsDriver). The protocol test task now declares src/test/interop, the terminal assets and the app's ui sources as inputs, so editing terminal.js re-runs these tests instead of reporting them up to date (checked: unchanged -> UP-TO-DATE, edited -> re-run). Mutation checks: with focusForKeyboard reduced to term.focus() the already-focused case fails; with the app files at HEAD all four tests fail; without clearFocus, or with a synchronous showSoftInput, the two source pins fail. Protocol suite (179 tests, 0 skipped) and the offline app type-check pass. Nothing has run on a device: whether the keyboard comes up and stays up is a device check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../app/src/main/assets/terminal/terminal.js | 6 ++ .../nodeterm/android/ui/TerminalController.kt | 33 ++++++- .../dev/nodeterm/android/ui/TerminalScreen.kt | 8 +- android/protocol/build.gradle.kts | 6 ++ .../src/test/interop/terminal-js-driver.cjs | 50 +++++++--- .../dev/nodeterm/protocol/TerminalJsDriver.kt | 36 ++++++++ .../nodeterm/protocol/TerminalJsOsc52Test.kt | 36 ++------ .../protocol/TerminalKeyboardChipTest.kt | 92 +++++++++++++++++++ docs/android.md | 9 ++ 9 files changed, 237 insertions(+), 39 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsDriver.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt diff --git a/android/app/src/main/assets/terminal/terminal.js b/android/app/src/main/assets/terminal/terminal.js index cf56d4c945..61e5add4ae 100644 --- a/android/app/src/main/assets/terminal/terminal.js +++ b/android/app/src/main/assets/terminal/terminal.js @@ -97,6 +97,12 @@ reset: function () { term.reset() }, focus: function () { term.focus() }, blur: function () { term.blur() }, + // The ⌨ chip (audit A46): the Kotlin side has just given the WebView Android's focus and asks + // the system for the soft keyboard; this puts the page's focus on xterm's textarea for it. + // Blur first: Blink's focus() on the element that already has focus returns early and does + // nothing, and that is the normal state once the terminal has been tapped. (With focus + // reporting on, xterm reports a focus-out and a focus-in to the pane for this.) + focusForKeyboard: function () { term.blur(); term.focus() }, setFontSize: function (n) { fontSize = n; term.options.fontSize = n; doFit(true) }, refit: function () { doFit(true) }, // A composed line from the native input bar. `term.paste` frames it as a bracketed paste when diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 0afcdae932..f896695a82 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -14,7 +14,9 @@ import android.os.Handler import android.os.Looper import android.os.TransactionTooLargeException import android.util.Base64 +import android.view.Choreographer import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager import android.webkit.JavascriptInterface import android.webkit.RenderProcessGoneDetail import android.webkit.WebResourceRequest @@ -597,7 +599,36 @@ class TerminalController( if (attached) stream?.write(data) } - fun focusTerminal() = js("nt.focus()") + /** + * The ⌨ chip: bring up the soft keyboard to type straight into the terminal (audit A46). It used + * to run only `term.focus()` in the page, which cannot do that: Blink ignores focus() on the + * element that already has focus (the textarea, once the terminal was tapped), and the keyboard + * attaches to the focused Android view, which is the input bar's text field or nothing. The + * caller releases Compose's focus first, so that field lets go of the keyboard. Here, in order: + * the WebView takes Android's focus, the page moves its focus onto xterm's textarea + * (`nt.focusForKeyboard`, which blurs first), and the keyboard is asked for through + * InputMethodManager, which does not depend on the page having seen a touch since it loaded. + * + * That request waits for the next frame and then one more main-thread turn. The input method + * serves the newly focused view only once its focus change has been processed, and the text + * field that lost focus can hide the keyboard from a frame callback rather than at once + * (Compose's text input has queued its keyboard commands to the next frame): asked for before + * either, the keyboard would not come up, or would be hidden again straight away. None of this + * has run on a device. + */ + fun showKeyboard() { + val wv = webView ?: return + wv.requestFocus() + js("nt.focusForKeyboard()") + Choreographer.getInstance().postFrameCallback { + wv.post { + // The view was replaced (renderer lost, A45) or the screen left meanwhile. + if (webView !== wv) return@post + val imm = wv.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(wv, InputMethodManager.SHOW_IMPLICIT) + } + } + } fun setFontSize(size: Int) { graph.hosts.fontSize = size diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 313857b780..2104d26d58 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -198,6 +199,7 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String @Composable private fun KeyRow(controller: TerminalController) { val on = controller.attached + val focusManager = LocalFocusManager.current Row( Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) @@ -220,7 +222,11 @@ private fun KeyRow(controller: TerminalController) { KeyChip("End", on) { controller.key("end") } KeyChip("PgUp", on) { controller.key("pgup") } KeyChip("PgDn", on) { controller.key("pgdn") } - KeyChip("⌨") { controller.focusTerminal() } + // The input bar's field lets go of focus first, or it keeps the keyboard (audit A46). + KeyChip("⌨") { + focusManager.clearFocus() + controller.showKeyboard() + } } } diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index f57913bc35..cd5001b242 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -44,6 +44,12 @@ tasks.test { // Interop tests drive this repo's TypeScript host code through node. They locate the repo root // from here and skip themselves (with a reason) when node or node_modules are missing. systemProperty("nodeterm.repoRoot", rootDir.resolve("../..").canonicalPath) + // Files some tests run or read that are not on the test classpath: the node drivers, the app's + // terminal page (TerminalJs*Test) and the terminal screen's source (TerminalKeyboardChipTest). + // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". + inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir(rootDir.resolve("../app/src/main/assets/terminal")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir(rootDir.resolve("../app/src/main/kotlin/dev/nodeterm/android/ui")).withPathSensitivity(PathSensitivity.RELATIVE) systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn") testLogging { events("failed", "skipped") diff --git a/android/protocol/src/test/interop/terminal-js-driver.cjs b/android/protocol/src/test/interop/terminal-js-driver.cjs index 13fbe13813..79fc58e0cb 100644 --- a/android/protocol/src/test/interop/terminal-js-driver.cjs +++ b/android/protocol/src/test/interop/terminal-js-driver.cjs @@ -1,13 +1,19 @@ // Runs the Android app's REAL terminal page script (android/app/src/main/assets/terminal/terminal.js) // in node, against stubs of xterm.js, the DOM and the WebView bridge, so a JVM test can check what -// the page hands the Kotlin side. Only the OSC 52 handler is driven today (audit A53). +// the page does. Driven today: the OSC 52 handler (audit A53) and the page's focus for the ⌨ chip +// (audit A46). // // Usage: node terminal-js-driver.cjs -// stdin: {"copyLimit": , "osc52": ["", ...]} +// stdin: {"copyLimit": , "osc52": ["", ...], +// "textareaFocused": , "nt": ["", ...]} +// (every field but copyLimit is optional) // stdout: one JSON object: -// {"copyLimitCalls": n, "osc52": [{"returned": , "calls": [[name, arg?], ...]}]} +// {"copyLimitCalls": n, "osc52": [{"returned": , "calls": [[name, arg?], ...]}], +// "nt": [{"fn": name, "focusChanges": ["blur" | "focus", ...], "focusedAfter": bool}]} // where an onCopy call's argument is reported as {"length": n, "sameAsInput": bool}, so a payload of -// several hundred thousand characters never has to be echoed back. +// several hundred thousand characters never has to be echoed back. The stub textarea follows Blink's +// rule for focus(): on the element that already has focus it returns early, changing nothing, and so +// does blur() on one that has not. focusChanges lists only the changes that took effect. 'use strict' const fs = require('fs') const vm = require('vm') @@ -35,6 +41,8 @@ const bridge = { } const oscHandlers = {} +let textareaFocused = !!input.textareaFocused +let focusChanges = null class Terminal { constructor() { this.cols = 80 @@ -53,8 +61,16 @@ class Terminal { onBinary() {} write() {} reset() {} - focus() {} - blur() {} + focus() { + if (textareaFocused) return + textareaFocused = true + if (focusChanges) focusChanges.push('focus') + } + blur() { + if (!textareaFocused) return + textareaFocused = false + if (focusChanges) focusChanges.push('blur') + } paste() {} } class FitAddonStub { @@ -74,15 +90,27 @@ const sandbox = { vm.createContext(sandbox) vm.runInContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath }) -const handler = oscHandlers[52] -if (typeof handler !== 'function') { - process.stdout.write(JSON.stringify({ error: 'terminal.js registered no OSC 52 handler' }) + '\n') +function fail(message) { + process.stdout.write(JSON.stringify({ error: message }) + '\n') process.exit(0) } + +const handler = oscHandlers[52] +if (typeof handler !== 'function') fail('terminal.js registered no OSC 52 handler') const results = [] -for (const data of input.osc52) { +for (const data of input.osc52 || []) { current = { input: data, calls: [] } const returned = handler(data) results.push({ returned, calls: current.calls }) } -process.stdout.write(JSON.stringify({ copyLimitCalls, osc52: results }) + '\n') + +const nt = sandbox.window.nt +const ntResults = [] +for (const fn of input.nt || []) { + if (!nt || typeof nt[fn] !== 'function') fail('terminal.js has no window.nt.' + fn) + focusChanges = [] + nt[fn]() + ntResults.push({ fn, focusChanges, focusedAfter: textareaFocused }) + focusChanges = null +} +process.stdout.write(JSON.stringify({ copyLimitCalls, osc52: results, nt: ntResults }) + '\n') diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsDriver.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsDriver.kt new file mode 100644 index 0000000000..a2f1e90d01 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsDriver.kt @@ -0,0 +1,36 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Runs the app's real `terminal.js` in node against stub xterm/DOM/bridge objects, through + * `src/test/interop/terminal-js-driver.cjs` (its header documents the request and the reply). Skips + * the calling test when there is no node. + */ +object TerminalJsDriver { + fun run(request: JsonObject): JsonObject { + val nodeOk = runCatching { ProcessBuilder("node", "--version").start().waitFor() == 0 }.getOrDefault(false) + assumeTrue(nodeOk, "node is needed to run terminal.js") + val root = InteropHarness.repoRoot + val script = File(root, "android/app/src/main/assets/terminal/terminal.js") + val driver = File(root, "android/protocol/src/test/interop/terminal-js-driver.cjs") + val proc = ProcessBuilder("node", driver.path, script.path).directory(root).start() + proc.outputStream.bufferedWriter().use { it.write(request.toString()) } + val out = proc.inputStream.bufferedReader().readText() + val err = proc.errorStream.bufferedReader().readText() + assertTrue(proc.waitFor(30, TimeUnit.SECONDS), "driver timed out") + assertEquals(0, proc.exitValue(), "driver failed: $err") + val reply = Json.parseToJsonElement(out.trim()).jsonObject + reply["error"]?.let { fail(it.jsonPrimitive.content) } + return reply + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt index 8c38d5dcf1..90baf5079f 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt @@ -1,7 +1,6 @@ package dev.nodeterm.protocol import dev.nodeterm.protocol.model.Osc52 -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -13,42 +12,27 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray -import org.junit.jupiter.api.Assumptions.assumeTrue -import java.io.File -import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -import kotlin.test.fail /** - * A53: the app's real terminal.js, run in node against stub xterm/bridge objects - * (src/test/interop/terminal-js-driver.cjs). It must apply the size cap it reads from the bridge - * BEFORE a copy crosses it, and otherwise hand over the WHOLE sequence so [Osc52.parse] can apply - * the desktop's rules (the `;` separator, the `?` refusal). Skips without node. + * A53: the app's real terminal.js, run in node against stub xterm/bridge objects ([TerminalJsDriver]). + * It must apply the size cap it reads from the bridge BEFORE a copy crosses it, and otherwise hand + * over the WHOLE sequence so [Osc52.parse] can apply the desktop's rules (the `;` separator, the `?` + * refusal). Skips without node. */ class TerminalJsOsc52Test { private data class Call(val name: String, val length: Int? = null, val sameAsInput: Boolean? = null) private data class Outcome(val returned: Boolean, val calls: List) private fun run(copyLimit: Int, sequences: List): Pair> { - val nodeOk = runCatching { ProcessBuilder("node", "--version").start().waitFor() == 0 }.getOrDefault(false) - assumeTrue(nodeOk, "node is needed to run terminal.js") - val root = InteropHarness.repoRoot - val script = File(root, "android/app/src/main/assets/terminal/terminal.js") - val driver = File(root, "android/protocol/src/test/interop/terminal-js-driver.cjs") - val proc = ProcessBuilder("node", driver.path, script.path).directory(root).start() - val request = buildJsonObject { - put("copyLimit", copyLimit) - putJsonArray("osc52") { sequences.forEach { add(JsonPrimitive(it)) } } - } - proc.outputStream.bufferedWriter().use { it.write(request.toString()) } - val out = proc.inputStream.bufferedReader().readText() - val err = proc.errorStream.bufferedReader().readText() - assertTrue(proc.waitFor(30, TimeUnit.SECONDS), "driver timed out") - assertEquals(0, proc.exitValue(), "driver failed: $err") - val reply = Json.parseToJsonElement(out.trim()).jsonObject - reply["error"]?.let { fail(it.jsonPrimitive.content) } + val reply = TerminalJsDriver.run( + buildJsonObject { + put("copyLimit", copyLimit) + putJsonArray("osc52") { sequences.forEach { add(JsonPrimitive(it)) } } + } + ) val outcomes = reply["osc52"]!!.jsonArray.map { r -> val o = r.jsonObject Outcome( diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt new file mode 100644 index 0000000000..fedb656522 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt @@ -0,0 +1,92 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A46: the key row's ⌨ chip must bring up the soft keyboard for the terminal. It used to run only + * `term.focus()` in the page, which does nothing on the textarea that already has focus (Blink returns + * early), cannot ask Android for the keyboard, and leaves Android's focus (and so the keyboard) with + * the input bar's text field. + * + * The page half runs the app's real terminal.js ([TerminalJsDriver]) against a textarea stub that + * follows Blink's rule. The Android half (Compose focus, the WebView's focus, InputMethodManager) + * cannot run on a JVM, so its order is pinned in the app's source instead; what it does on a device + * has not been checked. + */ +class TerminalKeyboardChipTest { + private data class Focus(val changes: List, val focusedAfter: Boolean) + + private fun focusForKeyboard(textareaFocused: Boolean): Focus { + val reply = TerminalJsDriver.run( + buildJsonObject { + put("copyLimit", 8) + put("textareaFocused", textareaFocused) + putJsonArray("nt") { add(JsonPrimitive("focusForKeyboard")) } + } + ) + val r = reply["nt"]!!.jsonArray.single().jsonObject + return Focus(r["focusChanges"]!!.jsonArray.map { it.jsonPrimitive.content }, r["focusedAfter"]!!.jsonPrimitive.boolean) + } + + @Test + fun `the page's focus really moves onto the textarea when it already had focus`() { + // The normal state once the terminal has been tapped: a bare focus() would change nothing. + assertEquals(Focus(listOf("blur", "focus"), focusedAfter = true), focusForKeyboard(textareaFocused = true)) + } + + @Test + fun `the page's focus moves onto the textarea when it did not have focus`() { + assertEquals(Focus(listOf("focus"), focusedAfter = true), focusForKeyboard(textareaFocused = false)) + } + + private val appSrc = File(InteropHarness.repoRoot, "android/app/src/main/kotlin/dev/nodeterm/android/ui") + + /** The block that follows the first occurrence of [start] in [source], braces balanced. */ + private fun blockAfter(source: String, start: String): String { + val at = source.indexOf(start) + assertTrue(at >= 0, "not found: $start") + val open = source.indexOf('{', at) + var depth = 0 + for (i in open until source.length) { + when (source[i]) { + '{' -> depth++ + '}' -> if (--depth == 0) return source.substring(open, i + 1) + } + } + error("unbalanced block after $start") + } + + private fun assertInOrder(block: String, vararg parts: String) { + var from = 0 + for (p in parts) { + val at = block.indexOf(p, from) + assertTrue(at >= 0, "expected `$p` after `${parts.takeWhile { it != p }.lastOrNull()}` in:\n$block") + from = at + p.length + } + } + + @Test + fun `the chip releases Compose's focus before it asks for the keyboard`() { + val chip = blockAfter(File(appSrc, "TerminalScreen.kt").readText(), "KeyChip(\"⌨\")") + assertInOrder(chip, "focusManager.clearFocus()", "controller.showKeyboard()") + } + + @Test + fun `the WebView takes focus, then the page, then the keyboard is asked for after the focus change`() { + val body = blockAfter(File(appSrc, "TerminalController.kt").readText(), "fun showKeyboard()") + assertInOrder(body, ".requestFocus()", "js(\"nt.focusForKeyboard()\")", ".post {", "showSoftInput(", "InputMethodManager.SHOW_IMPLICIT") + // Only the deferred call: a synchronous request right after requestFocus races the focus change. + assertEquals(1, Regex("""showSoftInput\(""").findAll(body).count()) + } +} diff --git a/docs/android.md b/docs/android.md index 5c734046b9..03dbf567be 100644 --- a/docs/android.md +++ b/docs/android.md @@ -175,6 +175,15 @@ reported its size. A crash is not, because the reattach would repaint the same s offers "Reopen terminal" instead. More than two kills within a minute fall back to that offer too. The WebView handling itself is only type-checked. +`TerminalKeyboardChipTest` covers the key row's ⌨ chip (`A46`), which used to leave the soft keyboard +down: it only called `focus()` in the page. The chip now releases the input bar's focus, gives the +WebView Android's focus, moves the page's focus onto xterm's textarea (blurring it first, because +Blink ignores `focus()` on the element that already has focus, which is the usual state after a tap), +and then asks `InputMethodManager` for the keyboard, after the next frame, so the focus change has +been processed first. The page half runs the real `terminal.js` in node against a textarea stub that +follows Blink's rule. The Android half cannot run on a JVM, so the test pins its order in the source. +Whether the keyboard comes up, and stays up, is a device check. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 6966f25f3321f1dde94272f6bf5616425ff47631 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:34:56 +0000 Subject: [PATCH 054/284] fix(android): offer to wake a Sleeping session opened over direct SSH (A76) Opening an Eco-hibernated (Sleeping) session over direct SSH landed on the bare shell Eco left behind with nothing offered: the phone's resume banner fired only when the attach reported `fresh`, which an SSH attach never is, and nothing tells the desktop about an SSH attach (unlike the relay, whose attach fires `agent:wake`). The decision now lives in a pure `ResumeOffer.afterAttach` in android/protocol, which TerminalController delegates to: - fresh (a reboot; only the relay creates a session): the cold-restore resume line as before (cd, managed account, permission mode; A15/A16); - Sleeping (mirror `hibernated`) over SSH: a "Wake " offer carrying the desktop's own wake line, `Launch.wakeLine` = the agent's resume plus the permission mode and no cd or CLAUDE_CONFIG_DIR, since the pane's shell is the one the CLI exited back to and its tmux env carries the account; - Sleeping over the relay: nothing, because the attach already asked the desktop to wake it and a second `--resume` would land in that CLI as a prompt. It stays an offer the user taps. Accepting a wake writes Ctrl-U first (the desktop's KILL_LINE, so a half-typed line at the prompt is not spliced in) and re-checks the current snapshot: a node no longer Sleeping gets a notice and nothing is typed. The offer is also only installed for the attach the screen still shows (the slot ticket). `paused` is not in the mirror; a shallow pause reads as Sleeping and gets only the tapped offer, which is the explicit Resume the desktop's PAUSED chip gives. A deep pause leaves `hibernated` unset and gets nothing. Verified: ResumeOfferTest (fresh, Sleeping over SSH, Sleeping over relay, paused shallow/deep, fallback ids, unsafe id, withdrawal at the tap, the wake line) and a SshTransportTest case that attaches to the real sandboxed tmux with a Sleeping mirror entry, types the offer after a half-typed line, and sees a stand-in CLI start in the node's folder with the right args. Mutation-checked: dropping the wake branch, the kill-line, the relay exclusion, the tap re-check, or building the wake like the cold restore each fails at least one test. Full protocol suite and the offline app type-check pass. Not run on a device. Departures from the audit's proposal: no cwd/account (per the verifier), plus the kill-line and the tap-time re-check it did not ask for. docs/android.md gains the protocol row, the test description and a known gap: through the relay a Sleeping node the desktop has not mounted stays asleep (the desktop's wake no-ops there) and the phone cannot tell. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 1 + .../nodeterm/android/ui/TerminalController.kt | 43 ++--- .../dev/nodeterm/android/ui/TerminalScreen.kt | 8 +- .../dev/nodeterm/protocol/host/ResumeOffer.kt | 99 ++++++++++++ .../dev/nodeterm/protocol/model/Agents.kt | 14 ++ .../dev/nodeterm/protocol/ResumeOfferTest.kt | 147 ++++++++++++++++++ .../dev/nodeterm/protocol/SshTransportTest.kt | 47 ++++++ docs/android.md | 19 +++ 8 files changed, 354 insertions(+), 24 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt diff --git a/android/README.md b/android/README.md index 3f24cbb7fa..e636032ccf 100644 --- a/android/README.md +++ b/android/README.md @@ -23,6 +23,7 @@ it is talking to. | Late relay adoption (paired while remote access was off) | ✓ | Reads `~/.nodeterm/relay.json` over SSH, mints its own device token | | Sessions, grouped like the desktop sidebar | ✓ | Needs you / Running / Sleeping, activity + context % | | Terminal (co-attach to the live tmux session) | ✓ | xterm.js renderer, native input bar, special keys, swipe = tmux scroll, OSC 52 copy | +| Sleeping (Eco) session opened | ✓ | Through the relay the computer wakes it on open. Over SSH the phone offers the desktop's wake line (`--resume ` + permission mode), typed only on a tap | | Cold-start resume offer (the computer rebooted) | relay | Offers the agent's own `--resume `; never types it unasked. Over SSH a session that is not running is never created (it would lack its hook environment): the phone offers to open it through the relay | | Sessions of the computer's SSH projects | relay | They run on another host; the computer attaches them over its SSH connection. Over direct SSH the phone offers the relay instead | | New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches | diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index f896695a82..c324ee969a 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -32,6 +32,7 @@ import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.RendererRecovery +import dev.nodeterm.protocol.host.ResumeOffer import dev.nodeterm.protocol.host.NewNode import dev.nodeterm.protocol.host.NewSessionHint import dev.nodeterm.protocol.host.PhoneLaunch @@ -40,12 +41,10 @@ import dev.nodeterm.protocol.host.TerminalPage import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TerminalStream import dev.nodeterm.protocol.host.ViewerSlot -import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.InputBar import dev.nodeterm.protocol.model.Keys -import dev.nodeterm.protocol.model.Launch import dev.nodeterm.protocol.model.Osc52 import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -88,8 +87,12 @@ class TerminalController( */ val attached: Boolean get() = state == TermState.Attached - /** Offered after a COLD attach of an agent node: its resume line (the desktop's cold restore). */ - var resumeOffer by mutableStateOf?>(null) + /** + * Offered after an attach of an agent node: its resume line after a COLD attach (the desktop's + * cold restore), or its wake line when the session is Sleeping over direct SSH (audit A76). + * Typed only when the user taps it. See [ResumeOffer]. + */ + var resumeOffer by mutableStateOf(null) private set /** A desktop viewer sized the shared pty differently from this screen. */ var sizedElsewhere by mutableStateOf?>(null) @@ -482,7 +485,7 @@ class TerminalController( state = TermState.Attached if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) } - if (launch == null && slot.isCurrent(ticket)) afterAttach(s, conn) + if (launch == null && slot.isCurrent(ticket)) afterAttach(s, conn, ticket) } catch (e: NeedsRelayException) { val msg = e.message ?: "This session opens through the relay." main.post { @@ -518,22 +521,16 @@ class TerminalController( } } - private suspend fun afterAttach(s: TerminalStream, conn: HostConnection) { + private suspend fun afterAttach(s: TerminalStream, conn: HostConnection, ticket: Long) { val snap = session.snapshot.value - val node = snap.findNode(nodeId)?.second val status = snap.statusOf(nodeId) - if (s.fresh) { - // The computer's tmux session was gone (a reboot): the conversation is on disk, not in the - // pane. Offer the agent's own resume — never type it unasked into a pane we cannot see. - // Built like the desktop's cold restore: the node's directory, its managed account, the - // project's permission mode (audit A15/A16) — a relay-created pane starts in $HOME. - val agent = Agent.of(node?.agentId ?: status?.agentId) - val sid = status?.sessionId ?: node?.agentSessionId - val project = snap.findNode(nodeId)?.first - if (agent != null && sid != null) { - Launch.resumeLine(agent, sid, snap.status?.settings, node?.accountId, project?.absoluteCwdOf(node), project?.defaultPermissionMode) - ?.let { cmd -> main.post { resumeOffer = agent.label to cmd } } - } + // A cold pane (a reboot) gets the agent's own resume, built like the desktop's cold restore + // (A15/A16); a Sleeping one over direct SSH gets the desktop's wake line, since nothing tells + // the desktop about an SSH attach (A76). Over the relay the attach itself asks the desktop to + // wake it, so nothing is offered there. Never typed unasked into a pane we cannot see. + ResumeOffer.afterAttach(s.fresh, conn.kind, snap, nodeId)?.let { offer -> + // Only for the attach the screen still shows: a newer one cleared the offer when it began. + main.post { if (slot.isCurrent(ticket)) resumeOffer = offer } } // Reading a finished session on the phone is a READ: tell the computer (unread clears there, // other phones archive the card), exactly what the SSH read-ack file does. @@ -551,7 +548,13 @@ class TerminalController( // Not attached (the stream dropped under the offer): keep it rather than spend it on nothing. if (!attached) return resumeOffer = null - stream?.write(offer.second + "\r") + // Re-asked at the tap (A76): the desktop may have woken the session since the offer appeared, + // and a wake line typed into that CLI would arrive as a prompt. + if (!offer.stillOffered(session.snapshot.value, nodeId)) { + notice = "This session is no longer sleeping on the computer, so nothing was typed." + return + } + stream?.write(offer.keys) } fun dismissResume() { diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 2104d26d58..8a9e91d276 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -137,15 +137,15 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String } TermState.Attached -> Unit } - controller.resumeOffer?.let { (agent, cmd) -> + controller.resumeOffer?.let { offer -> Column( Modifier.align(Alignment.TopCenter).fillMaxWidth().background(NtColors.panel2).padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Text("This session had ended on the computer (it restarted). Resume the $agent conversation?") - Text(cmd, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + Text(offer.message) + Text(offer.command, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { controller.acceptResume() }, enabled = controller.attached) { Text("Resume") } + Button(onClick = { controller.acceptResume() }, enabled = controller.attached) { Text(offer.button) } OutlinedButton(onClick = { controller.dismissResume() }) { Text("Not now") } } } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt new file mode 100644 index 0000000000..efc9ba1d65 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt @@ -0,0 +1,99 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.ProjectsSnapshot + +/** + * The agent line the terminal screen OFFERS right after an attach. It is typed only when the user + * taps it, never unasked into a pane the phone cannot see. There are two cases, and they build + * different lines on purpose: + * + * - [Kind.RESUME] (audit A15): the attach had to CREATE the tmux session, because the computer + * rebooted. The conversation is on disk and the pane is a new shell (in `$HOME` when the relay + * created it), so the line is built like the desktop's cold restore: `cd` into the node's folder, + * the node's managed Claude account, the permission mode ([Launch.resumeLine]). + * - [Kind.WAKE] (audit A76): the session is Sleeping. Eco (or a shallow "Pause session") exited the + * CLI and left the pane's shell behind. That shell already sits in the node's folder, and its tmux + * env already carries the account's CLAUDE_CONFIG_DIR / CODEX_HOME, so the line is what the + * desktop's own wake types: the bare resume plus the permission mode ([Launch.wakeLine]), after a + * kill-line so a half-typed line left at that prompt is not spliced into it. A `cd` or an account + * prefix could only be wrong there: the pane's shell and env are the authority, not project.json. + * + * A wake is offered over DIRECT SSH only. A relay attach tells the desktop that a phone opened the + * node (host-service `remoteViewer.attached` → `agent:wake`), and the desktop wakes it itself, after + * checking that the pane is still the one its CLI exited from. Offering the line on the phone as well + * would type `--resume` into the CLI the desktop just started, where it arrives as a prompt. (The + * desktop's wake does nothing for a node it has not mounted, a project other than the active one, + * and the phone cannot tell that case apart: docs/android.md "Known gaps".) Over SSH nothing tells + * the desktop about the attach, which is why the phone offers it there. + * + * `paused`: the desktop never wakes a paused node on its own, but its own UI offers the explicit + * Resume (the PAUSED chip, the node menu). The mirror carries `hibernated` and not `paused`, so a + * shallow-paused node reads as Sleeping here too; an offer the user taps is that explicit Resume, + * never an automatic one. A deep pause ("pause & end session") recycles the tmux session and leaves + * `hibernated` unset, so it gets no wake offer. + */ +data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String) { + enum class Kind { RESUME, WAKE } + + /** What accepting the offer writes into the pane. */ + val keys: String + get() = when (kind) { + Kind.RESUME -> command + "\r" + // The desktop's wake clears the line first (TerminalNode's wake closure, KILL_LINE in + // src/shared/shell-kill-line.ts). The host is POSIX: a Windows desktop pairs relay-only. + Kind.WAKE -> KILL_LINE + command + "\r" + } + + val message: String + get() = when (kind) { + Kind.RESUME -> "This session had ended on the computer (it restarted). Resume the ${agent.label} conversation?" + Kind.WAKE -> "This session is sleeping: ${agent.label} was closed on the computer to save memory. Wake the conversation?" + } + + val button: String + get() = when (kind) { + Kind.RESUME -> "Resume" + Kind.WAKE -> "Wake ${agent.label}" + } + + /** + * Re-asked when the user taps the offer, against the snapshot at that moment (the same rule as + * the Inbox quick actions: act only on what is still true). A wake is withdrawn once the node is + * no longer Sleeping: the desktop resumed it meanwhile, and a resume line typed now would land + * in that CLI's composer as a prompt. + */ + fun stillOffered(current: ProjectsSnapshot, nodeId: String): Boolean = when (kind) { + Kind.RESUME -> true + Kind.WAKE -> current.statusOf(nodeId)?.hibernated == true + } + + companion object { + /** `KILL_LINE` (src/shared/shell-kill-line.ts): Ctrl-U, the POSIX shells' line discard. */ + const val KILL_LINE = "\u0015" + + /** + * The offer after an attach of [nodeId] over [transport], or null. [fresh] is the attach's + * own answer ([TerminalStream.fresh]); [snapshot] is the phone's latest view of the computer. + */ + fun afterAttach(fresh: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String): ResumeOffer? { + val found = snapshot.findNode(nodeId) + val project = found?.first + val node = found?.second + val status = snapshot.statusOf(nodeId) + val agent = Agent.of(node?.agentId ?: status?.agentId) ?: return null + val sid = status?.sessionId ?: node?.agentSessionId ?: return null + val settings = snapshot.status?.settings + return when { + // A cold pane wins over a stale Sleeping flag: the shell the CLI exited to is gone + // with the old tmux session, so the new one needs the full cold-restore line. + fresh -> Launch.resumeLine(agent, sid, settings, node?.accountId, project?.absoluteCwdOf(node), project?.defaultPermissionMode) + ?.let { ResumeOffer(Kind.RESUME, agent, it) } + status?.hibernated == true && transport == TransportKind.SSH -> + Launch.wakeLine(agent, sid, settings, project?.defaultPermissionMode)?.let { ResumeOffer(Kind.WAKE, agent, it) } + else -> null + } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt index 1c420489ed..31c80859f1 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt @@ -93,6 +93,20 @@ object Launch { projectMode: String? = null ): String? = resumeCommand(agent, sessionId)?.let { compose(agent, it, settings, accountId, cwd, projectMode) } + /** + * The line that wakes a Sleeping (Eco-hibernated) session, as the desktop's own wake types it + * (TerminalNode's wake closure: the resume with the permission mode re-resolved, audit A76). It + * carries no `cd` and no account: the pane's shell is the one the CLI exited back to, so it already + * sits in the node's directory and its tmux env already names the account's config dir. Null + * when the agent or id cannot be resumed. + */ + fun wakeLine( + agent: Agent, + sessionId: String, + settings: MirrorSettings?, + projectMode: String? = null + ): String? = resumeCommand(agent, sessionId)?.let { compose(agent, it, settings, null, null, projectMode) } + private fun compose( agent: Agent, command: String, diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt new file mode 100644 index 0000000000..587ef713ce --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt @@ -0,0 +1,147 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ResumeOffer +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.ManagedAccount +import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A76 (and A15): what the terminal screen offers to type after an attach. The screen + * (TerminalController) is only type-checked; these pin the rule it delegates to. + */ +class ResumeOfferTest { + private val account = "/Users/me/Library/Application Support/node-terminal/claude-accounts/acct-1" + + /** + * The `projects.list` blob as the desktop serves it: one project with a managed-account Claude + * node in a subfolder, and a mirror entry [entry] for that node (the mirror's own JSON). + */ + private fun snapshot(entry: String?): ProjectsSnapshot = ProjectsParser.parseBlob( + """{"version":2,"projects":[{"id":"p1","name":"Repo","cwd":"/repo","defaultPermissionMode":"plan", + "nodes":[{"id":"n1","kind":"terminal","title":"Claude","agentId":"claude","accountId":"acct-1", + "cwd":"./sub","agentSessionId":"minted-1"}]}]} + ${ProjectsParser.PROJECTS_MARK} + nt-n1 + ${ProjectsParser.STATUS_MARK} + {"v":1,"updatedAt":1,"nodes":{${entry?.let { "\"n1\":$it" } ?: ""}}, + "settings":{"claudePermissionMode":"acceptEdits","autoSupported":true, + "claudeAccounts":[{"id":"acct-1","dir":"$account"}]}}""" + ) + + /** A node Eco put to sleep: the CLI exited (no live state), the flag carries it. */ + private val sleeping = """{"agentId":"claude","sessionId":"sid-7","hibernated":true,"updatedAt":5}""" + private val awake = """{"state":"done","agentId":"claude","sessionId":"sid-7","updatedAt":5}""" + + @Test + fun `a cold attach offers the cold-restore resume line (A15)`() { + // Only the relay creates a session (an SSH attach is never fresh); the rule itself does not + // look at the transport. + for (transport in TransportKind.entries) { + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = true, transport, snapshot(awake), "n1")) + assertEquals(ResumeOffer.Kind.RESUME, offer.kind) + assertEquals( + "cd '/repo/sub' && CLAUDE_CONFIG_DIR='$account' claude --resume sid-7 --permission-mode plan", + offer.command + ) + assertEquals(offer.command + "\r", offer.keys, "a pane created just now has no half-typed line to clear") + assertEquals("Resume", offer.button) + } + } + + @Test + fun `a Sleeping session opened over SSH offers the desktop's wake line (A76)`() { + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1")) + assertEquals(ResumeOffer.Kind.WAKE, offer.kind) + // No cd and no account prefix, although the node has both: the pane's shell is the one the CLI + // exited back to, already in the node's folder and under the account's env. + assertEquals("claude --resume sid-7 --permission-mode plan", offer.command) + assertEquals("\u0015claude --resume sid-7 --permission-mode plan\r", offer.keys, "the prompt's line is cleared first") + assertEquals("Wake Claude Code", offer.button) + assertTrue("sleeping" in offer.message) + } + + @Test + fun `a Sleeping session opened through the relay offers nothing, since the desktop wakes it on attach`() { + // host-service reports the attach (`remoteViewer.attached` → `agent:wake`); an offer as well + // would type `--resume` into the CLI the desktop just started. + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1")) + } + + @Test + fun `a warm attach of an awake session offers nothing, on either transport`() { + for (transport in TransportKind.entries) { + assertNull(ResumeOffer.afterAttach(fresh = false, transport, snapshot(awake), "n1")) + assertNull(ResumeOffer.afterAttach(fresh = false, transport, snapshot(null), "n1")) + } + } + + @Test + fun `a cold pane wins over a stale Sleeping flag`() { + // The shell the CLI exited to went with the old tmux session; the new pane needs the full line. + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(sleeping), "n1")) + assertEquals(ResumeOffer.Kind.RESUME, offer.kind) + assertTrue(offer.command.startsWith("cd '/repo/sub' && ")) + } + + @Test + fun `a paused node is only ever offered, and a deep pause offers nothing`() { + // A shallow "Pause session" is an Eco exit plus `paused`, and the mirror carries only the + // `hibernated` half: the phone sees Sleeping. The desktop never wakes a paused node on its own, + // but its PAUSED chip is an explicit Resume, and a tapped offer is that same explicit Resume. + val shallow = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1")) + assertEquals(ResumeOffer.Kind.WAKE, shallow.kind) + // "Pause & end session" recycles the tmux session and leaves `hibernated` unset: no offer. + val deep = """{"agentId":"claude","sessionId":"sid-7","updatedAt":5}""" + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(deep), "n1")) + } + + @Test + fun `a Sleeping entry with no agent or session of its own falls back to the node's`() { + // `setNodeHibernated` can create an entry that carries only the flag. + val bare = """{"hibernated":true,"updatedAt":5}""" + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(bare), "n1")) + assertEquals("claude --resume minted-1 --permission-mode plan", offer.command) + } + + @Test + fun `no offer for an id that cannot reach a command line, or an agent the phone cannot resume`() { + val unsafe = """{"agentId":"claude","sessionId":"x; rm -rf ~","hibernated":true,"updatedAt":5}""" + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(unsafe), "n1")) + val custom = ProjectsParser.parseBlob( + """{"version":2,"projects":[{"id":"p1","name":"R","nodes":[{"id":"c1","agentId":"custom:abc","agentSessionId":"s"}]}]} + ${ProjectsParser.STATUS_MARK} + {"nodes":{"c1":{"hibernated":true,"updatedAt":1}}}""" + ) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, custom, "c1")) + } + + @Test + fun `a wake is withdrawn at the tap once the node is no longer Sleeping`() { + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1")) + assertTrue(offer.stillOffered(snapshot(sleeping), "n1")) + // The desktop resumed it meanwhile (its SessionStart clears the flag). + assertFalse(offer.stillOffered(snapshot(awake), "n1")) + val resume = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) + assertTrue(resume.stillOffered(snapshot(awake), "n1"), "a cold pane's resume does not depend on the flag") + } + + @Test + fun `the wake line is the resume with the permission mode and nothing else`() { + val s = MirrorSettings("auto", autoSupported = false, claudeAccounts = listOf(ManagedAccount("a1", account)), codexApprovalValues = emptyList()) + assertEquals("claude --resume abc", Launch.wakeLine(Agent.CLAUDE, "abc", s), "auto on an old claude degrades to no flag") + assertEquals("claude --resume abc --permission-mode auto", Launch.wakeLine(Agent.CLAUDE, "abc", s.copy(autoSupported = true))) + assertEquals("claude --resume abc --permission-mode plan", Launch.wakeLine(Agent.CLAUDE, "abc", s, "plan")) + assertEquals("codex resume abc", Launch.wakeLine(Agent.CODEX, "abc", s, "plan"), "only claude gets the flag") + assertNull(Launch.wakeLine(Agent.CLAUDE, "-flag", s)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index e5796edf31..b3ffa293ab 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -3,6 +3,7 @@ package dev.nodeterm.protocol import dev.nodeterm.protocol.host.ApprovalOutcome import dev.nodeterm.protocol.host.HostException import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.ResumeOffer import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxEvent @@ -33,6 +34,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -401,6 +403,51 @@ class SshTransportTest { } } + @Test + fun `a Sleeping session opened over SSH offers its wake line, which a tap types at the pane's own prompt (A76)`() = runBlocking { + // Eco exited the CLI: term-a-1's pane is a shell again, still in the node's folder, and the + // mirror says Sleeping. Nothing tells the desktop about an SSH attach, so the phone offers it. + val status = File(home, ".config/node-terminal/agent-status.json") + val before = status.readText() + // A stand-in CLI that reports where it ran and with what. + val bin = File(root, "fake-bin").apply { mkdirs() } + File(bin, "claude").apply { + writeText("#!/bin/sh\necho \"woke:\$(pwd -P):\$*\"\n") + setExecutable(true) + } + status.writeText( + """{"v":1,"updatedAt":1,"nodes":{"term-a-1":{"agentId":"claude","sessionId":"sid-7","hibernated":true,"updatedAt":5}}, + "settings":{"claudePermissionMode":"plan","claudeAccounts":[]},"inbox":{"events":[],"nodes":{}}}""" + ) + try { + connect().use { conn -> + val snap = conn.listProjects() + val stream = conn.attach("term-a-1", 100, 30, Sink()) + val offer = assertNotNull(ResumeOffer.afterAttach(stream.fresh, conn.kind, snap, "term-a-1")) + assertEquals(ResumeOffer.Kind.WAKE, offer.kind) + assertEquals("claude --resume sid-7 --permission-mode plan", offer.command, "no cd: the pane is already there") + Thread.sleep(400) + stream.write("PATH='${bin.path}':\$PATH; export PATH\r") + // A line someone left half-typed at the Sleeping prompt: the wake clears it first, + // or the shell would run `echo half_typedclaude …` and the CLI would never start. + stream.write("echo half_typed") + stream.write(offer.keys) + val want = "woke:${File(repo, "sub").canonicalPath}:--resume sid-7 --permission-mode plan" + val end = System.currentTimeMillis() + 8_000 + var pane = "" + while (System.currentTimeMillis() < end) { + pane = tmux("capture-pane", "-p", "-J", "-t", "=nt-term-a-1:").second + if (pane.contains(want)) break + Thread.sleep(100) + } + assertTrue(pane.contains(want), pane) + stream.detach() + } + } finally { + status.writeText(before) + } + } + @Test fun `the attach script itself refuses to create a missing session`() { // Belt and braces for the race where the session ends between the check and the attach. diff --git a/docs/android.md b/docs/android.md index 03dbf567be..d0a14b63a2 100644 --- a/docs/android.md +++ b/docs/android.md @@ -62,6 +62,7 @@ describes as the future. The Android client implements what the host actually se | Type / resize | `OP.Input` / `OP.Resize` frames | channel stdin / window-change | | Scroll | `pty.scroll` (host writes SGR wheel events) | the phone writes the same SGR wheel events | | Detach / end | `pty.kill` / `pty.destroy` | close channel / `kill-session` | +| Wake on open | the attach itself: host-service reports the viewer (`remoteViewer.attached` → `agent:wake`) and the desktop wakes a Sleeping node it has mounted; the phone offers nothing, so it never types a second `--resume` | nothing reaches the desktop, so opening a Sleeping node offers the desktop's wake line (the agent's `--resume ` plus the permission mode, no `cd` or account: the pane's shell already has both), typed only on a tap, after a kill-line | | Wake, refresh, rename | `node.wake|refresh|rename` | — (needs the desktop app) | | Board | `projects.ensureBoard|setCardColumn|editCardLabels` | read-only | | New session | `pty.attach` of a fresh `term-…` id, launch line, then `projects.registerNode` | — | @@ -175,6 +176,19 @@ reported its size. A crash is not, because the reattach would repaint the same s offers "Reopen terminal" instead. More than two kills within a minute fall back to that offer too. The WebView handling itself is only type-checked. +`ResumeOfferTest` pins what the terminal screen offers to type after an attach (`A15`, `A76`). A cold +attach (the computer rebooted; only the relay creates a session) gets the cold-restore line: `cd` into +the node's folder, its managed account, the permission mode. A Sleeping (Eco-hibernated) node opened over direct +SSH gets the desktop's own wake line instead, with no `cd` and no account prefix, since the pane's +shell is the one the CLI exited back to; accepting it clears the prompt's line first (Ctrl-U, the +desktop's kill-line) and re-checks that the node is still Sleeping. Through the relay a Sleeping +node gets no offer: the attach already asked the desktop to wake it. A shallow "Pause session" is +Sleeping in the mirror (it carries no `paused`), so it too gets only the offer, which is the explicit +Resume the desktop's PAUSED chip is; a deep pause leaves no Sleeping flag and gets nothing. +`SshTransportTest` runs the offer end to end against the real tmux: the line it types starts the +stand-in CLI in the node's own folder even with a half-typed line left at the prompt. The banner +itself is only type-checked. + `TerminalKeyboardChipTest` covers the key row's ⌨ chip (`A46`), which used to leave the soft keyboard down: it only called `focus()` in the page. The chip now releases the input bar's focus, gives the WebView Android's focus, moves the page's focus onto xterm's textarea (blurring it first, because @@ -212,6 +226,11 @@ Whether the keyboard comes up, and stays up, is a device check. from the request's `permission_suggestions` (both fields exist in that CLI's hook schema). That changes the `~/.nodeterm/pending` answer file and `approvals.answer`, so it needs the desktop, iOS and Android together. +- **A Sleeping node the desktop has not mounted stays asleep through the relay** (`A76`). The relay + attach's wake is the desktop's `wakeHibernatedNode` nudge, which does nothing for a node that is + not on screen (a project other than the active one), and the phone offers no wake there because + it cannot tell the two apart: a second `--resume` typed into a CLI the desktop just woke arrives + as a prompt. Typing the resume by hand, or opening the node over direct SSH, works. - **Codex/Gemini/… launch flags.** A phone-started non-Claude agent launches bare (its own default approval mode): the per-agent approval table needs host facts (codex's vocabulary moved between releases, #785) the mirror only partly publishes. From 2be5e8759d555a6aa27364d997395ad8f9e403d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:45:27 +0000 Subject: [PATCH 055/284] fix(android): keep the Host screen's tab, scroll and Board project across a terminal (A43) AppContent composed only the top back-stack entry and had no SaveableStateHolder, so a screen's rememberSaveable state died the moment another was pushed on it. Back from a terminal, the Host screen was on its first tab, scrolled to the top, and the Board was on the first project. - protocol: BackStack, a value back stack whose entries each carry a key unique to the ENTRY (a random UUID; never reused while it still names saved state), not derived from the route, which can repeat on the stack. pop/replaceAll/retain RETIRE the keys of entries that leave; retain keeps surviving entries under their own keys. encode/decode save the keys and the retired list with the stack (A22's Saver), drop an entry that no longer decodes (the single-use pairing code) and retire its key, read the A22 format (a bare array of routes) with new keys, and refuse a corrupted save's duplicate key. - Navigator is now an observable holder of a BackStack; the forgotten- host filter uses retain() instead of clearing the list (so kept entries keep their state). AppContent wraps the top entry in rememberSaveableStateHolder().SaveableStateProvider(entry.key) and, in a SideEffect, calls removeState() for every retired key. The old key(r) around HostScreen is subsumed by the per-entry key (a notification's Host entry is a new entry, so it still starts on its own tab). - BoardTab's selected projectId and InboxTab's showArchive are now rememberSaveable (they were plain remember, so the holder alone would not have kept them). - HostScreen also wraps the tab content in its own SaveableStateHolder keyed by tab, so a tab's scroll/project/archive state also survives switching to another tab and back. Verified: BackStackTest (12 tests) passes; mutation checks done: pop not retiring, decode ignoring saved keys, keys derived from the route, and retired keys being reusable each fail 1-3 of its tests. Full protocol suite (gradle test --offline --rerun, 202 tests) and the offline app type-check (gradle compileKotlin --offline) pass; SaveableStateHolder is the real runtime-saveable artifact there, no stub was needed. The Compose wiring was also exercised in a throwaway headless composition (Compose Multiplatform runtime 1.5.12 desktop, not committed): back from a pushed entry the saved tab comes back, the popped entry's state is not saved, and both survive a save/restore; without the holder the tab resets, without the removeState drain the popped state leaks into the saved bundle. The app screens are only type-checked; nothing has run on a device. Departures from the audit: the key bookkeeping moved into a pure protocol class so it is testable; retired keys are persisted with the stack so an entry that left just before process death is still cleaned up; the per-tab holder in HostScreen goes beyond the finding (tab switches, not only a terminal round trip). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/MainActivity.kt | 126 ++++++++------- .../dev/nodeterm/android/ui/BoardTab.kt | 4 +- .../dev/nodeterm/android/ui/HostScreen.kt | 18 ++- .../dev/nodeterm/android/ui/InboxTab.kt | 5 +- .../dev/nodeterm/protocol/model/BackStack.kt | 141 +++++++++++++++++ .../dev/nodeterm/protocol/BackStackTest.kt | 148 ++++++++++++++++++ 6 files changed, 379 insertions(+), 63 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/BackStack.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackStackTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt index f2f0ab3ce6..19b7ff7bd0 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -12,16 +12,12 @@ import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonPrimitive +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.core.content.ContextCompat import dev.nodeterm.android.ui.HostScreen import dev.nodeterm.android.ui.HostsScreen @@ -29,6 +25,7 @@ import dev.nodeterm.android.ui.NodetermTheme import dev.nodeterm.android.ui.PairScreen import dev.nodeterm.android.ui.SettingsScreen import dev.nodeterm.android.ui.TerminalScreen +import dev.nodeterm.protocol.model.BackStack /** The screens. A plain back stack: five destinations, one deep link (the pairing URL). */ sealed interface Route { @@ -39,62 +36,75 @@ sealed interface Route { data class Terminal(val hostId: String, val nodeId: String, val title: String) : Route } -class Navigator(initial: List) { - constructor(initial: Route) : this(listOf(initial)) +/** + * The back stack. Each entry has its own key (audit A43): AppContent files the entry's saved UI state + * (the Host screen's tab, scroll positions, the Board's project, the Inbox's archive toggle) under it, + * so the screen below a terminal comes back as it was left. The rules live in [BackStack] (protocol, + * tested); this is its observable holder. Used from the main thread only. + */ +class Navigator(initial: BackStack) { + constructor(initial: Route) : this(BackStack.of(listOf(initial), Route.Hosts)) - val stack = mutableStateListOf().apply { addAll(initial.ifEmpty { listOf(Route.Hosts) }) } - val current: Route get() = stack.last() + private var backStack by mutableStateOf(initial) + + val size: Int get() = backStack.size + + /** The showing entry: its route, and the key its saved UI state is filed under. */ + val top: BackStack.Entry get() = backStack.entries.last() fun push(route: Route) { - stack.add(route) + backStack = backStack.push(route) } fun pop(): Boolean { - if (stack.size <= 1) return false - stack.removeAt(stack.lastIndex) + backStack = backStack.pop() ?: return false return true } fun replaceAll(route: Route) { - stack.clear() - stack.add(route) + backStack = backStack.replaceAll(route) + } + + /** Keeps the entries [keep] accepts, each with its saved state; the computers list when none is left. */ + fun retain(keep: (Route) -> Boolean) { + backStack = backStack.retain(Route.Hosts, keep = keep) + } + + /** The keys of the entries that left the stack since the last call. Their saved state is to be dropped. */ + fun takeRetired(): List { + val keys = backStack.retired + if (keys.isNotEmpty()) backStack = backStack.withoutRetired() + return keys } companion object { /** * Saves the back stack across activity recreation (a density, font-scale or locale change, * or process death) — it used to reset to the computers list (audit A22). Each route is a - * JSON array of strings; an entry that no longer decodes is dropped, never guessed. + * list of strings, saved with its entry's key (audit A43); an entry that no longer decodes is + * dropped, never guessed. */ val Saver: Saver = Saver( - save = { nav -> JsonArray(nav.stack.map { encode(it) }).toString() }, - restore = { raw -> - val routes = runCatching { (Json.parseToJsonElement(raw) as JsonArray).mapNotNull { decode(it) } }.getOrNull() - Navigator(routes ?: listOf(Route.Hosts)) - } + save = { nav -> nav.backStack.encode(::encode) }, + restore = { raw -> Navigator(BackStack.decode(raw, Route.Hosts, route = ::decode)) } ) - private fun encode(r: Route): JsonArray = JsonArray( - when (r) { - Route.Hosts -> listOf("hosts") - is Route.PairHost -> listOfNotNull("pair", r.code) - Route.Settings -> listOf("settings") - is Route.Host -> listOf("host", r.hostId, r.tab.toString()) - is Route.Terminal -> listOf("terminal", r.hostId, r.nodeId, r.title) - }.map { JsonPrimitive(it) } - ) + private fun encode(r: Route): List = when (r) { + Route.Hosts -> listOf("hosts") + is Route.PairHost -> listOfNotNull("pair", r.code) + Route.Settings -> listOf("settings") + is Route.Host -> listOf("host", r.hostId, r.tab.toString()) + is Route.Terminal -> listOf("terminal", r.hostId, r.nodeId, r.title) + } - private fun decode(e: JsonElement): Route? { - val parts = (e as? JsonArray)?.map { (it as? JsonPrimitive)?.content ?: return null } ?: return null - return when (parts.firstOrNull()) { - "hosts" -> Route.Hosts - // A pairing code is single-use: coming back to it would only fail. Drop it. - "pair" -> null - "settings" -> Route.Settings - "host" -> parts.getOrNull(1)?.let { Route.Host(it, parts.getOrNull(2)?.toIntOrNull() ?: 0) } - "terminal" -> if (parts.size == 4) Route.Terminal(parts[1], parts[2], parts[3]) else null - else -> null - } + private fun decode(parts: List): Route? = when (parts.firstOrNull()) { + "hosts" -> Route.Hosts + // A pairing code is single-use: coming back to it would only fail. Drop it. + "pair" -> null + "settings" -> Route.Settings + "host" -> parts.getOrNull(1)?.let { Route.Host(it, parts.getOrNull(2)?.toIntOrNull() ?: 0) } + "terminal" -> if (parts.size == 4) Route.Terminal(parts[1], parts[2], parts[3]) else null + else -> null } } } @@ -154,17 +164,13 @@ class MainActivity : ComponentActivity() { } // A restored stack may name a computer that was forgotten meanwhile. LaunchedEffect(Unit) { - val known = nav.stack.filter { r -> + nav.retain { r -> when (r) { is Route.Host -> graph.hosts.get(r.hostId) != null is Route.Terminal -> graph.hosts.get(r.hostId) != null else -> true } } - if (known.size != nav.stack.size) { - nav.stack.clear() - nav.stack.addAll(known.ifEmpty { listOf(Route.Hosts) }) - } } val hostTap = incomingHost LaunchedEffect(hostTap) { @@ -195,14 +201,24 @@ class MainActivity : ComponentActivity() { @Composable private fun AppContent(nav: Navigator) { - BackHandler(enabled = nav.stack.size > 1) { nav.pop() } - when (val r = nav.current) { - Route.Hosts -> HostsScreen(nav) - is Route.PairHost -> PairScreen(nav, r.code) - Route.Settings -> SettingsScreen(nav) - // Keyed on the route: a Host screen for another computer, or for the same one opened on a - // different tab (a notification → Inbox), must not inherit the showing screen's saved state. - is Route.Host -> key(r) { HostScreen(nav, r.hostId, r.tab) } - is Route.Terminal -> TerminalScreen(nav, r.hostId, r.nodeId, r.title) + // Only the top entry is composed, so without a holder a screen's saved state died the moment + // another was pushed on it: back from a terminal, the Host screen was on its first tab, scrolled + // to the top, on the Board's first project (audit A43). Each entry's state is filed under its own + // key, which the saved back stack keeps across recreation, like the holder keeps the state. + val saved = rememberSaveableStateHolder() + // An entry that left the stack never comes back (a new push gets a new key): drop its state. + SideEffect { nav.takeRetired().forEach { saved.removeState(it) } } + BackHandler(enabled = nav.size > 1) { nav.pop() } + val top = nav.top + // Keyed per ENTRY, not per route: a Host screen for another computer, or for the same one opened + // again (a notification → Inbox), starts from its own route, never from the showing screen's state. + saved.SaveableStateProvider(top.key) { + when (val r = top.value) { + Route.Hosts -> HostsScreen(nav) + is Route.PairHost -> PairScreen(nav, r.code) + Route.Settings -> SettingsScreen(nav) + is Route.Host -> HostScreen(nav, r.hostId, r.tab) + is Route.Terminal -> TerminalScreen(nav, r.hostId, r.nodeId, r.title) + } } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt index cd7e79b083..ad8b3a2380 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -83,7 +84,8 @@ fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro val context = LocalContext.current val scope = rememberCoroutineScope() val projects = snapshot.openProjects() - var projectId by remember { mutableStateOf(null) } + // Saveable, so the chosen project is still showing after a terminal opened from a card (audit A43). + var projectId by rememberSaveable { mutableStateOf(null) } val project = projects.firstOrNull { it.id == projectId } ?: projects.firstOrNull() var projectMenu by remember { mutableStateOf(false) } var moving by remember { mutableStateOf(null) } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index 167c9f6863..b9f2b707b3 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,7 +60,12 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { val session = remember(hostId) { graph.connections.session(hostId) } val state by session.state.collectAsState() val snapshot by session.snapshot.collectAsState() + // Saved under this back-stack entry's key (AppContent), so it survives a terminal pushed on top + // and a recreation (audit A43); [initialTab] only picks the tab a NEW entry opens on. var tab by rememberSaveable { mutableIntStateOf(initialTab) } + // Each tab's own saved state (scroll position, the Board's project, the Inbox's archive toggle), + // kept while another tab shows. The holder itself lives in this entry's saved state. + val tabStates = rememberSaveableStateHolder() var newSession by remember { mutableStateOf(false) } // Watch (the 8 s poll) only while the screen is STARTED: a backgrounded app used to keep @@ -137,11 +143,13 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text(if (needsYou > 0) "Inbox ($needsYou)" else "Inbox") }) Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Usage") }) } - when (tab) { - 0 -> SessionsTab(nav, hostId, session, snapshot) - 1 -> BoardTab(nav, hostId, session, snapshot) - 2 -> InboxTab(nav, hostId, session, snapshot) - else -> UsageTab(snapshot) + tabStates.SaveableStateProvider(tab) { + when (tab) { + 0 -> SessionsTab(nav, hostId, session, snapshot) + 1 -> BoardTab(nav, hostId, session, snapshot) + 2 -> InboxTab(nav, hostId, session, snapshot) + else -> UsageTab(snapshot) + } } } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index 7a5df8ce5f..968cef94a8 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -29,8 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -73,7 +73,8 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro val context = LocalContext.current val graph = NodetermApp.graph(context) val scope = rememberCoroutineScope() - var showArchive by remember { mutableStateOf(false) } + // Saveable, so an open archive stays open after a terminal opened from it (audit A43). + var showArchive by rememberSaveable { mutableStateOf(false) } val inbox = snapshot.status?.inbox val events = inbox?.events.orEmpty().sortedByDescending { it.ts } val actionable = events.filter { it.actionable } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/BackStack.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/BackStack.kt new file mode 100644 index 0000000000..d09729c454 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/BackStack.kt @@ -0,0 +1,141 @@ +package dev.nodeterm.protocol.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.util.UUID + +/** + * The app's back stack, as a value (audit A43). The app's Navigator holds one of these and is only + * type-checked; this pins the rules it delegates to. + * + * Each entry carries a [Entry.key] that belongs to that ENTRY, not to its destination: the same + * screen can sit on the stack twice, and the app files each entry's saved UI state (the selected tab, + * scroll positions, the Board's project) under its key. Only the top entry is composed, so a screen + * below a terminal keeps its state only because its key outlives its composition. + * + * A key whose entry left the stack is RETIRED until the app has dropped the state filed under it + * ([withoutRetired]); otherwise that state would be kept, and saved with the activity, for the rest of + * the process. Retired keys are saved and restored with the stack, so an entry that left just before + * the process died is still cleaned up after it comes back. + * + * The stack is never empty: [pop] refuses the root, and every operation that could empty it takes a + * fallback. + */ +class BackStack private constructor(val entries: List>, val retired: List) { + data class Entry(val key: String, val value: T) + + val current: T get() = entries.last().value + val currentKey: String get() = entries.last().key + val size: Int get() = entries.size + val values: List get() = entries.map { it.value } + + /** [value] on top, under a key no entry on this stack (or waiting to be dropped) has. */ + fun push(value: T, newKey: () -> String = ::randomKey): BackStack = + BackStack(entries + Entry(unusedKey(taken(), newKey), value), retired) + + /** Null when only the root is left: the root is never popped. */ + fun pop(): BackStack? { + if (entries.size <= 1) return null + return BackStack(entries.dropLast(1), retired + entries.last().key) + } + + /** [value] alone. Every entry that was on the stack leaves it, even one showing the same screen. */ + fun replaceAll(value: T, newKey: () -> String = ::randomKey): BackStack = + BackStack(listOf(Entry(unusedKey(taken(), newKey), value)), retired + entries.map { it.key }) + + /** + * The entries [keep] accepts, each under the key it had, so its saved state survives; [fallback] + * (under a new key) when none is left. + */ + fun retain(fallback: T, newKey: () -> String = ::randomKey, keep: (T) -> Boolean): BackStack { + val (kept, gone) = entries.partition { keep(it.value) } + if (gone.isEmpty()) return this + val dropped = retired + gone.map { it.key } + return BackStack(kept.ifEmpty { listOf(Entry(unusedKey(taken(), newKey), fallback)) }, dropped) + } + + /** The same stack once the app has dropped the state filed under every [retired] key. */ + fun withoutRetired(): BackStack = if (retired.isEmpty()) this else BackStack(entries, emptyList()) + + /** Every key in use: on the stack, or still naming state the app has not dropped. */ + private fun taken(): Set = entries.mapTo(HashSet()) { it.key } + retired + + /** + * `{"entries":[{"key":…,"route":[…]}],"retired":[…]}`, each value written by [route] as a list of + * strings. [decode] reads it back. + */ + fun encode(route: (T) -> List): String = JsonObject( + mapOf( + "entries" to JsonArray(entries.map { e -> + JsonObject(mapOf("key" to JsonPrimitive(e.key), "route" to JsonArray(route(e.value).map { JsonPrimitive(it) }))) + }), + "retired" to JsonArray(retired.map { JsonPrimitive(it) }) + ) + ).toString() + + companion object { + fun randomKey(): String = UUID.randomUUID().toString() + + /** [values] bottom first, each under a new key; [fallback] when there are none. */ + fun of(values: List, fallback: T, newKey: () -> String = ::randomKey): BackStack { + var stack = BackStack(emptyList(), emptyList()) + for (v in values.ifEmpty { listOf(fallback) }) stack = stack.push(v, newKey) + return stack + } + + /** + * Reads what [encode] wrote. An entry whose route no longer decodes ([route] returns null) is + * dropped, never guessed, and its key is retired so the state filed under it goes too. The + * format before keys existed (a bare array of routes, audit A22) is read with new keys. Anything + * unreadable is [fallback] alone. + */ + fun decode( + raw: String, + fallback: T, + newKey: () -> String = ::randomKey, + route: (List) -> T?, + ): BackStack { + val root = runCatching { Json.parseToJsonElement(raw) }.getOrNull() + return when (root) { + is JsonArray -> of(root.mapNotNull { strings(it)?.let(route) }, fallback, newKey) + is JsonObject -> { + val retired = (root["retired"] as? JsonArray)?.mapNotNull(::text).orEmpty().toMutableList() + val live = mutableListOf>() + (root["entries"] as? JsonArray)?.forEach { e -> + val obj = e as? JsonObject ?: return@forEach + val key = obj["key"]?.let(::text) + val value = obj["route"]?.let(::strings)?.let(route) + when { + value == null -> key?.let { retired += it } + // A missing key, or one an entry below already has (a corrupted save), + // cannot be trusted to name this entry's state. + key == null || live.any { it.key == key } -> + live += Entry(unusedKey(live.mapTo(HashSet()) { it.key } + retired, newKey), value) + else -> live += Entry(key, value) + } + } + val liveKeys = live.mapTo(HashSet()) { it.key } + val dropped = retired.filterNot { it in liveKeys }.distinct() + if (live.isEmpty()) BackStack(emptyList(), dropped).push(fallback, newKey) + else BackStack(live, dropped) + } + else -> of(emptyList(), fallback, newKey) + } + } + + /** A key from [newKey] that is not in [taken]. A random UUID in practice; a test's source may repeat. */ + private fun unusedKey(taken: Set, newKey: () -> String): String { + repeat(16) { newKey().let { if (it.isNotBlank() && it !in taken) return it } } + return generateSequence { randomKey() }.first { it !in taken } + } + + private fun text(e: JsonElement): String? = + (e as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() } + + private fun strings(e: JsonElement): List? = + (e as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content ?: return null } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackStackTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackStackTest.kt new file mode 100644 index 0000000000..e467bb9788 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackStackTest.kt @@ -0,0 +1,148 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.BackStack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The app's back stack (audit A43). The Navigator and its SaveableStateHolder wiring are only + * type-checked; they file each entry's saved UI state under [BackStack.Entry.key] and drop the state + * of every [BackStack.retired] key. These pin the keys: one per ENTRY, kept while the entry is on the + * stack (and across recreation), retired when it leaves. + */ +class BackStackTest { + private fun counter(): () -> String { + var n = 0 + return { "k${++n}" } + } + + /** The route codec the app uses, reduced: a route is its list of strings. */ + private val codec: (List) -> List? = { it.takeIf { r -> r.firstOrNull() != "pair" } } + + @Test + fun `the same screen twice gets two keys`() { + // A computer's Host screen opened again from a notification must not share state with the one + // below it, so the key cannot be derived from the route. + val s = BackStack.of(listOf("hosts"), "hosts").push("host:a").push("terminal:a:n1").push("host:a") + val keys = s.entries.map { it.key } + assertEquals(keys.size, keys.toSet().size) + assertNotEquals(s.entries[1].key, s.entries[3].key) + } + + @Test + fun `pushing a terminal and popping it keeps the host screen's key, and retires the terminal's`() { + val host = BackStack.of(listOf("hosts"), "hosts", counter()).push("host:a") + val hostKey = host.currentKey + val withTerminal = host.push("terminal:a:n1") + val terminalKey = withTerminal.currentKey + val back = withTerminal.pop()!! + assertEquals(hostKey, back.currentKey) + assertEquals(listOf(terminalKey), back.retired) + assertEquals(emptyList(), back.withoutRetired().retired) + assertEquals(hostKey, back.withoutRetired().currentKey) + } + + @Test + fun `the root is never popped`() { + assertNull(BackStack.of(listOf("hosts"), "hosts").pop()) + } + + @Test + fun `a new entry never reuses a key that still names saved state`() { + // A counter that restarts would hand the new terminal the popped one's key, and with it the + // popped terminal's saved state. + var n = 0 + val source = { "k${(n++ % 3) + 1}" } + val s = BackStack.of(listOf("hosts"), "hosts", source).push("host:a", source).push("terminal:a:n1", source) + val popped = s.pop()!! + val again = popped.push("terminal:a:n1", source) + assertTrue(again.currentKey !in popped.retired) + assertTrue(again.currentKey !in popped.entries.map { it.key }) + } + + @Test + fun `replaceAll retires every entry, even one showing the same screen`() { + val s = BackStack.of(listOf("hosts"), "hosts").push("host:a") + val old = s.entries.map { it.key } + val r = s.replaceAll("hosts") + assertEquals(listOf("hosts"), r.values) + assertEquals(old, r.retired) + assertTrue(r.currentKey !in old) + } + + @Test + fun `retain keeps each surviving entry under its own key and retires the rest`() { + val s = BackStack.of(listOf("hosts"), "hosts").push("host:a").push("host:gone").push("terminal:a:n1") + val keys = s.entries.map { it.key } + val r = s.retain("hosts") { !it.endsWith("gone") } + assertEquals(listOf("hosts", "host:a", "terminal:a:n1"), r.values) + assertEquals(listOf(keys[0], keys[1], keys[3]), r.entries.map { it.key }) + assertEquals(listOf(keys[2]), r.retired) + // Nothing removed: the very same stack. + assertTrue(r.retain("hosts") { true } === r) + } + + @Test + fun `retain that leaves nothing falls back to a new entry and retires everything`() { + val s = BackStack.of(listOf("host:gone"), "hosts") + val r = s.retain("hosts") { false } + assertEquals(listOf("hosts"), r.values) + assertEquals(s.entries.map { it.key }, r.retired) + assertTrue(r.currentKey !in r.retired) + } + + @Test + fun `keys and retired keys survive a save and restore`() { + // A recreation (rotation, process death) restores the SaveableStateHolder's state by key: the + // restored stack must name its entries by the same keys, or the Host screen below a terminal + // comes back on its first tab and scrolled to the top. + val s = BackStack.of(listOf("hosts"), "hosts").push("host:a").push("terminal:a:n1").push("terminal:a:n2").pop()!! + val restored = BackStack.decode(s.encode { it.split(":") }, "hosts", route = { it.joinToString(":") }) + assertEquals(s.entries, restored.entries) + assertEquals(s.retired, restored.retired) + } + + @Test + fun `an entry that no longer restores is dropped and its key retired`() { + val s = BackStack.of(listOf("hosts"), "hosts").push("pair:code") + val pairKey = s.currentKey + val restored = BackStack.decode(s.encode { it.split(":") }, listOf("hosts"), route = codec) + assertEquals(listOf(listOf("hosts")), restored.values) + assertEquals(s.entries[0].key, restored.currentKey) + assertEquals(listOf(pairKey), restored.retired) + } + + @Test + fun `a stack saved before keys existed restores with new, distinct keys`() { + // The audit A22 format: a bare array of routes. + val raw = """[["hosts"],["host","a","0"],["pair","nodeterm://pair?code=x"],["terminal","a","n1","T"]]""" + val restored = BackStack.decode(raw, listOf("hosts"), route = codec) + assertEquals(listOf(listOf("hosts"), listOf("host", "a", "0"), listOf("terminal", "a", "n1", "T")), restored.values) + assertEquals(3, restored.entries.map { it.key }.toSet().size) + assertEquals(emptyList(), restored.retired) + } + + @Test + fun `a corrupted save cannot give two entries one key, nor retire a live one`() { + val raw = """{"entries":[{"key":"a","route":["hosts"]},{"key":"a","route":["host","x","0"]},{"route":["settings"]}],""" + + """"retired":["a","b","b"]}""" + val restored = BackStack.decode(raw, listOf("hosts"), route = codec) + assertEquals(listOf(listOf("hosts"), listOf("host", "x", "0"), listOf("settings")), restored.values) + val keys = restored.entries.map { it.key } + assertEquals("a", keys[0]) + assertEquals(3, keys.toSet().size) + assertEquals(listOf("b"), restored.retired) + } + + @Test + fun `unreadable saves fall back to the root`() { + for (raw in listOf("", "not json", "42", """{"entries":"x"}""", """{"entries":[{"key":"k","route":["pair","c"]}]}""")) { + val restored = BackStack.decode(raw, listOf("hosts"), route = codec) + assertEquals(listOf(listOf("hosts")), restored.values, raw) + assertTrue(restored.currentKey !in restored.retired, raw) + } + } +} From 7988087dd4d970ecabf57bacc92add1bf7350e6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:52:25 +0000 Subject: [PATCH 056/284] fix(android): save Settings edits on the system back too (A44) The phone name and the relay API address were stored only by the top-bar back arrow. The system back (gesture or button) reached AppContent's BackHandler, which popped the screen and threw the edits away. An address that was not https was also dropped by the arrow without a word. SettingsScreen now has one leave(): store the name, decide the address, pop. The arrow calls it, and so does a BackHandler inside the screen (enabled like AppContent's, nav.size > 1, and registered after it, so it runs instead of the plain pop while Settings shows). Nothing is stored per keystroke. A refused address is not stored; the field shows an error while it holds one, and leaving shows a Toast saying it was not saved. The address rule moved to the protocol module (ApiBaseSetting, pure and unit-tested). Departure from the audit: the rule is tightened beyond startsWith("https://"). It still requires the https:// prefix (now case-insensitive, since a keyboard may capitalize the first letter), and the address must also parse as a URL with a host and without a query or fragment. Measured with okhttp 4.12.0: "https://" alone was stored as "https:", which OkHttp reads with the appended /v1/... path as a host named "v1"; "https://?x", "https://:443" and "https://a b" made Request.Builder.url throw IllegalArgumentException, which RelayApi does not turn into a RelayApiException. A refused value that equals the stored one (only an older build could have stored it) is left alone without a message. Tests: SettingsLeaveTest. The address rule and the leave decision are unit tests; the wiring (the BackHandler and the arrow both call leave(), leave stores before it pops and toasts a refusal, and the file has one nav.pop() and one write each of apiBase and deviceName) is pinned in the source, as for A46. The source-pin helpers moved from TerminalKeyboardChipTest to a shared AppSourcePins. docs/android.md describes the test; the protocol build's comment on its ui inputs now names it. Mutation checks: with SettingsScreen.kt at HEAD, the three source-pin tests fail; with only the BackHandler line removed, the back test fails; with accept() reduced to the old prefix check, the four rule tests fail. Protocol suite: 210 tests, 0 skipped, 0 failed. The offline app type-check passes. Nothing has run on a device: whether the back gesture reaches leave() and the Toast shows is a device check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/SettingsScreen.kt | 37 +++++- android/protocol/build.gradle.kts | 3 +- .../nodeterm/protocol/relay/ApiBaseSetting.kt | 54 +++++++++ .../dev/nodeterm/protocol/AppSourcePins.kt | 39 ++++++ .../nodeterm/protocol/SettingsLeaveTest.kt | 112 ++++++++++++++++++ .../protocol/TerminalKeyboardChipTest.kt | 36 +----- docs/android.md | 8 ++ 7 files changed, 250 insertions(+), 39 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt index 133c125f55..245dbb6ab6 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -4,6 +4,8 @@ import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.os.Build +import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat @@ -46,6 +48,8 @@ import dev.nodeterm.android.NodetermApp import dev.nodeterm.android.data.RoutePreference import dev.nodeterm.android.notify.InboxNotifier import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.relay.ApiBaseSetting +import dev.nodeterm.protocol.relay.RelayApi import java.security.MessageDigest @OptIn(ExperimentalMaterial3Api::class) @@ -78,16 +82,32 @@ fun SettingsScreen(nav: Navigator) { } var routes by remember { mutableStateOf(hosts.associate { it.id to graph.hosts.route(it.id) }) } + // The name and the relay address are stored when the screen is left, by the top-bar arrow AND + // by the system back (gesture or button), which used to pop without saving (audit A44). Not per + // keystroke: a half-typed address would be the one the relay calls used. An address that is not + // https is not stored, and the user is told so rather than finding the old one still in place. + fun leave() { + graph.hosts.deviceName = name + when (val edit = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase)) { + is ApiBaseSetting.OnLeave.Save -> graph.hosts.apiBase = edit.value + ApiBaseSetting.OnLeave.Keep -> {} + ApiBaseSetting.OnLeave.Rejected -> Toast.makeText( + context, + "Relay API not saved: it must be a full https:// address.", + Toast.LENGTH_LONG + ).show() + } + nav.pop() + } + // Registered after AppContent's, so it runs instead of that plain pop while Settings shows. + BackHandler(enabled = nav.size > 1) { leave() } + Scaffold( topBar = { TopAppBar( title = { Text("Settings") }, navigationIcon = { - IconButton(onClick = { - graph.hosts.deviceName = name - if (apiBase.startsWith("https://")) graph.hosts.apiBase = apiBase - nav.pop() - }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + IconButton(onClick = { leave() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } } ) } @@ -184,14 +204,19 @@ fun SettingsScreen(nav: Navigator) { HorizontalDivider() Text("Advanced", style = MaterialTheme.typography.titleMedium) + val apiBaseRejected = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase) == ApiBaseSetting.OnLeave.Rejected OutlinedTextField( value = apiBase, onValueChange = { apiBase = it.trim() }, label = { Text("Relay API (https)") }, + isError = apiBaseRejected, + supportingText = if (apiBaseRejected) { + { Text("Not a full https:// address: it will not be saved.") } + } else null, singleLine = true, modifier = Modifier.fillMaxWidth() ) - TextButton(onClick = { apiBase = dev.nodeterm.protocol.relay.RelayApi.DEFAULT_API_BASE }) { Text("Reset to default") } + TextButton(onClick = { apiBase = RelayApi.DEFAULT_API_BASE }) { Text("Reset to default") } HorizontalDivider() Text("About", style = MaterialTheme.typography.titleMedium) diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index cd5001b242..bf7d0d0b40 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -45,7 +45,8 @@ tasks.test { // from here and skip themselves (with a reason) when node or node_modules are missing. systemProperty("nodeterm.repoRoot", rootDir.resolve("../..").canonicalPath) // Files some tests run or read that are not on the test classpath: the node drivers, the app's - // terminal page (TerminalJs*Test) and the terminal screen's source (TerminalKeyboardChipTest). + // terminal page (TerminalJs*Test) and the app's screen sources (the source pins in + // TerminalKeyboardChipTest and SettingsLeaveTest). // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) inputs.dir(rootDir.resolve("../app/src/main/assets/terminal")).withPathSensitivity(PathSensitivity.RELATIVE) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt new file mode 100644 index 0000000000..94aa7588a9 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt @@ -0,0 +1,54 @@ +package dev.nodeterm.protocol.relay + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * The relay API address the user can edit under Settings → Advanced, and what leaving that screen + * does with it (audit A44). The address is stored only when the user leaves the screen, never per + * keystroke: a half-typed value such as `https://a` would otherwise be the one every relay call + * used until the typing was done. + */ +object ApiBaseSetting { + /** What leaving Settings does with the typed address. */ + sealed interface OnLeave { + /** Store [value]. */ + data class Save(val value: String) : OnLeave + + /** + * Not an address we accept, but it is what is already stored (a value an older build + * saved): nothing to store, and nothing to tell a user who did not type it. + */ + data object Keep : OnLeave + + /** Not an address we accept: nothing is stored, and the user must be told so. */ + data object Rejected : OnLeave + } + + /** + * [typed] as it is to be stored (trimmed, without a trailing slash), or null when it is not an + * address the phone will use. It must start with `https://` (the device token and the relay + * tokens ride it), and be a URL with a host and without a query or fragment. The old rule was + * only the prefix check. It let `https://` through, which is stored as `https:` and which OkHttp + * then reads, with the `/v1/…` path [RelayApi] appends, as a host named `v1`. It let + * `https://?x`, `https://:443` and `https://a b` through, and with those every relay call threw + * IllegalArgumentException from OkHttp's `Request.Builder.url`, which [RelayApi] does not turn + * into a [RelayApiException] (measured with okhttp 4.12.0). A query or fragment swallows the + * appended path. + */ + fun accept(typed: String): String? { + val base = normalize(typed) + if (!base.startsWith("https://", ignoreCase = true)) return null + val url = base.toHttpUrlOrNull() ?: return null + if (!url.isHttps || url.query != null || url.fragment != null) return null + return base + } + + /** What leaving Settings does with [typed], given the address [stored] now. */ + fun onLeave(typed: String, stored: String): OnLeave { + accept(typed)?.let { return OnLeave.Save(it) } + return if (normalize(typed) == normalize(stored)) OnLeave.Keep else OnLeave.Rejected + } + + /** The form the app stores an address in (HostStore's setter trims the same way). */ + private fun normalize(address: String): String = address.trim().trimEnd('/') +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt new file mode 100644 index 0000000000..70193aa1d1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt @@ -0,0 +1,39 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.assertTrue + +/** + * Reading the app's Compose sources from a JVM test. What only a device can run (Compose focus, the + * system back dispatcher, InputMethodManager) is pinned in the source instead: the calls a fix + * depends on, in the order it depends on them. + */ +object AppSourcePins { + val appUi = File(InteropHarness.repoRoot, "android/app/src/main/kotlin/dev/nodeterm/android/ui") + + fun ui(file: String): String = File(appUi, file).readText() + + /** The block that follows the first occurrence of [start] in [source], braces balanced. */ + fun blockAfter(source: String, start: String): String { + val at = source.indexOf(start) + assertTrue(at >= 0, "not found: $start") + val open = source.indexOf('{', at) + var depth = 0 + for (i in open until source.length) { + when (source[i]) { + '{' -> depth++ + '}' -> if (--depth == 0) return source.substring(open, i + 1) + } + } + error("unbalanced block after $start") + } + + fun assertInOrder(block: String, vararg parts: String) { + var from = 0 + for (p in parts) { + val at = block.indexOf(p, from) + assertTrue(at >= 0, "expected `$p` after `${parts.takeWhile { it != p }.lastOrNull()}` in:\n$block") + from = at + p.length + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt new file mode 100644 index 0000000000..608635f318 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt @@ -0,0 +1,112 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.relay.ApiBaseSetting +import dev.nodeterm.protocol.relay.ApiBaseSetting.OnLeave +import dev.nodeterm.protocol.relay.RelayApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A44: leaving Settings by the system back (gesture or button) threw away the edited device name + * and relay address; only the top-bar arrow stored them. And an address that was not https was + * dropped by that arrow without a word. + * + * The address rule and the leave decision are pure and tested here. The wiring (both backs run the + * one `leave()`, which stores before it pops, and nothing stores per keystroke) cannot run on a JVM, + * so it is pinned in the app's source ([AppSourcePins]); whether the gesture reaches it on a device + * has not been checked. + */ +class SettingsLeaveTest { + @Test + fun `an https address is stored as typed, trimmed and without a trailing slash`() { + assertEquals(RelayApi.DEFAULT_API_BASE, ApiBaseSetting.accept(RelayApi.DEFAULT_API_BASE)) + assertEquals("https://relay.example.com", ApiBaseSetting.accept(" https://relay.example.com/ ")) + assertEquals("https://relay.example.com:8443/api", ApiBaseSetting.accept("https://relay.example.com:8443/api/")) + // The scheme is case-insensitive, as it is to OkHttp (a keyboard may capitalize the first letter). + assertEquals("Https://relay.example.com", ApiBaseSetting.accept("Https://relay.example.com")) + } + + @Test + fun `anything that is not an https address is refused`() { + assertNull(ApiBaseSetting.accept("")) + assertNull(ApiBaseSetting.accept("http://relay.example.com")) + assertNull(ApiBaseSetting.accept("relay.example.com")) + assertNull(ApiBaseSetting.accept("wss://relay.example.com")) + assertNull(ApiBaseSetting.accept("https:relay.example.com")) + } + + @Test + fun `an https prefix without a usable URL behind it is refused`() { + // All passed the old startsWith("https://") check. "https://" was stored as "https:", which + // OkHttp read as a host named "v1"; with the next three every relay call threw + // IllegalArgumentException from Request.Builder.url instead of a RelayApiException. + assertNull(ApiBaseSetting.accept("https://")) + assertNull(ApiBaseSetting.accept("https://?x")) + assertNull(ApiBaseSetting.accept("https://:443")) + assertNull(ApiBaseSetting.accept("https://relay example.com")) + // The /v1/... path is appended to the address: a query or fragment would swallow it. + assertNull(ApiBaseSetting.accept("https://relay.example.com?x=1")) + assertNull(ApiBaseSetting.accept("https://relay.example.com#top")) + } + + @Test + fun `leaving stores an accepted address and reports a refused one`() { + val stored = RelayApi.DEFAULT_API_BASE + assertEquals(OnLeave.Save("https://relay.example.com"), ApiBaseSetting.onLeave("https://relay.example.com/", stored)) + assertEquals(OnLeave.Save(stored), ApiBaseSetting.onLeave(stored, stored)) + assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("http://relay.example.com", stored)) + assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("", stored)) + assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("https://", stored)) + } + + @Test + fun `a refused address that is already the stored one is left alone without a message`() { + // Only an older build could have stored it (its check let "https://" through, and HostStore + // stored that as "https:"); the user did not type it here. + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave("https:", "https:")) + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave(" https:// ", "https:")) + assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("http://relay.example.com", "https:")) + } + + private val settings get() = AppSourcePins.ui("SettingsScreen.kt") + + @Test + fun `the system back runs the same leave as the top-bar arrow`() { + val src = settings + assertTrue(src.contains("import androidx.activity.compose.BackHandler"), "SettingsScreen registers no BackHandler") + // Enabled like AppContent's, so it never swallows the back that should close the app. + assertEquals("{ leave() }", AppSourcePins.blockAfter(src, "BackHandler(enabled = nav.size > 1)")) + val arrow = AppSourcePins.blockAfter(src, "navigationIcon =") + AppSourcePins.assertInOrder(arrow, "IconButton(onClick = { leave() })") + assertFalse(arrow.contains("nav.pop()"), "the arrow pops without the shared leave():\n$arrow") + } + + @Test + fun `leave stores the name and the address, tells about a refused one, then pops`() { + val leave = AppSourcePins.blockAfter(settings, "fun leave()") + AppSourcePins.assertInOrder( + leave, + "graph.hosts.deviceName = name", + "ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase)", + "OnLeave.Save -> graph.hosts.apiBase = edit.value", + "OnLeave.Rejected -> Toast.makeText(", + ".show()", + "nav.pop()" + ) + } + + @Test + fun `nothing else pops or stores the edits`() { + val src = settings + // One pop (in leave), and the two stores only there: never per keystroke, never skipped. + assertEquals(1, Regex("""nav\.pop\(\)""").findAll(src).count()) + assertEquals(1, Regex("""graph\.hosts\.apiBase\s*=(?!=)""").findAll(src).count()) + assertEquals(1, Regex("""graph\.hosts\.deviceName\s*=(?!=)""").findAll(src).count()) + // The field warns with the same decision leaving acts on. + assertTrue(src.contains("val apiBaseRejected = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase) == ApiBaseSetting.OnLeave.Rejected")) + assertTrue(src.contains("isError = apiBaseRejected")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt index fedb656522..95862f0c91 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt @@ -8,10 +8,8 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray -import java.io.File import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue /** * A46: the key row's ⌨ chip must bring up the soft keyboard for the terminal. It used to run only @@ -50,42 +48,16 @@ class TerminalKeyboardChipTest { assertEquals(Focus(listOf("focus"), focusedAfter = true), focusForKeyboard(textareaFocused = false)) } - private val appSrc = File(InteropHarness.repoRoot, "android/app/src/main/kotlin/dev/nodeterm/android/ui") - - /** The block that follows the first occurrence of [start] in [source], braces balanced. */ - private fun blockAfter(source: String, start: String): String { - val at = source.indexOf(start) - assertTrue(at >= 0, "not found: $start") - val open = source.indexOf('{', at) - var depth = 0 - for (i in open until source.length) { - when (source[i]) { - '{' -> depth++ - '}' -> if (--depth == 0) return source.substring(open, i + 1) - } - } - error("unbalanced block after $start") - } - - private fun assertInOrder(block: String, vararg parts: String) { - var from = 0 - for (p in parts) { - val at = block.indexOf(p, from) - assertTrue(at >= 0, "expected `$p` after `${parts.takeWhile { it != p }.lastOrNull()}` in:\n$block") - from = at + p.length - } - } - @Test fun `the chip releases Compose's focus before it asks for the keyboard`() { - val chip = blockAfter(File(appSrc, "TerminalScreen.kt").readText(), "KeyChip(\"⌨\")") - assertInOrder(chip, "focusManager.clearFocus()", "controller.showKeyboard()") + val chip = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalScreen.kt"), "KeyChip(\"⌨\")") + AppSourcePins.assertInOrder(chip, "focusManager.clearFocus()", "controller.showKeyboard()") } @Test fun `the WebView takes focus, then the page, then the keyboard is asked for after the focus change`() { - val body = blockAfter(File(appSrc, "TerminalController.kt").readText(), "fun showKeyboard()") - assertInOrder(body, ".requestFocus()", "js(\"nt.focusForKeyboard()\")", ".post {", "showSoftInput(", "InputMethodManager.SHOW_IMPLICIT") + val body = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "fun showKeyboard()") + AppSourcePins.assertInOrder(body, ".requestFocus()", "js(\"nt.focusForKeyboard()\")", ".post {", "showSoftInput(", "InputMethodManager.SHOW_IMPLICIT") // Only the deferred call: a synchronous request right after requestFocus races the focus change. assertEquals(1, Regex("""showSoftInput\(""").findAll(body).count()) } diff --git a/docs/android.md b/docs/android.md index d0a14b63a2..e0cf4ccc55 100644 --- a/docs/android.md +++ b/docs/android.md @@ -198,6 +198,14 @@ been processed first. The page half runs the real `terminal.js` in node against follows Blink's rule. The Android half cannot run on a JVM, so the test pins its order in the source. Whether the keyboard comes up, and stays up, is a device check. +`SettingsLeaveTest` covers leaving Settings (`A44`). The system back (gesture or button) used to pop +the screen without storing the edited phone name or relay API address; only the top-bar arrow stored +them. Both now run one `leave()`, which stores, then pops. Nothing is stored per keystroke. The relay +address must be a full `https://` URL (`ApiBaseSetting`: a host, no query or fragment); one that is +not is left unstored, the field says so while it is being typed, and leaving shows a message. The +address rule is unit-tested; the wiring is pinned in the source, and whether the back gesture reaches +it is a device check. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 7debe67f018f4647c7ba0ebae6a2ebba2193fb4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 15:58:11 +0000 Subject: [PATCH 057/284] fix(android): derive the New-session selection from the current listing (A42) The New-session dialog remembered the user's taps while HostScreen re-listed the host under it every 8 s. Start looked the remembered project up with `projects.first { it.id == projectId }`, which threw NoSuchElementException inside the click handler when the desktop had closed or removed that project meanwhile. The account picker had the same shape: a managed account removed on the desktop stayed remembered, no row was drawn as selected (or, with every account gone, no picker was drawn at all), and Start still sent the stale id to the desktop in the LaunchRequest. What changed: - New protocol helper NewSessionChoice (pure Kotlin): offeredProjects (the A14 rule: open, not SSH, with a folder), project(offered, pickedId) = the pick while it is still offered, else the first offered project, else null, and account(settings, project, pickedId) = the pick while the host still has it (null = System), else the project's default via Launch.defaultAccount, else System. - The dialog computes `selected` and `accountId` from those on every recomposition and uses the same values for the radios it draws and for Start. Start is enabled only when `selected != null` and uses `selected` (no first {}). The account pick is re-seeded from the selected project's default whenever the selection moves to another project, and `accountId` is now a val no tap can overwrite. - With no project left to start in, Start is disabled and the dialog says so instead of showing an empty Project list. - HostScreen's canCreate uses the same offeredProjects rule instead of its own copy of the filter. - The agent picker was checked: its list is the static Agent.entries, so it cannot lose its selection. Verified: NewSessionChoiceTest (9 cases: listings that change between taps, vanished and returning projects, removed accounts, empty lists, plus AppSourcePins pins of the dialog's wiring and HostScreen's rule). Mutation-checked: reverting SessionsTab.kt/HostScreen.kt fails 3 pin tests; drawing the project radio from the raw pick fails 1; project() with first {} throws NoSuchElementException in 3 tests; project() returning null for a vanished pick fails 1; account() returning the raw pick, or skipping the project default, fails 1 each. Full protocol suite (219 tests) and the offline app type-check pass. Nothing has run on a device. Departures from the audit: the verifier's derived-selection fix is followed; on "dismiss or disable" the dialog disables Start and shows a line rather than vanishing under the user's finger. The pick is not overwritten on fallback, so a project that reappears in a later listing is selected again. The account picker got the same treatment (the task asked to check it), falling back to the project default before System. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/ui/HostScreen.kt | 3 +- .../dev/nodeterm/android/ui/SessionsTab.kt | 35 +++-- .../dev/nodeterm/protocol/model/NewSession.kt | 41 +++++ .../nodeterm/protocol/NewSessionChoiceTest.kt | 141 ++++++++++++++++++ 4 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/NewSession.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/NewSessionChoiceTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index b9f2b707b3..d2282bc852 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -46,6 +46,7 @@ import dev.nodeterm.android.NodetermApp import dev.nodeterm.android.conn.ConnState import dev.nodeterm.android.conn.HostSession import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.NewSessionChoice @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -77,7 +78,7 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { val needsYou = snapshot.status?.inbox?.events?.count { it.actionable } ?: 0 val canCreate = (state as? ConnState.Connected)?.kind == TransportKind.RELAY && - snapshot.projects.any { !it.closed && it.sshTarget == null && it.cwd != null } + NewSessionChoice.offeredProjects(snapshot).isNotEmpty() Scaffold( topBar = { diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt index d5d0a38edd..84f5e41b49 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -45,6 +45,7 @@ import dev.nodeterm.protocol.model.AccountNames import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.ContextFill import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.NewSessionChoice import dev.nodeterm.protocol.model.NodeInfo import dev.nodeterm.protocol.model.ProjectInfo import dev.nodeterm.protocol.model.ProjectsSnapshot @@ -299,17 +300,20 @@ private fun SessionRow(node: NodeInfo, snapshot: ProjectsSnapshot, onClick: () - /** New session: a project on THIS computer, an agent (or a plain shell), and for Claude an account. */ @Composable fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate: (LaunchRequest) -> Unit) { - // Only projects the desktop can register a node in: on this computer (not an SSH project) AND - // with a folder — it refuses cwd-less (inline) canvases, which would orphan the session (A14). - val projects = snapshot.openProjects().filter { it.sshTarget == null && it.cwd != null } + // Only projects the desktop can register a node in: on this computer, with a folder (A14). + val projects = NewSessionChoice.offeredProjects(snapshot) + // The user's taps are remembered, but the listing is re-fetched under the open dialog, so a tap + // can name a project or account the desktop has since removed. What the dialog draws as selected, + // and what Start uses, is derived from the CURRENT listing — the same answer for both (A42). var projectId by remember { mutableStateOf(projects.firstOrNull()?.id) } + val selected = NewSessionChoice.project(projects, projectId) var agent by remember { mutableStateOf(Agent.CLAUDE) } val settings = snapshot.status?.settings val accounts = settings?.claudeAccounts.orEmpty() - // The project's own default account, when this host still has it (A16). - var accountId by remember(projectId) { - mutableStateOf(Launch.defaultAccount(settings, projects.firstOrNull { it.id == projectId })) - } + // Starts at the selected project's own default account, when this host still has it (A16), and + // again whenever the selection moves to another project. + var accountPick by remember(selected?.id) { mutableStateOf(Launch.defaultAccount(settings, selected)) } + val accountId = NewSessionChoice.account(settings, selected, accountPick) AlertDialog( onDismissRequest = onDismiss, @@ -317,9 +321,18 @@ fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate text = { LazyColumn(verticalArrangement = Arrangement.spacedBy(2.dp)) { item { Text("Project", style = MaterialTheme.typography.labelLarge) } + if (projects.isEmpty()) { + item { + Text( + "No open project on this computer has a folder to start a session in.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } items(projects, key = { "proj-${it.id}" }) { p -> Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = projectId == p.id, onClick = { projectId = p.id }) + RadioButton(selected = selected?.id == p.id, onClick = { projectId = p.id }) Text(p.name) } } @@ -334,7 +347,7 @@ fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate item { Text("Claude account", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) } items(listOf(null) + accounts.map { it.id }, key = { "acct-${it ?: "system"}" }) { id -> Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = accountId == id, onClick = { accountId = id }) + RadioButton(selected = accountId == id, onClick = { accountPick = id }) Text(id?.let { AccountNames.managed(it, snapshot.status) } ?: AccountNames.SYSTEM) } } @@ -342,8 +355,8 @@ fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate } }, confirmButton = { - TextButton(enabled = projectId != null, onClick = { - val p = projects.first { it.id == projectId } + TextButton(enabled = selected != null, onClick = start@{ + val p = selected ?: return@start val a = agent val acct = if (a == Agent.CLAUDE) accountId else null val cmd = if (a != null) Launch.launchCommand(a, settings, acct, p.cwd, p.defaultPermissionMode) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/NewSession.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/NewSession.kt new file mode 100644 index 0000000000..07436ab42a --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/NewSession.kt @@ -0,0 +1,41 @@ +package dev.nodeterm.protocol.model + +/** + * What the phone's New-session dialog offers, and what its Start button uses (audit A42). + * + * The dialog stays open while the host listing is re-fetched under it (the Host screen re-lists every + * 8 s), but the user's taps are remembered. So a remembered pick can name a project the desktop has + * since closed or removed, or a managed account it has since removed. Start used to look the project + * up with `first {}`, which threw inside the click handler; and a vanished account id was still sent + * to the desktop, under a picker that drew no row as selected (or, with every account gone, drew no + * picker at all). + * + * So the dialog derives its selection from the CURRENT listing on every recomposition, through these + * functions, and draws and starts with the same answer: the row shown as selected is always the one + * Start uses, and Start is off when there is nothing to start in. + */ +object NewSessionChoice { + /** + * The projects a phone can start a session in: open, on this computer (an SSH project's terminals + * run on another machine), and with a folder — the desktop refuses to register a node in a + * cwd-less canvas, which would orphan the session (A14). + */ + fun offeredProjects(snapshot: ProjectsSnapshot): List = + snapshot.openProjects().filter { it.sshTarget == null && it.cwd != null } + + /** + * The project Start uses: the one the user picked while it is still offered, else the first + * offered project (what the dialog preselects), else null — nothing to start in, so Start is off. + */ + fun project(offered: List, pickedId: String?): ProjectInfo? = + offered.firstOrNull { it.id == pickedId } ?: offered.firstOrNull() + + /** + * The managed Claude account Start uses in [project]: the user's pick while this host still has + * that account (null is System, which is always there), else the project's default, itself only + * while the host still has it ([Launch.defaultAccount]) — else System. + */ + fun account(settings: MirrorSettings?, project: ProjectInfo?, pickedId: String?): String? = + if (pickedId == null || settings?.claudeAccounts?.any { it.id == pickedId } == true) pickedId + else Launch.defaultAccount(settings, project) +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NewSessionChoiceTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NewSessionChoiceTest.kt new file mode 100644 index 0000000000..2be73bca03 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NewSessionChoiceTest.kt @@ -0,0 +1,141 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ManagedAccount +import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.NewSessionChoice +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A42: the New-session dialog remembered the user's taps while the host listing was re-fetched under + * it (every 8 s). Start looked the remembered project up with `first {}`, so a project closed or + * removed on the desktop meanwhile threw NoSuchElementException inside the click handler; and a + * removed account was still sent to the desktop under a picker showing no row selected. + * + * The selection rules are pure and tested here against listings that change between taps. The + * dialog's wiring (it draws and starts with the same derived answer, and never looks a pick up with + * `first {}`) cannot run on a JVM, so it is pinned in the app's source ([AppSourcePins]); nothing + * here has run on a device. + */ +class NewSessionChoiceTest { + private fun project(id: String, cwd: String? = "/w/$id", ssh: String? = null, closed: Boolean = false, defaultAccount: String? = null) = + ProjectInfo(id, id.uppercase(), null, cwd, ssh, closed, emptyList(), null, defaultAccountId = defaultAccount) + + private fun listing(vararg projects: ProjectInfo) = ProjectsSnapshot(projects.toList(), emptySet(), null, 0) + + private fun settings(vararg accountIds: String) = MirrorSettings( + "manual", + false, + accountIds.map { ManagedAccount(it, "/Users/me/Library/Application Support/nodeterm/claude-accounts/$it") }, + emptyList() + ) + + @Test + fun `only open projects on this computer with a folder are offered (A14)`() { + val offered = NewSessionChoice.offeredProjects( + listing( + project("a"), + project("closed", closed = true), + project("remote", ssh = "me@box"), + project("inline", cwd = null), + project("b") + ) + ) + assertEquals(listOf("a", "b"), offered.map { it.id }) + } + + @Test + fun `the picked project is used while it is still offered`() { + val offered = NewSessionChoice.offeredProjects(listing(project("a"), project("b"))) + assertEquals("b", NewSessionChoice.project(offered, "b")?.id) + assertEquals("a", NewSessionChoice.project(offered, "a")?.id) + assertEquals("a", NewSessionChoice.project(offered, null)?.id, "no pick yet: the first project, as preselected") + } + + @Test + fun `a picked project that disappears from the listing falls back to one that is shown, instead of crashing Start`() { + // The user picks B; the next re-list no longer has it (closed on the desktop). + val picked = "b" + val relisted = NewSessionChoice.offeredProjects(listing(project("a"), project("b", closed = true), project("c"))) + val selected = NewSessionChoice.project(relisted, picked) + // Start and the radio use this one answer: a project that is on screen, never the hidden B. + assertEquals("a", selected?.id) + assertTrue(selected in relisted) + // Removed outright, or turned into an SSH or cwd-less project: the same. + assertEquals("a", NewSessionChoice.project(NewSessionChoice.offeredProjects(listing(project("a"))), picked)?.id) + assertEquals("a", NewSessionChoice.project(NewSessionChoice.offeredProjects(listing(project("a"), project("b", cwd = null))), picked)?.id) + // It comes back: the user's pick is honoured again. + assertEquals("b", NewSessionChoice.project(NewSessionChoice.offeredProjects(listing(project("a"), project("b"))), picked)?.id) + } + + @Test + fun `with no project left to start in there is no selection, so Start is off`() { + assertNull(NewSessionChoice.project(emptyList(), "b")) + assertNull(NewSessionChoice.project(emptyList(), null)) + assertNull(NewSessionChoice.project(NewSessionChoice.offeredProjects(listing(project("b", closed = true))), "b")) + } + + @Test + fun `the picked account is used while the host still has it, and System is always there`() { + val p = project("a", defaultAccount = "acct-1") + val s = settings("acct-1", "acct-2") + assertEquals("acct-2", NewSessionChoice.account(s, p, "acct-2")) + assertEquals("acct-1", NewSessionChoice.account(s, p, "acct-1")) + assertNull(NewSessionChoice.account(s, p, null), "System was picked") + } + + @Test + fun `a picked account the desktop removed falls back to the project's default, then to System`() { + val p = project("a", defaultAccount = "acct-1") + // acct-2 was picked, then removed on the desktop: the project default still exists. + assertEquals("acct-1", NewSessionChoice.account(settings("acct-1"), p, "acct-2")) + // Both gone (or every account gone, when the picker is not even drawn): System, never a stale id. + assertNull(NewSessionChoice.account(settings("acct-3"), p, "acct-2")) + assertNull(NewSessionChoice.account(settings(), p, "acct-1")) + assertNull(NewSessionChoice.account(null, p, "acct-1")) + // No project selected at all: nothing to take a default from. + assertNull(NewSessionChoice.account(settings("acct-1"), null, "gone")) + } + + private val dialog get() = AppSourcePins.blockAfter(AppSourcePins.ui("SessionsTab.kt"), "fun NewSessionDialog(") + + @Test + fun `the dialog draws and starts with the selection derived from the current listing`() { + val src = dialog + assertTrue(src.contains("val projects = NewSessionChoice.offeredProjects(snapshot)"), "the dialog filters projects on its own") + assertTrue(src.contains("val selected = NewSessionChoice.project(projects, projectId)"), "the selection is not derived from the listing") + // No lookup that can throw on a vanished pick. + assertFalse(src.contains(".first {"), "a remembered pick is looked up with first {}") + assertFalse(src.contains("!!"), "a remembered pick is asserted non-null") + // The radio shown as selected is the one Start uses. + assertTrue(src.contains("RadioButton(selected = selected?.id == p.id, onClick = { projectId = p.id })"), "the project radio is not drawn from the selection") + AppSourcePins.assertInOrder( + src, + "TextButton(enabled = selected != null", + "val p = selected ?: return@start", + "projectId = p.id" + ) + assertTrue(src.contains("if (projects.isEmpty())"), "an empty project list is not explained") + } + + @Test + fun `the account picker draws and starts with the same derived account`() { + val src = dialog + assertTrue(src.contains("remember(selected?.id)"), "the account pick does not follow the selected project") + assertTrue(src.contains("val accountId = NewSessionChoice.account(settings, selected, accountPick)"), "the account is not derived from the listing") + assertTrue(src.contains("RadioButton(selected = accountId == id, onClick = { accountPick = id })"), "the account radio is not drawn from the derived account") + // A `val`, so no tap can overwrite it with an id the listing no longer has. + assertTrue(src.contains("val acct = if (a == Agent.CLAUDE) accountId else null"), "Start does not use the derived account") + } + + @Test + fun `the New session button is offered by the same rule as the dialog's list`() { + val host = AppSourcePins.ui("HostScreen.kt") + assertTrue(host.contains("NewSessionChoice.offeredProjects(snapshot).isNotEmpty()"), "HostScreen keeps its own copy of the project rule") + } +} From 8dbeb48ef4304ca1618251b17930486a3db063b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 16:03:34 +0000 Subject: [PATCH 058/284] fix(android): show a multi-select question's options on its Inbox card (A57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-select AskUserQuestion card showed only "Open session": InboxTab drew option buttons only for `!ev.multiSelect`, so the card did not say what was being asked, although the desktop publishes the options and the multiSelect flag precisely so the phone can show them. The card now lists them, numbered and read-only (plain text, not buttons), under "Choose several — answer in the session." and beside "Open session". A single-select question keeps its answer buttons. What the card shows is one pure rule in android/protocol, QuestionChoices.of(event): Answer (single-select, numbered rows that are quick answers), ReadOnly (multi-select) or None (no options, or not a question). QuickActions.answerQuestion now asks the same rule instead of its own `multiSelect` check, so the card can never offer an answer the action refuses. There is deliberately no answer path for multi-select: nothing in this repo measures how Claude Code's multi-select picker toggles an option or submits, and the phone cannot see the picker. Tests: QuestionChoicesTest covers the rule, and pins in InboxTab's source that the card draws by it, that single-select rows answer through QuickActions, and that the multi-select branch shows the note, the rows as text and "Open session" with no answerQuestion, OutlinedButton or clickable. QuickActionsTest adds that a multi-select question on a WAITING node is never typed. Mutation checks: mapping multi-select to None fails the ReadOnly rule test; letting answerQuestion accept a ReadOnly question fails the new QuickActions test; InboxTab reverted to HEAD fails the three source pins; making the read-only rows answer buttons fails the multi-select pin. Full protocol suite (226 tests) and the offline app type-check pass. Nothing ran on a device. Departures from the audit: none from the verifier's refinement, which replaced the proposed toggleable chips and key sequence (unmeasured) with the read-only listing. docs/android.md (tests and Known gaps) and the android/README.md parity row now say multi-select is answered in the session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 2 +- .../dev/nodeterm/android/ui/InboxTab.kt | 39 +++++++-- .../nodeterm/protocol/host/QuickActions.kt | 17 ++-- .../protocol/model/QuestionChoices.kt | 39 +++++++++ .../nodeterm/protocol/QuestionChoicesTest.kt | 83 +++++++++++++++++++ .../dev/nodeterm/protocol/QuickActionsTest.kt | 17 +++- docs/android.md | 12 ++- 7 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/QuestionChoices.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuestionChoicesTest.kt diff --git a/android/README.md b/android/README.md index e636032ccf..99853b5b65 100644 --- a/android/README.md +++ b/android/README.md @@ -29,7 +29,7 @@ it is talking to. | New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches | | Wake / refresh / rename / end session | relay (end: both) | `node.*` verbs; over SSH "end" stops the tmux session only | | Kanban board, move cards, labels | relay (read-only on SSH) | `projects.ensureBoard/setCardColumn/editCardLabels` | -| Inbox: approvals, questions, finished turns | ✓ | Held hook approvals are answered deterministically (never with keystrokes); each card shows its node's context % when known | +| Inbox: approvals, questions, finished turns | ✓ | Held hook approvals are answered deterministically (never with keystrokes); a multi-select question lists its options but is answered in the session; each card shows its node's context % when known | | Read-ack (reading a finished session clears it on the computer) | ✓ | `inbox.ack` over the relay, `~/.nodeterm/acks` over SSH | | Usage (rate limits per account) | ✓ | From the agent-status mirror, with a pace line ("5h usage pace faster") when the reset time is known | | Notifications | local | See "Notifications" below | diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index 968cef94a8..5c32962d68 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -54,6 +54,7 @@ import dev.nodeterm.protocol.model.ContextFill import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.QuestionChoices import dev.nodeterm.protocol.model.UsageAccount import dev.nodeterm.protocol.model.UsageLimit import dev.nodeterm.protocol.model.UsagePace @@ -136,16 +137,38 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro }) { Text("Deny") } TextButton(onClick = { open(ev.nodeId) }) { Text("Open") } } - } else if (ev.options.isNotEmpty() && !ev.multiSelect) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - ev.options.forEachIndexed { i, opt -> - OutlinedButton(onClick = { - run("Answered.", { c -> QuickActions.answerQuestion(c, ev, i) }, ev.nodeId) - }, modifier = Modifier.fillMaxWidth()) { Text("${i + 1}. $opt", maxLines = 2) } + } else { + // One rule for what the card offers and what the answer path accepts (audit A57). + when (val choices = QuestionChoices.of(ev)) { + is QuestionChoices.Answer -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + choices.rows.forEachIndexed { i, row -> + OutlinedButton(onClick = { + run("Answered.", { c -> QuickActions.answerQuestion(c, ev, i) }, ev.nodeId) + }, modifier = Modifier.fillMaxWidth()) { Text(row, maxLines = 2) } + } + } + // Multi-select: shown so the card says what is asked, but plain text, not + // buttons. The picker's toggle/submit keys are unmeasured, so it is answered + // in the session (QuestionChoices says why). + is QuestionChoices.ReadOnly -> Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + QuestionChoices.SEVERAL_NOTE, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + choices.rows.forEach { row -> + Text( + row, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp) + ) + } + TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } } + QuestionChoices.None -> TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } } - } else { - TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } } } } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt index c77f4510fc..8f8eb1e4d2 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt @@ -4,6 +4,7 @@ import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.QuestionChoices /** * The Inbox card actions (docs/mobile-usage-inbox.md "Quick approve", docs/hook-reply-approvals.md), @@ -14,8 +15,9 @@ import dev.nodeterm.protocol.model.ProjectsSnapshot * 2. a held hook-reply approval (it carries a `pendingId`) is answered DETERMINISTICALLY — the * relay verb or the answer file — never with keys: while the hook holds the request, the prompt * is not on screen and a keystroke would land in the agent's composer; - * 3. only a claude approval with NO ticket falls back to keys (`1` allow / Esc deny), because only - * claude's prompt layout is known; anything else is "open the session". + * 3. keys are typed only for a claude approval with NO ticket (`1` allow / Esc deny), because only + * claude's prompt layout is known, and for a single-select question (its digit); anything else is + * "open the session" (a multi-select question's options are shown, not answered: [QuestionChoices]). * * What "still open" means differs by path, on purpose (audit A38). KEYS need the node to show * exactly the prompt they answer: `1` on a node whose AskUserQuestion picker is on screen picks @@ -52,10 +54,15 @@ object QuickActions { return typeOrOpen(conn, event, if (allow) "1" else "\u001b") } - /** AskUserQuestion: choices are digits on screen (a hook cannot inject an answer value). */ + /** + * AskUserQuestion: choices are digits on screen (a hook cannot inject an answer value). Only a + * question [QuestionChoices] lists as [QuestionChoices.Answer] is typed, the rule the Inbox card + * draws its buttons by. A multi-select question is shown read-only and answered in the session + * (audit A57): how its picker toggles and submits has not been measured, so it gets no keys. + */ suspend fun answerQuestion(conn: HostConnection, event: InboxEvent, optionIndex: Int): Result { - if (event.kind != InboxKind.QUESTION || event.multiSelect) return Result.OPEN_SESSION - if (optionIndex !in event.options.indices || optionIndex > 8) return Result.OPEN_SESSION + val choices = QuestionChoices.of(event) as? QuestionChoices.Answer ?: return Result.OPEN_SESSION + if (optionIndex !in choices.rows.indices || optionIndex > 8) return Result.OPEN_SESSION if (!stillWaiting(conn, event, AgentState.WAITING)) return Result.ALREADY_HANDLED return typeOrOpen(conn, event, (optionIndex + 1).toString()) } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/QuestionChoices.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/QuestionChoices.kt new file mode 100644 index 0000000000..abf87b6a1a --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/QuestionChoices.kt @@ -0,0 +1,39 @@ +package dev.nodeterm.protocol.model + +/** + * What an Inbox question card shows under the question (audit A57), and the one rule + * [dev.nodeterm.protocol.host.QuickActions.answerQuestion] answers by, so the card never offers an + * answer the action would refuse. + * + * A single-select AskUserQuestion is answered from the card: row N types the digit N into the picker. + * A MULTI-select one (`multiSelect`, which the desktop publishes beside the options so the phone can + * show them: src/core/agent-status-mirror.ts, src/core/push-notify.ts) is NOT. Nothing in this repo + * measures how Claude Code's multi-select picker toggles an option or submits the selection, and a + * guessed key sequence typed into a picker the phone cannot see could submit the wrong set, or none. + * Its options are still shown, numbered and read-only, under a line that says the question takes + * several and is answered in the session: before this the card showed only "Open session", so it + * did not say what was being asked. Answering from the Inbox waits for the picker's keys to be + * measured on a live CLI, and would keep the still-waiting re-check `QuickActions` does before typing. + */ +sealed interface QuestionChoices { + /** Each row is a quick answer: tapping row i types the digit i + 1 (`QuickActions.answerQuestion`). */ + data class Answer(val rows: List) : QuestionChoices + + /** The picker takes several options: the rows are shown under [SEVERAL_NOTE] and never answered. */ + data class ReadOnly(val rows: List) : QuestionChoices + + /** No options to show: an approval, a finished turn, or a question the desktop sent without a picker. */ + data object None : QuestionChoices + + companion object { + /** Marks a [ReadOnly] card: its rows are not buttons, and the answer is given in the session. */ + const val SEVERAL_NOTE = "Choose several — answer in the session." + + fun of(event: InboxEvent): QuestionChoices { + if (event.kind != InboxKind.QUESTION || event.options.isEmpty()) return None + // Numbered in the order the question lists them (the desktop keeps that order). + val rows = event.options.mapIndexed { i, option -> "${i + 1}. $option" } + return if (event.multiSelect) ReadOnly(rows) else Answer(rows) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuestionChoicesTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuestionChoicesTest.kt new file mode 100644 index 0000000000..fc6a237854 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuestionChoicesTest.kt @@ -0,0 +1,83 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.QuestionChoices +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * A57: a multi-select AskUserQuestion card showed only "Open session", so the Inbox did not say what + * was being asked. Its options are now shown, numbered and read-only, under a line that says the + * question takes several and is answered in the session; a single-select question keeps its answer + * buttons. The rule is pure and tested here. The card itself cannot run on a JVM, so what it draws for + * each case is pinned in the app's source ([AppSourcePins]); how it looks is a device check. + */ +class QuestionChoicesTest { + private fun event( + kind: InboxKind = InboxKind.QUESTION, + options: List = listOf("Red", "Green", "Blue"), + multiSelect: Boolean = false + ) = InboxEvent( + id = "e1", ts = 1, nodeId = "term-1", agentId = "claude", sessionId = "s", kind = kind, + title = "Which colours?", detail = null, interrupted = false, resolved = false, + options = options, multiSelect = multiSelect, pendingId = null + ) + + @Test + fun `a single-select question is answered from the card, one numbered row per option`() { + assertEquals( + QuestionChoices.Answer(listOf("1. Red", "2. Green", "3. Blue")), + QuestionChoices.of(event()) + ) + } + + @Test + fun `a multi-select question shows its options read-only, numbered in the question's order`() { + assertEquals( + QuestionChoices.ReadOnly(listOf("1. Red", "2. Green", "3. Blue")), + QuestionChoices.of(event(multiSelect = true)) + ) + } + + @Test + fun `nothing is shown without options, or on a card that is not a question`() { + assertEquals(QuestionChoices.None, QuestionChoices.of(event(options = emptyList()))) + assertEquals(QuestionChoices.None, QuestionChoices.of(event(options = emptyList(), multiSelect = true))) + assertEquals(QuestionChoices.None, QuestionChoices.of(event(kind = InboxKind.APPROVAL))) + assertEquals(QuestionChoices.None, QuestionChoices.of(event(kind = InboxKind.DONE))) + } + + private val inbox get() = AppSourcePins.ui("InboxTab.kt") + + @Test + fun `the card draws its options by this rule, not by its own`() { + // The pre-fix gate (`!ev.multiSelect`) dropped a multi-select question's options entirely. + assertFalse(inbox.contains("ev.multiSelect"), "InboxTab decides by QuestionChoices.of, not by the flag") + AppSourcePins.assertInOrder(inbox, "when (val choices = QuestionChoices.of(ev))") + } + + @Test + fun `a single-select row answers through QuickActions`() { + val answer = AppSourcePins.blockAfter(inbox, "is QuestionChoices.Answer ->") + AppSourcePins.assertInOrder(answer, "choices.rows.forEachIndexed", "OutlinedButton(", "QuickActions.answerQuestion(c, ev, i)") + } + + @Test + fun `a multi-select card says it is answered in the session, shows the rows as text, and offers the session`() { + val readOnly = AppSourcePins.blockAfter(inbox, "is QuestionChoices.ReadOnly ->") + AppSourcePins.assertInOrder( + readOnly, + "QuestionChoices.SEVERAL_NOTE", + "choices.rows.forEach", + "Text(", + "TextButton(onClick = { open(ev.nodeId) })", + "Open session" + ) + // No answer path: nothing in the branch types a key or draws a button per option. + assertFalse(readOnly.contains("answerQuestion"), readOnly) + assertFalse(readOnly.contains("OutlinedButton"), readOnly) + assertFalse(readOnly.contains("clickable"), readOnly) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt index 8991ad0bd3..f68073d05d 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt @@ -42,11 +42,12 @@ class QuickActionsTest { kind: InboxKind = InboxKind.APPROVAL, pendingId: String? = "term-1-1700000000000-42", resolved: Boolean = false, - options: List = emptyList() + options: List = emptyList(), + multiSelect: Boolean = false ) = InboxEvent( id = id, ts = 1, nodeId = node, agentId = "claude", sessionId = "s", kind = kind, title = "Approve Bash", detail = null, interrupted = false, resolved = resolved, - options = options, multiSelect = false, pendingId = pendingId + options = options, multiSelect = multiSelect, pendingId = pendingId ) private fun snapshot(state: AgentState?, vararg events: InboxEvent) = ProjectsSnapshot( @@ -196,4 +197,16 @@ class QuickActionsTest { assertEquals(QuickActions.Result.SENT, QuickActions.answerQuestion(conn, question, 1)) assertEquals(listOf("2"), conn.keys) } + + @Test + fun `a multi-select question is never typed, even on a WAITING node`() = runBlocking { + // A57: its card shows the options read-only. The picker's toggle and submit keys are + // unmeasured, so a digit could submit a one-option answer (or toggle without submitting). + val question = card(kind = InboxKind.QUESTION, pendingId = null, options = listOf("a", "b", "c"), multiSelect = true) + val conn = FakeConn(snapshot(AgentState.WAITING, question)) + for (i in question.options.indices) { + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestion(conn, question, i)) + } + assertEquals(emptyList(), conn.keys) + } } diff --git a/docs/android.md b/docs/android.md index e0cf4ccc55..5b24cbe41e 100644 --- a/docs/android.md +++ b/docs/android.md @@ -132,7 +132,11 @@ cover an approval answered after the hook's hold ended (`A06`), and the desktop generated SSH answer command under a real `/bin/sh`. `QuickActionsTest` pins which node state each Inbox quick answer needs (`A38`): a ticketed approval is judged by its card, so it is answered while the node still shows WAITING for a held question; keys are typed only for a ticketless approval on a -BLOCKED node or a question on a WAITING one. `UsagePaceTest` pins the Usages pace line (`A58`): no line +BLOCKED node or a question on a WAITING one. `QuestionChoicesTest` pins what a question card shows +(`A57`): a single-select question keeps its answer buttons, while a multi-select one lists its options +numbered and read-only under "Choose several — answer in the session." beside "Open session", and +`QuickActions` never types into it. The card's drawing is pinned in the source and only type-checked. +`UsagePaceTest` pins the Usages pace line (`A58`): no line without a reset time, 300/10080-minute defaults for the session/weekly kinds (Claude reports no window length), none for an unknown kind, and injected clocks; the elapsed share is taken at the account's `updatedAt` (when the percentage was measured), so a stale snapshot never drifts toward @@ -234,6 +238,12 @@ it is a device check. from the request's `permission_suggestions` (both fields exist in that CLI's hook schema). That changes the `~/.nodeterm/pending` answer file and `approvals.answer`, so it needs the desktop, iOS and Android together. +- **Multi-select questions are answered in the session** (audit `A57`). The Inbox card lists the + options, numbered and read-only, but offers no answer: nothing in this repo measures how Claude + Code's multi-select picker toggles an option or submits the selection, and the phone cannot see the + picker, so a guessed key sequence could submit the wrong set. Answering from the Inbox needs those + keys measured on a live CLI, and would keep the still-waiting re-check the single-select digits + have. Whether iOS answers these cannot be seen from this repo. - **A Sleeping node the desktop has not mounted stays asleep through the relay** (`A76`). The relay attach's wake is the desktop's `wakeHibernatedNode` nudge, which does nothing for a node that is not on screen (a project other than the active one), and the phone offers no wake there because From 52df0a303e8ae5493754ddc883e543ac557c7862 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 16:09:26 +0000 Subject: [PATCH 059/284] fix(android): check relay-token presence without a Keystore decrypt on the host list (A47) The host list labelled each computer "From anywhere" by decrypting its relay device token inside the LazyColumn row: graph.secure.getString(relayTokenKey(id)) != null. That is an AES-GCM open under the Android Keystore key (a binder round trip to keystore2) on the main thread, once per row per recomposition (every ConnState change), and it went through SecretStoreCore's @Synchronized read, so composition also waited behind any connection decrypting at the same moment. It ran even for hosts with no relay block, where routeSummary ignores the answer. The list only needs to know a token is STORED: - SecretStoreCore.contains(name) answers from the storage alone: no sealer.open, and not synchronized, so it never waits on the store's lock. A value that cannot be opened right now still counts (the old check read "no relay" during a keystore hiccup); anything that uses the value still reads it with getBytes. - SecretStoreCore.revision, a StateFlow bumped by every putBytes and remove (never by a read), so a screen can re-ask when a secret comes or goes. - SecureStore exposes contains, hasRelayToken(hostId) and revision. - HostsScreen collects graph.secure.revision and each row computes remember(host.id, secretsRevision) { graph.secure.hasRelayToken(host.id) }. Departure from the audit: the verifier said either a hasRelayToken flag on PairedHost or prefs.contains would update through the graph.hosts.update that follows putString in adoptRelayIfAdvertised. That holds only when the host record changes. When the host already has a relay block but no token (paired with remote access on but no token minted), the adoption's update produces an equal PairedHost, the hosts StateFlow does not emit, and the row would keep its stale label. The revision signal covers that and the forget path without a second copy of the fact: the preferences file stays the only source of truth, so there is no PairedHost field to migrate or keep in sync at the four put/remove sites. The dial paths (relayConfigured, hasRelay, the relay leg of connectLocked, adoptRelayIfAdvertised's early return) still decrypt: they need the token itself, or a readable one, and run once per action rather than per recomposition. Verified: new SecretPresenceTest (protocol, JVM): contains never opens the value; stays true while the keystore fails transiently; answers within 2 s while another thread holds the store's lock inside a blocked decrypt; revision moves on put/remove/identity creation and never on reads; and a source pin on HostsScreen (no secure.getString / getBytes, the revision collect, the remembered hasRelayToken feeding routeSummary). Mutation-checked: contains decrypting fails three tests, contains taking the lock fails the lock test, dropping the revision bumps fails the revision test, and HEAD's HostsScreen fails the pin. Full protocol suite (231 tests) and the offline app type-check pass. Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/data/SecureStore.kt | 14 ++ .../dev/nodeterm/android/ui/HostsScreen.kt | 9 +- .../protocol/secure/SecretStoreCore.kt | 30 +++- .../nodeterm/protocol/SecretPresenceTest.kt | 143 ++++++++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretPresenceTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt index 7ed296ffa5..3d00ecce7b 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt @@ -12,6 +12,7 @@ import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec +import kotlinx.coroutines.flow.StateFlow /** * Secrets at rest: AES-256-GCM under a key that lives in the Android Keystore (it never leaves the @@ -102,6 +103,19 @@ class SecureStore(context: Context) { fun remove(name: String) = core.remove(name) + /** + * A value is stored under [name]: answered from the preferences alone — no Keystore decrypt and + * no waiting on this store's lock — so a screen may ask it while composing (audit A47). What it + * cannot tell is whether the value still opens; anything that uses the value reads it instead. + */ + fun contains(name: String): Boolean = core.contains(name) + + /** This phone holds a relay device token for the computer paired as [hostId] (see [contains]). */ + fun hasRelayToken(hostId: String): Boolean = contains(relayTokenKey(hostId)) + + /** Changes whenever a secret is stored or removed: the key to re-ask [contains] on. */ + val revision: StateFlow get() = core.revision + /** * Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). Replaces a stored * value only when it is provably gone; throws [SecretUnavailableException] when it merely cannot diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt index 9e7a481a28..250a6d37cd 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt @@ -52,6 +52,9 @@ import dev.nodeterm.protocol.model.PairedHost fun HostsScreen(nav: Navigator) { val graph = NodetermApp.graph(LocalContext.current) val hosts by graph.hosts.hosts.collectAsState() + // Re-asks each row's relay-token check when a token is stored or removed (a late relay + // adoption can mint one without changing the host record; a forget drops one). + val secretsRevision by graph.secure.revision.collectAsState() var removing by remember { mutableStateOf(null) } Scaffold( @@ -96,6 +99,10 @@ fun HostsScreen(nav: Navigator) { items(hosts, key = { it.id }) { host -> val session = graph.connections.session(host.id) val state by session.state.collectAsState() + // Whether a token is STORED, never its value: decrypting it here was a Keystore + // round trip on the main thread per row per recomposition, and it waited on + // SecureStore's lock behind any connection decrypting meanwhile (audit A47). + val relayTokenStored = remember(host.id, secretsRevision) { graph.secure.hasRelayToken(host.id) } Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), modifier = Modifier @@ -118,7 +125,7 @@ fun HostsScreen(nav: Navigator) { color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - routeSummary(host, graph.secure.getString(SecureStore.relayTokenKey(host.id)) != null) + + routeSummary(host, relayTokenStored) + stateSuffix(state), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt index 72a638d6aa..a75ae8c328 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt @@ -1,5 +1,9 @@ package dev.nodeterm.protocol.secure +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.security.SecureRandom import java.util.Base64 import javax.crypto.AEADBadTagException @@ -44,6 +48,26 @@ class SecretStoreCore(private val storage: SecretStorage, private val sealer: Se data class Lost(val reason: String) : Read } + private val _revision = MutableStateFlow(0L) + + /** + * Bumped by every write and removal (never by a read), so a screen that shows [contains] can ask + * again when a secret comes or goes — a relay token minted by a late adoption, one dropped when + * its computer is forgotten — even when nothing else it shows has changed (audit A47). + */ + val revision: StateFlow = _revision.asStateFlow() + + /** + * Whether a value is STORED under [name], from the storage alone: nothing is opened, so no + * Keystore round trip, and this store's lock is not taken, so the answer never waits behind a + * decrypt another thread is in the middle of (audit A47 — the host list asked this per row, per + * recomposition, on the main thread, by decrypting the relay token). "Stored" is what a label + * needs: a value that cannot be read right now still counts (the keystore will answer later), + * and so does one the Keystore can no longer open at all (its key was lost), which only opening + * it can tell. Anything that USES the value reads it with [getBytes]. + */ + fun contains(name: String): Boolean = storage.get(name) != null + @Synchronized fun read(name: String): Read { val raw = storage.get(name) ?: return Read.Absent @@ -68,10 +92,14 @@ class SecretStoreCore(private val storage: SecretStorage, private val sealer: Se @Synchronized fun putBytes(name: String, value: ByteArray, durable: Boolean = false) { storage.put(name, Base64.getEncoder().encodeToString(sealer.seal(value)), durable) + _revision.update { it + 1 } } @Synchronized - fun remove(name: String) = storage.remove(name) + fun remove(name: String) { + storage.remove(name) + _revision.update { it + 1 } + } /** * Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). Creates only when diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretPresenceTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretPresenceTest.kt new file mode 100644 index 0000000000..09195c6598 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretPresenceTest.kt @@ -0,0 +1,143 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.secure.SecretStorage +import dev.nodeterm.protocol.secure.SecretStoreCore +import dev.nodeterm.protocol.secure.SecretUnavailableException +import dev.nodeterm.protocol.secure.Sealer +import java.security.ProviderException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Audit A47: the host list decided "From anywhere" by DECRYPTING each computer's relay token — an + * Android Keystore round trip on the main thread, per row, per recomposition, and one that waited + * on the store's lock behind any connection decrypting at the same moment. The list only needs to + * know a token is stored. [SecretStoreCore.contains] answers that from the storage alone, and + * [SecretStoreCore.revision] tells the list when to ask again. + * + * The Compose row itself cannot run on a JVM, so its wiring is pinned in the app's source + * ([AppSourcePins]); nothing here has run on a device. + */ +class SecretPresenceTest { + private class MemStorage : SecretStorage { + val map = HashMap() + override fun get(name: String) = map[name] + override fun put(name: String, value: String, durable: Boolean) { + map[name] = value + } + override fun remove(name: String) { + map.remove(name) + } + } + + /** A reversible stand-in cipher that counts opens and can fail or block like a busy keystore. */ + private class CountingSealer : Sealer { + var opens = 0 + var failOpenWith: Exception? = null + var onOpen: (() -> Unit)? = null + override fun seal(plain: ByteArray): ByteArray = ByteArray(12) + plain.map { (it.toInt() xor 0x5a).toByte() } + ByteArray(16) + override fun open(sealed: ByteArray): ByteArray { + opens++ + onOpen?.invoke() + failOpenWith?.let { throw it } + return sealed.copyOfRange(12, sealed.size - 16).map { (it.toInt() xor 0x5a).toByte() }.toByteArray() + } + } + + private val storage = MemStorage() + private val sealer = CountingSealer() + private val store = SecretStoreCore(storage, sealer) + private val token = "relay.token.h1" + + @Test + fun `presence is answered without opening the value`() { + assertFalse(store.contains(token)) + store.putBytes(token, "tok".toByteArray()) + assertTrue(store.contains(token)) + assertEquals(0, sealer.opens, "contains opened the stored value") + store.remove(token) + assertFalse(store.contains(token)) + assertEquals(0, sealer.opens) + } + + @Test + fun `a token the keystore cannot open right now is still stored`() { + // The old check read "no relay" during a keystore hiccup; the token is still there. + store.putBytes(token, "tok".toByteArray()) + sealer.failOpenWith = ProviderException("Keystore operation failed") + assertFailsWith { store.getBytes(token) } + assertTrue(store.contains(token)) + } + + @Test + fun `presence does not wait behind a decrypt on another thread`() { + store.putBytes(token, "tok".toByteArray()) + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + sealer.onOpen = { + entered.countDown() + release.await(10, TimeUnit.SECONDS) + } + // A connection decrypting the token, holding the store's lock for as long as the keystore takes. + val reader = Thread { runCatching { store.getBytes(token) } }.apply { start() } + val ui = Executors.newSingleThreadExecutor() + try { + assertTrue(entered.await(5, TimeUnit.SECONDS), "the reader never reached the keystore") + val answer = ui.submit { store.contains(token) } + // Throws TimeoutException when contains waits for the lock the reader holds. + assertTrue(answer.get(2, TimeUnit.SECONDS)) + } finally { + release.countDown() + reader.join(5_000) + ui.shutdownNow() + } + } + + @Test + fun `the revision moves when a secret is stored or removed, never when one is read`() { + val start = store.revision.value + store.putBytes(token, "tok".toByteArray()) + val afterPut = store.revision.value + assertTrue(afterPut > start, "storing a token did not move the revision") + + store.getBytes(token) + store.read(token) + store.contains(token) + assertEquals(afterPut, store.revision.value, "a read moved the revision") + + store.remove(token) + val afterRemove = store.revision.value + assertTrue(afterRemove > afterPut, "removing a token did not move the revision") + + store.getOrCreate32("box.secret") + val afterCreate = store.revision.value + assertTrue(afterCreate > afterRemove, "creating the identity did not move the revision") + store.getOrCreate32("box.secret") + assertEquals(afterCreate, store.revision.value, "reading back the identity moved the revision") + } + + private val hostsScreen get() = AppSourcePins.ui("HostsScreen.kt") + + @Test + fun `the host list asks whether a token is stored, never for its value`() { + val src = hostsScreen + assertFalse(src.contains("secure.getString"), "HostsScreen decrypts a secret") + assertFalse(src.contains("secure.getBytes"), "HostsScreen decrypts a secret") + // The row re-asks when the store's revision moves: a late relay adoption can mint a token + // without changing the host record, so the host list alone would not recompose the row. + assertTrue(src.contains("val secretsRevision by graph.secure.revision.collectAsState()")) + assertTrue(src.contains("items(hosts, key = { it.id }) { host ->")) + val row = AppSourcePins.blockAfter(src, "}) { host ->") // the row's body, not the key lambda + AppSourcePins.assertInOrder( + row, + "val relayTokenStored = remember(host.id, secretsRevision) { graph.secure.hasRelayToken(host.id) }", + "routeSummary(host, relayTokenStored)" + ) + } +} From 3780f5a719eb49d4a9f8e163bac5814442bc5005 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 16:15:10 +0000 Subject: [PATCH 060/284] fix(android): keep command text and the agent's last message out of notifications by default (A52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An approval notification carried the desktop's tool summary (the command's first line, a file path with its diff size, a fetched URL; without a summary, the agent's last line), a question the question itself, and a finished turn the agent's last message. Nothing set a public version, and Android shows a notification's full content on a secure lock screen under its default setting. What a notification says is now the pure InboxNotificationText (android/protocol). By default it carries none of the event's own text: the title keeps the session and whether it needs you or completed ("Needs you — " / "Completed — "), the text names the kind ("Needs approval", "Has a question", "Finished", "Interrupted"), and there is no expanded text. The event's title and detail are added only when the user turns on the new Settings switch "Show details in notifications" (HostStore.notificationDetails, off by default). A public version with only the title and the computer's name is always set, with VISIBILITY_PRIVATE made explicit. Following the verifier, the fix does not rely on VISIBILITY_PRIVATE plus setPublicVersion alone: that hides nothing under Android's default lock-screen setting, so the detail is left out of the private content itself. The audit proposed calling the switch "Show details on lock screen"; it is "Show details in notifications", because it controls the notification, which the lock screen then shows or hides by Android's own setting. The event's title is also hidden by default, not only its detail: for an approval without a tool summary, and for a question, the title is itself the agent's text. docs/android.md and android/README.md now note that the iOS app gets the same detail through the desktop's APNs push body (src/core/push-notify.ts), a difference in platform defaults. Verified: InboxNotificationTextTest (9 tests) pins the words for each kind with details off and on, the public version, and the session fallback, and pins in the app's source that InboxNotifier reads every word from InboxNotificationText (never ev.title/ev.detail), sets the public version, and that the setting defaults to off and is stored only from its switch. Mutation check: with the old InboxNotifier, the detail always included, and the default flipped to on, 5 of the 9 tests fail. Full protocol suite green (240 tests); offline app type-check green (NotificationCompat stub gained setVisibility, setPublicVersion and the VISIBILITY_* constants, as in androidx.core). Not run on a device: what a lock screen shows is a device check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 7 + .../dev/nodeterm/android/data/HostStore.kt | 12 ++ .../nodeterm/android/notify/InboxNotifier.kt | 44 +++-- .../dev/nodeterm/android/ui/SettingsScreen.kt | 18 ++ .../protocol/model/InboxNotificationText.kt | 67 +++++++ .../dev/nodeterm/protocol/AppSourcePins.kt | 6 +- .../protocol/InboxNotificationTextTest.kt | 184 ++++++++++++++++++ .../androidx/core/app/NotificationCompat.java | 5 + docs/android.md | 14 ++ 9 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt diff --git a/android/README.md b/android/README.md index 99853b5b65..be6a67c790 100644 --- a/android/README.md +++ b/android/README.md @@ -82,6 +82,13 @@ Each Inbox event raises at most one notification: only events younger than 6 hou and the phone remembers the ones it has announced or you have read for a day after it last saw them (longer when the computer's clock runs ahead), so nothing still eligible is forgotten. +A notification names the session, the computer and the kind of event ("Needs you — build-bot", +"Needs approval"), but not the event's own text: the command, file or question, or the agent's last +message. Android shows a notification's full content on a secure lock screen unless you hide +sensitive content there. **Settings → Show details in notifications** (off by default) adds that +text. A lock screen set to hide sensitive content shows only "Needs you — " and the +computer's name either way. The iOS app does receive the detail, in the push the desktop sends. + ## Security - The phone's relay identity (a Curve25519 box key) and SSH identity (an Ed25519 seed) are diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt index 7a4e7aeec1..1a220a1a49 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt @@ -99,6 +99,18 @@ class HostStore(context: Context) { prefs.edit().putBoolean("notify", value).apply() } + /** + * "Show details in notifications": put the event's own text (the command, file or question and + * the agent's last message) in its notification. OFF by default, because Android shows a + * notification's full content on a secure lock screen unless the user hides sensitive content + * (audit A52; the words are dev.nodeterm.protocol.model.InboxNotificationText's). + */ + var notificationDetails: Boolean + get() = prefs.getBoolean("notifyDetails", false) + set(value) { + prefs.edit().putBoolean("notifyDetails", value).apply() + } + var fontSize: Int get() = prefs.getInt("fontSize", 13) set(value) { diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt index f0dd875a0b..74d2c90cb5 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -24,6 +24,7 @@ import dev.nodeterm.android.R import dev.nodeterm.protocol.host.RelayApprovalGate import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.InboxNotificationText import dev.nodeterm.protocol.model.PairedHost import dev.nodeterm.protocol.model.ProjectsSnapshot import kotlinx.coroutines.withTimeoutOrNull @@ -84,19 +85,30 @@ object InboxNotifier { val fresh = graph.hosts.claimAnnounceable(snapshot.status?.inbox?.events.orEmpty()) if (fresh.isEmpty()) return 0 val nm = NotificationManagerCompat.from(context) + val showDetails = graph.hosts.notificationDetails for (ev in fresh.takeLast(5)) { - nm.notify("${host.id}:${ev.id}".hashCode(), build(context, host, snapshot, ev)) + nm.notify("${host.id}:${ev.id}".hashCode(), build(context, host, snapshot, ev, showDetails)) } return fresh.size } - private fun build(context: Context, host: PairedHost, snapshot: ProjectsSnapshot, ev: InboxEvent): android.app.Notification { + /** + * The event's own text (the command, file or question, the agent's last message) is left out + * unless the user opted in: Android shows a notification's full content on a secure lock screen + * under its default setting, and a public version changes that only for users who hide sensitive + * content (audit A52). The words are [InboxNotificationText]'s; the public version is always set. + */ + private fun build( + context: Context, + host: PairedHost, + snapshot: ProjectsSnapshot, + ev: InboxEvent, + showDetails: Boolean + ): android.app.Notification { val node = snapshot.findNode(ev.nodeId)?.second - val nodeTitle = snapshot.statusOf(ev.nodeId)?.name ?: node?.title ?: "Session" - val headline = when (ev.kind) { - InboxKind.APPROVAL, InboxKind.QUESTION -> "Needs you — $nodeTitle" - InboxKind.DONE -> "Completed — $nodeTitle" - } + val session = snapshot.statusOf(ev.nodeId)?.name?.takeIf { it.isNotBlank() } ?: node?.title + val words = InboxNotificationText.of(ev, session, host.name, showDetails) + val channel = if (ev.kind == InboxKind.DONE) CH_DONE else CH_ATTENTION val open = PendingIntent.getActivity( context, host.id.hashCode(), @@ -105,16 +117,24 @@ object InboxNotifier { .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - return NotificationCompat.Builder(context, if (ev.kind == InboxKind.DONE) CH_DONE else CH_ATTENTION) + val publicVersion = NotificationCompat.Builder(context, channel) .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle(headline) - .setContentText(ev.title + (ev.detail?.let { " — $it" } ?: "")) - .setStyle(NotificationCompat.BigTextStyle().bigText(listOfNotNull(ev.title, ev.detail).joinToString("\n"))) + .setContentTitle(words.publicTitle) + .setContentText(words.publicText) + .setWhen(ev.ts) + .build() + val builder = NotificationCompat.Builder(context, channel) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(words.title) + .setContentText(words.text) .setSubText(host.name) .setWhen(ev.ts) .setAutoCancel(true) .setContentIntent(open) - .build() + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) + words.bigText?.let { builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) } + return builder.build() } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt index 245dbb6ab6..459088f1c0 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -61,6 +61,7 @@ fun SettingsScreen(nav: Navigator) { var name by remember { mutableStateOf(graph.hosts.deviceName) } var apiBase by remember { mutableStateOf(graph.hosts.apiBase) } var notify by remember { mutableStateOf(graph.hosts.notificationsEnabled) } + var notifyDetails by remember { mutableStateOf(graph.hosts.notificationDetails) } // Whether the phone will actually SHOW them, re-read whenever the screen starts (the user may // have come back from the system settings). The switch used to read On while nothing could // arrive (audit A21). @@ -161,6 +162,23 @@ fun SettingsScreen(nav: Navigator) { TextButton(onClick = { openNotificationSettings() }) { Text("Open settings") } } } + // Off by default: the event's own text reaches the lock screen under Android's default + // setting, and a public version does not change that (audit A52). + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Show details in notifications") + Text( + "Adds the command, file or question and the agent's last message. Android then shows them " + + "on the lock screen too, unless you hide sensitive notification content there.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = notifyDetails, enabled = notify, onCheckedChange = { + notifyDetails = it + graph.hosts.notificationDetails = it + }) + } if (hosts.isNotEmpty()) { HorizontalDivider() diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt new file mode 100644 index 0000000000..5f83b31f24 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt @@ -0,0 +1,67 @@ +package dev.nodeterm.protocol.model + +/** + * What an Inbox notification says (audit A52). + * + * An event's own text is the sensitive part. For an approval, [InboxEvent.title] and + * [InboxEvent.detail] are the desktop's tool summary: the command's first line, a file path with its + * diff size, a fetched URL (`buildApprovalSummary`, src/core/agent-status-mirror.ts). Without that + * summary the title is the first line of the agent's last message. For a question it is the question + * itself, and for a finished turn the detail is the agent's last message. Android shows a + * notification's full content on a secure lock screen unless the user has turned on "hide sensitive + * content", and that is not the default. A public version alone therefore hides nothing in the + * default case. So by default the notification carries none of that text: the title names the + * session and whether it needs you or completed, and the text names the kind of event. The event's + * own text is added only when the user opts in ("Show details in notifications"). + * + * The public version (what a lock screen that hides sensitive content shows) is built here too, and + * never carries the event's text, whatever the setting. + * + * The iOS app does get the detail: the desktop sends the same title and detail in the APNs push body + * (src/core/push-notify.ts), and what iOS then shows on its lock screen is up to iOS's own preview + * setting. That is a difference in platform defaults, not an Android-only leak; this app does not + * copy it. + */ +data class InboxNotificationText( + val title: String, + val text: String, + /** The expanded text (BigTextStyle), or null when the notification has none. */ + val bigText: String?, + /** The public version's title: [title]'s session and kind, nothing from the event. */ + val publicTitle: String, + /** The public version's text: the computer's name. */ + val publicText: String +) { + companion object { + /** The session's name when the listing has none. */ + const val FALLBACK_SESSION = "Session" + + /** + * [sessionName] is the session's name as the listing gives it (null or blank = unknown), + * [computerName] the paired computer's. [showDetails] is the user's opt-in; off by default. + */ + fun of(event: InboxEvent, sessionName: String?, computerName: String, showDetails: Boolean): InboxNotificationText { + val session = sessionName?.trim()?.takeIf { it.isNotEmpty() } ?: FALLBACK_SESSION + val headline = when (event.kind) { + InboxKind.APPROVAL, InboxKind.QUESTION -> "Needs you — $session" + InboxKind.DONE -> "Completed — $session" + } + val kind = kindLabel(event) + val own = if (showDetails) listOf(event.title, event.detail.orEmpty()).map { it.trim() }.filter { it.isNotEmpty() } else emptyList() + return InboxNotificationText( + title = headline, + text = own.joinToString(" — ").ifEmpty { kind }, + bigText = own.takeIf { it.isNotEmpty() }?.joinToString("\n"), + publicTitle = headline, + publicText = computerName + ) + } + + /** The kind of event in words, as the Inbox card's chip says it. Never the event's own text. */ + fun kindLabel(event: InboxEvent): String = when (event.kind) { + InboxKind.APPROVAL -> "Needs approval" + InboxKind.QUESTION -> "Has a question" + InboxKind.DONE -> if (event.interrupted) "Interrupted" else "Finished" + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt index 70193aa1d1..7497be66f0 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt @@ -9,10 +9,14 @@ import kotlin.test.assertTrue * depends on, in the order it depends on them. */ object AppSourcePins { - val appUi = File(InteropHarness.repoRoot, "android/app/src/main/kotlin/dev/nodeterm/android/ui") + val appSrc = File(InteropHarness.repoRoot, "android/app/src/main/kotlin/dev/nodeterm/android") + val appUi = File(appSrc, "ui") fun ui(file: String): String = File(appUi, file).readText() + /** A source file under the app's package root, e.g. `notify/InboxNotifier.kt`. */ + fun app(path: String): String = File(appSrc, path).readText() + /** The block that follows the first occurrence of [start] in [source], braces balanced. */ fun blockAfter(source: String, start: String): String { val at = source.indexOf(start) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt new file mode 100644 index 0000000000..94375b3023 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt @@ -0,0 +1,184 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.InboxNotificationText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A52: approval and finish notifications put the command text and the agent's last message in the + * notification, and so on the lock screen: Android shows a notification's full content there unless + * the user hides sensitive content, which is not the default. Now the event's own text is left out + * unless the user opts in ("Show details in notifications", off by default), and a public version + * that names only the session and the computer is always set. + * + * What the notification says is pure and tested here. The notification itself cannot be built on a + * JVM, so the wiring (every word comes from [InboxNotificationText], the public version is set, the + * setting defaults to off) is pinned in the app's source ([AppSourcePins]). How a lock screen shows + * it is a device check. + */ +class InboxNotificationTextTest { + private fun event( + kind: InboxKind, + title: String, + detail: String? = null, + interrupted: Boolean = false + ) = InboxEvent( + id = "e1", ts = 1, nodeId = "term-1", agentId = "claude", sessionId = "s", kind = kind, + title = title, detail = detail, interrupted = interrupted, resolved = false, + options = emptyList(), multiSelect = false, pendingId = null + ) + + // Shapes the desktop sends (src/core/agent-status-mirror.ts): a Bash approval's summary, a Write, + // an approval with no summary (its title is the agent's last line), a question, a finished turn. + private val bash = event(InboxKind.APPROVAL, "Run command", "curl -H 'Authorization: Bearer s3cret' https://api.example.com …") + private val write = event(InboxKind.APPROVAL, "Write id_rsa", "/home/me/.ssh/id_rsa (27 lines)") + private val bare = event(InboxKind.APPROVAL, "I'll now delete the customer table in prod.") + private val question = event(InboxKind.QUESTION, "Which of the leaked keys should I rotate first?") + private val done = event(InboxKind.DONE, "Finished", "The password for the staging database is hunter2.") + private val stopped = event(InboxKind.DONE, "Stopped", "Half-way through the migration.", interrupted = true) + + private fun InboxNotificationText.all() = listOfNotNull(title, text, bigText, publicTitle, publicText) + + private fun assertCarriesNothingOf(ev: InboxEvent, words: InboxNotificationText) { + for (secret in listOfNotNull(ev.title, ev.detail)) { + // A DONE title is the desktop's own "Finished"/"Stopped"; the kind label may say the same + // word, which is not the event's text leaking. Everything else must not appear at all. + if (ev.kind == InboxKind.DONE && secret == ev.title) continue + for (field in words.all()) assertFalse(field.contains(secret), "`$secret` is in `$field`") + } + } + + @Test + fun `by default an approval says only that the session needs approval`() { + for (ev in listOf(bash, write, bare)) { + val words = InboxNotificationText.of(ev, "build-bot", "Studio Mac", showDetails = false) + assertEquals("Needs you — build-bot", words.title) + assertEquals("Needs approval", words.text) + assertNull(words.bigText, "no expanded text: it would carry the command") + assertCarriesNothingOf(ev, words) + } + } + + @Test + fun `by default a question does not show the question`() { + val words = InboxNotificationText.of(question, "build-bot", "Studio Mac", showDetails = false) + assertEquals("Needs you — build-bot", words.title) + assertEquals("Has a question", words.text) + assertNull(words.bigText) + assertCarriesNothingOf(question, words) + } + + @Test + fun `by default a finished turn does not show the agent's last message`() { + val words = InboxNotificationText.of(done, "build-bot", "Studio Mac", showDetails = false) + assertEquals("Completed — build-bot", words.title) + assertEquals("Finished", words.text) + assertNull(words.bigText) + assertCarriesNothingOf(done, words) + + val interrupted = InboxNotificationText.of(stopped, "build-bot", "Studio Mac", showDetails = false) + assertEquals("Completed — build-bot", interrupted.title) + assertEquals("Interrupted", interrupted.text) + assertCarriesNothingOf(stopped, interrupted) + } + + @Test + fun `the public version names only the session and the computer, with or without details`() { + for (ev in listOf(bash, write, bare, question, done, stopped)) { + for (details in listOf(false, true)) { + val words = InboxNotificationText.of(ev, "build-bot", "Studio Mac", showDetails = details) + assertEquals(words.title, words.publicTitle) + assertEquals("Studio Mac", words.publicText) + for (secret in listOfNotNull(ev.title, ev.detail).filterNot { ev.kind == InboxKind.DONE && it == ev.title }) { + assertFalse(words.publicTitle.contains(secret) || words.publicText.contains(secret), "public carries `$secret`") + } + } + } + val attention = InboxNotificationText.of(bash, "build-bot", "Studio Mac", showDetails = true) + assertEquals("Needs you — build-bot", attention.publicTitle) + assertEquals("Completed — build-bot", InboxNotificationText.of(done, "build-bot", "Studio Mac", true).publicTitle) + } + + @Test + fun `with details on, the notification carries the event's text as before`() { + val words = InboxNotificationText.of(bash, "build-bot", "Studio Mac", showDetails = true) + assertEquals("Needs you — build-bot", words.title) + assertEquals("Run command — ${bash.detail}", words.text) + assertEquals("Run command\n${bash.detail}", words.bigText) + + val q = InboxNotificationText.of(question, "build-bot", "Studio Mac", showDetails = true) + assertEquals(question.title, q.text) + assertEquals(question.title, q.bigText) + + val d = InboxNotificationText.of(done, "build-bot", "Studio Mac", showDetails = true) + assertEquals("Finished — ${done.detail}", d.text) + assertEquals("Finished\n${done.detail}", d.bigText) + } + + @Test + fun `with details on but no text in the event, the kind is said instead of nothing`() { + val empty = event(InboxKind.APPROVAL, "", " ") + val words = InboxNotificationText.of(empty, "build-bot", "Studio Mac", showDetails = true) + assertEquals("Needs approval", words.text) + assertNull(words.bigText) + } + + @Test + fun `a session with no name is called Session`() { + for (name in listOf(null, "", " ")) { + val words = InboxNotificationText.of(bash, name, "Studio Mac", showDetails = false) + assertEquals("Needs you — ${InboxNotificationText.FALLBACK_SESSION}", words.title) + assertEquals(words.title, words.publicTitle) + } + } + + private val notifier get() = AppSourcePins.app("notify/InboxNotifier.kt") + + @Test + fun `the notification takes every word from InboxNotificationText and always sets the public version`() { + val build = AppSourcePins.blockAfter(notifier, "private fun build(") + // The pre-fix builder wrote `ev.title + ev.detail` into the text and the BigTextStyle. + assertFalse(build.contains("ev.title"), "the notification reads the event's title itself:\n$build") + assertFalse(build.contains("ev.detail"), "the notification reads the event's detail itself:\n$build") + AppSourcePins.assertInOrder( + build, + "val words = InboxNotificationText.of(ev, session, host.name, showDetails)", + "val publicVersion = NotificationCompat.Builder(context, channel)", + ".setContentTitle(words.publicTitle)", + ".setContentText(words.publicText)", + ".build()", + ".setContentTitle(words.title)", + ".setContentText(words.text)", + ".setVisibility(NotificationCompat.VISIBILITY_PRIVATE)", + ".setPublicVersion(publicVersion)", + "words.bigText?.let { builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) }" + ) + val announce = AppSourcePins.blockAfter(notifier, "fun announce(") + AppSourcePins.assertInOrder( + announce, + "val showDetails = graph.hosts.notificationDetails", + "build(context, host, snapshot, ev, showDetails)" + ) + } + + @Test + fun `the details setting is off until the user turns it on in Settings`() { + val store = AppSourcePins.app("data/HostStore.kt") + assertTrue(store.contains("""get() = prefs.getBoolean("notifyDetails", false)"""), "the setting does not default to off") + val settings = AppSourcePins.ui("SettingsScreen.kt") + assertTrue(settings.contains("var notifyDetails by remember { mutableStateOf(graph.hosts.notificationDetails) }")) + AppSourcePins.assertInOrder( + settings, + "Text(\"Show details in notifications\")", + "Switch(checked = notifyDetails", + "graph.hosts.notificationDetails = it" + ) + // Stored only from the switch. + assertEquals(1, Regex("""graph\.hosts\.notificationDetails\s*=(?!=)""").findAll(settings).count()) + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java index 3ddb3ab798..6826e26905 100644 --- a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java +++ b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java @@ -3,6 +3,9 @@ import android.app.PendingIntent; import android.content.Context; public class NotificationCompat { + public static final int VISIBILITY_PUBLIC = Notification.VISIBILITY_PUBLIC; + public static final int VISIBILITY_PRIVATE = Notification.VISIBILITY_PRIVATE; + public static final int VISIBILITY_SECRET = Notification.VISIBILITY_SECRET; public abstract static class Style {} public static class BigTextStyle extends Style { public BigTextStyle bigText(CharSequence cs) { return this; } } public static class Builder { @@ -15,6 +18,8 @@ public Builder(Context context, String channelId) {} public Builder setWhen(long when) { return this; } public Builder setAutoCancel(boolean autoCancel) { return this; } public Builder setContentIntent(PendingIntent intent) { return this; } + public Builder setVisibility(int visibility) { return this; } + public Builder setPublicVersion(Notification n) { return this; } public Notification build() { return null; } } } diff --git a/docs/android.md b/docs/android.md index 5b24cbe41e..84f1bf22ae 100644 --- a/docs/android.md +++ b/docs/android.md @@ -210,6 +210,20 @@ not is left unstored, the field says so while it is being typed, and leaving sho address rule is unit-tested; the wiring is pinned in the source, and whether the back gesture reaches it is a device check. +`InboxNotificationTextTest` pins what an Inbox notification says (`A52`). An approval's notification +used to carry the desktop's tool summary (the command's first line, a file path, a fetched URL), and +a finished turn's the agent's last message. Android shows a notification's full content on a secure +lock screen under its default setting, and a public version changes that only for users who hide +sensitive content, so the event's own text is now left out unless the user turns on **Settings → +Show details in notifications** (off by default). The title keeps the session and whether it needs +you or completed ("Needs you — build-bot"), and the text says the kind ("Needs approval", "Has a +question", "Finished", "Interrupted"). A public version with only that title and the computer's name +is always set. The iOS app does get the detail: the desktop sends the event's title and detail in +the APNs push body (`src/core/push-notify.ts`), and what iOS shows on its lock screen is iOS's own +preview setting. That is a difference in platform defaults, not an Android-only leak. The words are +unit-tested; the notification's use of them and the setting's default are pinned in the source, and +what a lock screen shows is a device check. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From e055f3711c940ac69c964e6c037d0dec136d0aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 16:26:58 +0000 Subject: [PATCH 061/284] fix(android): announce live for the computer whose screen is open (A73) The Settings switch, the README and InboxNotifier's comment all promised notifications "live every 8 seconds while a computer is open", but InboxNotifier.announce had one caller: the 15-minute WorkManager job. The in-app 8 s poll only updated the listing. What changed: - HostSession.refreshNow now runs the announce path on every listing that arrives (the 8 s poll of the computer on screen, a change the computer pushed, the background check). The call sits outside the listing's try, so a notification the system refuses is never read as a transport failure (which would drop a working connection). No network is added: the listing already arrived. - What the user is looking at is left out and recorded as seen: every event while that computer's Inbox tab is on screen, and the events of the session open in a terminal. A new OnScreenTracker on HostSession counts those screens (InboxTab and TerminalScreen register while STARTED; the terminal registers before its watch starts, so the first listing knows). - The decision is in protocol: OnScreen.split (pure) and SeenLog.claimLive, which records the on-screen events and claims the rest in one locked step. It records on-screen events even with notifications off or not allowed (A21's canPost + the 13+ permission), so turning them on later does not announce what the user already looked at. It writes only what is new and skips the read entirely when nothing in the feed is announceable, so the 8 s path stays cheap. - The worker no longer announces a second time from session.snapshot.value: a listing announces itself, with the same on-screen view, and a failed one has nothing new. - Copy fixed in Settings, the InboxNotifier KDoc and android/README.md: "Checked about every 15 minutes in the background, and live for the computer whose screen is open"; the README now says other computers are not polled. docs/android.md gets a paragraph on the test. Verification: new LiveNotificationsTest (16 tests: the split, claimLive against the background check, shown-then-left, terminal node, notifications off, no-write on repeat, tracker counting and idempotent close, plus source pins for the refresh, announce, worker, both screens and the three copy sites). Mutation-checked: restoring the pre-fix app wiring and copy from HEAD fails 5 tests; dropping the inbox branch of OnScreen.shows, ignoring onScreen in claimLive, returning early when notify is false, re-recording known ids, making close non-idempotent, removing the refresh's announce, restoring the worker's announce, dropping the Inbox tab's registration and reverting the Settings copy each fail at least one test. Full protocol suite (256 tests) and the offline app type-check pass. Nothing has run on a device. Departures from the audit's proposal: announce runs from refreshNow for every successful listing rather than only from the poll loop, so push-triggered refreshes and the worker share one path and the worker also skips what is on screen; the worker's stale-snapshot announce after a failed refresh is dropped. HostStore.claimAnnounceable is replaced by claimLive (SeenLog.claimAnnounceable stays, as the nothing-on-screen case). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 16 +- .../dev/nodeterm/android/NodetermApp.kt | 14 + .../android/conn/ConnectionManager.kt | 29 +- .../dev/nodeterm/android/data/HostStore.kt | 15 +- .../nodeterm/android/notify/InboxNotifier.kt | 40 ++- .../dev/nodeterm/android/ui/InboxTab.kt | 7 + .../dev/nodeterm/android/ui/SettingsScreen.kt | 2 +- .../dev/nodeterm/android/ui/TerminalScreen.kt | 5 + .../dev/nodeterm/protocol/model/OnScreen.kt | 77 +++++ .../dev/nodeterm/protocol/model/SeenLog.kt | 45 ++- .../protocol/LiveNotificationsTest.kt | 294 ++++++++++++++++++ docs/android.md | 14 + 12 files changed, 526 insertions(+), 32 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt diff --git a/android/README.md b/android/README.md index be6a67c790..94bf74789c 100644 --- a/android/README.md +++ b/android/README.md @@ -74,13 +74,19 @@ Try again or open that computer. The iOS app is woken by APNs pushes the nodeterm backend sends. That backend has no Android (FCM) -leg, so this app checks each computer's Inbox periodically in the background (WorkManager's floor -is ~15 minutes) and live every 8 seconds while a computer is open. Real-time push on Android needs -an FCM leg in the backend. +leg, so this app polls instead: checked about every 15 minutes in the background (WorkManager's +floor), and live for the computer whose screen is open, which the app re-lists every 8 seconds. +Other paired computers are not polled while you look at one, so their notifications still come from +the background check only. Real-time push on Android needs an FCM leg in the backend. + +The live check leaves out what you are looking at: nothing of a computer is announced while its +Inbox tab is on screen, and nothing of a session open in a terminal. Those events count as seen, so +no later check announces them either. Each Inbox event raises at most one notification: only events younger than 6 hours are announced, -and the phone remembers the ones it has announced or you have read for a day after it last saw them -(longer when the computer's clock runs ahead), so nothing still eligible is forgotten. +and the phone remembers the ones it has announced, you have read or you had on screen for a day +after it last saw them (longer when the computer's clock runs ahead), so nothing still eligible is +forgotten. A notification names the session, the computer and the kind of event ("Needs you — build-bot", "Needs approval"), but not the event's own text: the command, file or question, or the agent's last diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt b/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt index 4f94bb79db..358c0bee6f 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt @@ -8,6 +8,8 @@ import dev.nodeterm.android.data.SecureStore import dev.nodeterm.android.notify.InboxNotifier import dev.nodeterm.protocol.crypto.BoxKeyPair import dev.nodeterm.protocol.host.RelayApprovalGate +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.ProjectsSnapshot import dev.nodeterm.protocol.pairing.SshIdentity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,6 +32,18 @@ class AppGraph(context: Context) { /** The phone's SSH identity (Ed25519); its public half is in each computer's authorized_keys. */ val sshIdentity: SshIdentity by lazy { SshIdentity.fromSeed(secure.getOrCreate32(SecureStore.SSH_SEED)) } + + private val appContext: Context = context.applicationContext + + /** + * A fresh listing of [hostId] arrived (HostSession.refreshNow): notify about its new Inbox events, + * minus what [onScreen] shows (audit A73). One path for the 8 s refresh of the computer on screen + * and the background check alike. + */ + fun announce(hostId: String, snapshot: ProjectsSnapshot, onScreen: OnScreen) { + val host = hosts.get(hostId) ?: return + InboxNotifier.announce(appContext, host, snapshot, onScreen) + } } class NodetermApp : Application() { diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index d74ddadadd..e3ae31b96a 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -11,6 +11,7 @@ import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.RelayConnector import dev.nodeterm.protocol.host.TransportKind import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.OnScreenTracker import dev.nodeterm.protocol.model.PairedHost import dev.nodeterm.protocol.model.ProjectsSnapshot import dev.nodeterm.protocol.pairing.PairingPayload @@ -74,6 +75,12 @@ class HostSession(val hostId: String, private val graph: AppGraph) { /** A screen is showing this computer right now (so its connection is worth keeping open). */ val isWatched: Boolean get() = synchronized(this) { watchers > 0 } + /** + * What of this computer is on screen: its Inbox tab, and the sessions open in a terminal. Each + * listing announces its new Inbox events except those (audit A73, see [refreshNow]). + */ + val onScreen = OnScreenTracker() + suspend fun ensureConnected(trigger: Trigger = Trigger.AUTO): HostConnection { conn?.let { return it } return mutex.withLock { conn ?: connectLocked(trigger) } @@ -302,12 +309,19 @@ class HostSession(val hostId: String, private val graph: AppGraph) { * Re-list now. Connects first when needed; failures land in [state]/[lastError], never throw. * [trigger] says who asked: a [Trigger.USER] refresh releases a held relay approval, a * [Trigger.BACKGROUND] one never makes a first relay handshake (see [RelayApprovalGate]). + * + * A listing that arrives announces its new Inbox events, minus what [onScreen] shows (audit A73): + * the 8 s poll of the computer on screen, a change the computer pushed, and the background check + * all come through here, so notifications are live for the computer whose screen is open. Other + * computers are not re-listed, so theirs wait for the background check. */ suspend fun refreshNow(trigger: Trigger = Trigger.AUTO) { - try { + val listed = try { val c = ensureConnected(trigger) - _snapshot.value = c.listProjects() - _lastError.value = null + c.listProjects().also { + _snapshot.value = it + _lastError.value = null + } } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { @@ -316,7 +330,11 @@ class HostSession(val hostId: String, private val graph: AppGraph) { // own drop). Anything else is an unexpected transport failure: drop the connection so the // next refresh dials a fresh one instead of reusing a dead socket. if (e !is HostException) disconnect() + return } + // Outside the try: a notification the system refused is not a failed listing, and must not + // drop a working connection. No network here — the listing just arrived. + runCatching { graph.announce(hostId, listed, onScreen.now()) } } /** A refresh the USER asked for (Refresh, Try again). */ @@ -324,7 +342,10 @@ class HostSession(val hostId: String, private val graph: AppGraph) { scope.launch { refreshNow(Trigger.USER) } } - /** While a screen shows this computer, re-list every 8 s (the iOS foreground cadence). */ + /** + * While a screen shows this computer, re-list every 8 s (the iOS foreground cadence); each listing + * announces what is new and not on screen ([refreshNow]). + */ @Synchronized fun startWatching() { watchers++ diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt index 1a220a1a49..b56c27d9af 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt @@ -2,6 +2,7 @@ package dev.nodeterm.android.data import android.content.Context import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.OnScreen import dev.nodeterm.protocol.model.PairedHost import dev.nodeterm.protocol.model.SeenLog import kotlinx.coroutines.flow.MutableStateFlow @@ -118,9 +119,9 @@ class HostStore(context: Context) { } /** - * Inbox events this phone has announced or read (phone-local, like iOS), trimmed by age and - * locked across each update — see [SeenLog] (audit A48). The pre-A48 build kept a bare id set - * under `seenEvents`; [SeenLog] migrates it on first use and the same edit removes it. + * Inbox events this phone has announced, read or had on screen (phone-local, like iOS), trimmed + * by age and locked across each update — see [SeenLog] (audit A48). The pre-A48 build kept a bare + * id set under `seenEvents`; [SeenLog] migrates it on first use and the same edit removes it. */ private val seenLog = SeenLog(object : SeenLog.Storage { override fun read(): String? = prefs.getString(SEEN_LOG_KEY, null) @@ -134,8 +135,12 @@ class HostStore(context: Context) { /** The phone has seen [events]: the user read them here. */ fun markSeen(events: Collection) = seenLog.markSeen(events) - /** The events of a feed to notify about now, recorded as announced in the same locked step. */ - fun claimAnnounceable(events: List): List = seenLog.claimAnnounceable(events) + /** + * The events of a fresh listing to notify about now, recorded as announced in the same locked + * step; what [onScreen] shows is recorded as seen instead (audit A73) — see [SeenLog.claimLive]. + */ + fun claimLive(events: List, onScreen: OnScreen, notify: Boolean): List = + seenLog.claimLive(events, onScreen, notify) private companion object { const val SEEN_LOG_KEY = "seenEvents.v2" diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt index 74d2c90cb5..a34d3c33bc 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -25,6 +25,7 @@ import dev.nodeterm.protocol.host.RelayApprovalGate import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.InboxNotificationText +import dev.nodeterm.protocol.model.OnScreen import dev.nodeterm.protocol.model.PairedHost import dev.nodeterm.protocol.model.ProjectsSnapshot import kotlinx.coroutines.withTimeoutOrNull @@ -35,9 +36,13 @@ import java.util.concurrent.TimeUnit * * The iOS app gets these as APNs pushes fanned out by the nodeterm backend (src/core/push-notify.ts). * That backend has no Android (FCM) leg, so this app cannot be woken the same way. What it does - * instead, honestly: a periodic WorkManager check (Android's floor is 15 minutes) that reads each - * paired computer's Inbox and raises a local notification for events it has not announced yet — plus - * the in-app 8 s refresh while a computer's screen is open. A real push leg is backend work. + * instead, honestly: checked about every 15 minutes in the background, and live for the computer + * whose screen is open. Both are the same [announce], run on every fresh listing of a computer + * (HostSession.refreshNow): the periodic WorkManager check (Android's floor is 15 minutes) lists + * each paired computer, and the app re-lists the computer on screen every 8 s (audit A73). Other + * computers are not polled, so their notifications come from the background check only. What the + * user is looking at ([OnScreen]) is recorded as seen instead of announced. A real push leg is + * backend work. */ object InboxNotifier { private const val CH_ATTENTION = "attention" @@ -73,16 +78,22 @@ object InboxNotifier { wm.enqueueUniquePeriodicWork(WORK, ExistingPeriodicWorkPolicy.KEEP, req) } - /** Announce events not announced before. Returns how many were posted. */ - fun announce(context: Context, host: PairedHost, snapshot: ProjectsSnapshot): Int { + /** + * Announce the events of a fresh listing of [host] not announced before, except what [onScreen] + * says the user is looking at: those are recorded as seen (audit A73). Returns how many were + * posted. + */ + fun announce(context: Context, host: PairedHost, snapshot: ProjectsSnapshot, onScreen: OnScreen): Int { val graph = NodetermApp.graph(context) - if (!graph.hosts.notificationsEnabled) return 0 - if (Build.VERSION.SDK_INT >= 33 && - ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) return 0 - // Decided and recorded in one locked step (SeenLog, audit A48): unresolved, younger than the - // announce window, and never announced or read on this phone. - val fresh = graph.hosts.claimAnnounceable(snapshot.status?.inbox?.events.orEmpty()) + // The switch, and whether the system will show it (A21): with either off nothing is claimed, + // but what is on screen is still recorded as seen, so turning them on later does not + // announce what the user already looked at. + val permitted = Build.VERSION.SDK_INT < 33 || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + val notify = graph.hosts.notificationsEnabled && canPost(context) && permitted + // Decided and recorded in one locked step (SeenLog, audits A48/A73): unresolved, younger than + // the announce window, not on screen, and never announced, read or shown on this phone. + val fresh = graph.hosts.claimLive(snapshot.status?.inbox?.events.orEmpty(), onScreen, notify) if (fresh.isEmpty()) return 0 val nm = NotificationManagerCompat.from(context) val showDetails = graph.hosts.notificationDetails @@ -147,9 +158,10 @@ class InboxWorker(context: Context, params: WorkerParameters) : CoroutineWorker( val session = graph.connections.session(host.id) val watched = session.isWatched // BACKGROUND: never a first relay handshake — that would raise the desktop's approval - // dialog with nobody at the phone to compare the code (audit A05). + // dialog with nobody at the phone to compare the code (audit A05). A listing that arrives + // announces its new events itself (HostSession.refreshNow → InboxNotifier.announce, the + // path the live refresh takes too, audit A73); a failed one has nothing new to announce. withTimeoutOrNull(45_000) { session.refreshNow(RelayApprovalGate.Trigger.BACKGROUND) } - InboxNotifier.announce(applicationContext, host, session.snapshot.value) // Don't hold a socket open in the background for a screen nobody is looking at. if (!watched && !session.isWatched) session.disconnect() } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index 5c32962d68..15162b49c0 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect import dev.nodeterm.android.Navigator import dev.nodeterm.android.NodetermApp import dev.nodeterm.android.Route @@ -84,6 +85,12 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro .filter { (id, st) -> st.state == AgentState.WORKING && inbox?.nodes?.get(id)?.activity != null } LaunchedEffect(actionable.map { it.id }) { graph.hosts.markSeen(actionable) } + // While this tab is on screen, the live refresh announces none of this computer's events; it + // records them as seen, so the background check does not announce them later either (A73). + LifecycleStartEffect(hostId) { + val showing = session.onScreen.showInbox() + onStopOrDispose { showing.close() } + } fun titleOf(nodeId: String): String = snapshot.findNode(nodeId)?.second?.let { displayTitle(it, snapshot) } ?: snapshot.statusOf(nodeId)?.name ?: "Session" diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt index 459088f1c0..c9ca8942bf 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -129,7 +129,7 @@ fun SettingsScreen(nav: Navigator) { Column(Modifier.weight(1f)) { Text("Notifications") Text( - "Checked about every 15 minutes in the background, and live while a computer is open.", + "Checked about every 15 minutes in the background, and live for the computer whose screen is open.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 8a9e91d276..db7b092baf 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -60,12 +60,17 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String // Attached and watching only while the screen is STARTED (audit A18): in the background the // relay stream kept the desktop treating the session as watched (Eco shield, and the phone's // size as a ceiling) and kept the radio busy. Stop detaches; start reattaches. + // While it shows, this session's Inbox events are in front of the user: the live refresh records + // them as seen instead of announcing them (A73). Registered before the watch starts, so its + // first listing already knows. LifecycleStartEffect(controller) { + val showing = session.onScreen.showNode(nodeId) session.startWatching() controller.onStart() onStopOrDispose { controller.onStop() session.stopWatching() + showing.close() } } DisposableEffect(controller) { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt new file mode 100644 index 0000000000..ec065931af --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt @@ -0,0 +1,77 @@ +package dev.nodeterm.protocol.model + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * What of one paired computer the phone is showing right now, for the live notifications (audit A73). + * + * The app re-lists the computer whose screen is open every 8 s, and every fresh listing runs the same + * announce path as the 15-minute background check, except for what the user is looking at: + * + * - every event while that computer's Inbox tab is on screen (the tab is where they are listed); + * - the events of a session open in a terminal (its prompt or its finish is in front of the user). + * + * Those are recorded as seen instead of announced ([SeenLog.claimLive]), so no later check (the next + * refresh, or the background one) announces what the user already saw. Other computers are not + * polled at all, so nothing here makes their notifications live: theirs still come from the + * background check only. + */ +data class OnScreen(val inbox: Boolean = false, val nodes: Set = emptySet()) { + /** The user is looking at [ev]. */ + fun shows(ev: InboxEvent): Boolean = inbox || ev.nodeId in nodes + + /** [events] (a computer's feed) split into what is on screen and what is not; each keeps the feed's order. */ + fun split(events: List): Split { + val (shown, offScreen) = events.partition(::shows) + return Split(shown, offScreen) + } + + data class Split( + /** On screen: recorded as seen, never announced. */ + val shown: List, + /** Not on screen: announced when new ([SeenLog.claimLive] decides). */ + val offScreen: List + ) + + companion object { + /** Nothing of this computer on screen: the app is in the background, or shows something else. */ + val NOTHING = OnScreen() + } +} + +/** + * The screens showing a computer's Inbox tab or one of its sessions right now. A screen takes a + * handle when it starts and closes it when it stops. Screens are counted, not flagged, because the + * next screen can start before the last one stops; closing a handle twice counts once. Thread-safe: + * the screens register on the main thread and a refresh reads [now] from a background thread. + */ +class OnScreenTracker { + private var inboxes = 0 + private val nodes = HashMap() + + /** What is on screen at this moment. */ + @Synchronized + fun now(): OnScreen = if (inboxes == 0 && nodes.isEmpty()) OnScreen.NOTHING else OnScreen(inboxes > 0, nodes.keys.toSet()) + + /** The computer's Inbox tab is on screen until the handle is closed. */ + fun showInbox(): AutoCloseable { + synchronized(this) { inboxes++ } + return handle { inboxes-- } + } + + /** The session [nodeId] is open in a terminal until the handle is closed. */ + fun showNode(nodeId: String): AutoCloseable { + synchronized(this) { nodes[nodeId] = (nodes[nodeId] ?: 0) + 1 } + return handle { + val left = (nodes[nodeId] ?: 0) - 1 + if (left > 0) nodes[nodeId] = left else nodes.remove(nodeId) + } + } + + private fun handle(release: () -> Unit): AutoCloseable { + val closed = AtomicBoolean(false) + return AutoCloseable { + if (closed.compareAndSet(false, true)) synchronized(this@OnScreenTracker) { release() } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt index 70ba9af28b..2451a6643a 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt @@ -4,8 +4,8 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive /** - * Which Inbox events this phone has already announced or read (phone-local, like iOS), so the - * background check notifies each event once (audit A48). + * Which Inbox events this phone has already announced, read or had on screen (phone-local, like + * iOS), so each event raises at most one notification (audit A48), whichever check sees it first. * * The first version kept a bare id set and trimmed it with `(set + ids).toList().takeLast(500)`. * The set came back from SharedPreferences as a `HashSet`, whose order is hash order, so past 500 @@ -27,7 +27,8 @@ import kotlinx.serialization.json.JsonPrimitive * now", so the upgrade announces nothing again and they age out one retention period later. * * Every entry point holds this object's lock across the whole read-modify-write, and - * [claimAnnounceable] decides and records in one step, so two callers cannot both announce an event. + * [claimAnnounceable] and [claimLive] (the app's path since A73) decide and record in one step, so + * two callers cannot both announce an event. */ class SeenLog( private val storage: Storage, @@ -60,6 +61,44 @@ class SeenLog( save(entries, now) } + /** + * One check of a fresh listing ([events], a host's feed): the events to notify about now, recorded + * as announced in the same locked step (audit A73). What [onScreen] shows is recorded as seen and + * never returned, so no later check (the next refresh, or the background one) announces what the + * user already looked at; that happens even when [notify] is false (notifications off, or not + * allowed by the system), so turning them on later does not announce it either. The rest is + * [claimAnnounceable]'s answer when [notify], and left untouched otherwise. + * + * Only events that could still be announced are recorded, an entry already recorded is left as it + * is, and nothing is written when nothing is new: the live check runs every 8 s. Leaving an + * anchor alone is safe, since the first anchor is never earlier than the event's `ts`, so the + * entry outlives the announce window. With [OnScreen.NOTHING] and [notify] it returns what + * [claimAnnounceable] returns. + */ + @Synchronized + fun claimLive(events: List, onScreen: OnScreen, notify: Boolean): List { + val now = clock() + val (shown, offScreen) = onScreen.split(events.filter { announceable(it, now) }) + val claim = if (notify) offScreen else emptyList() + if (shown.isEmpty() && claim.isEmpty()) return emptyList() + val entries = load(now) + var changed = false + for (ev in shown) { + if (ev.id in entries) continue + record(entries, ev, now) + changed = true + } + val fresh = ArrayList() + for (ev in claim) { + if (ev.id in entries) continue + record(entries, ev, now) + fresh += ev + changed = true + } + if (changed) save(entries, now) + return fresh + } + /** * The events of [events] (a host's feed, oldest → newest) that should raise a notification now — * unresolved, inside the announce window and never announced or read — recorded as announced in diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt new file mode 100644 index 0000000000..ce81abe86a --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt @@ -0,0 +1,294 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.OnScreenTracker +import dev.nodeterm.protocol.model.SeenLog +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * A73: notifications were documented as live every 8 s while a computer is open, but only the + * 15-minute background check ever posted one; the in-app refresh only updated the listing. Now every + * fresh listing of a computer runs the one announce path, so notifications are live for the computer + * whose screen is open, minus what the user is looking at: every event while that computer's Inbox + * tab is on screen, and the events of the session open in a terminal. Those are recorded as seen, so + * no later check announces them. Other computers are not polled, and the copy now says so. + * + * The decision ([OnScreen], [SeenLog.claimLive]) and the screen bookkeeping ([OnScreenTracker]) are + * pure and tested here. The wiring (the listing announces, the screens register while started, the + * copy) cannot run on a JVM, so it is pinned in the app's source ([AppSourcePins]); whether a + * notification appears on a phone is a device check. + */ +class LiveNotificationsTest { + private class MemStorage : SeenLog.Storage { + var raw: String? = null + var reads = 0 + var writes = 0 + override fun read(): String? = raw.also { reads++ } + override fun readLegacy(): Set? = null + override fun write(encoded: String) { + raw = encoded + writes++ + } + } + + private class Clock(var now: Long) : () -> Long { + override fun invoke(): Long = now + } + + private val hour = 3_600_000L + private val t0 = 1_800_000_000_000L + + private fun ev( + id: String, + nodeId: String = "n1", + ts: Long = t0, + kind: InboxKind = InboxKind.APPROVAL, + resolved: Boolean = false + ) = InboxEvent( + id = id, ts = ts, nodeId = nodeId, agentId = "claude", sessionId = null, kind = kind, title = "t", + detail = null, interrupted = false, resolved = resolved, options = emptyList(), multiSelect = false, pendingId = null + ) + + private fun ids(events: List) = events.map { it.id } + + // --- What is on screen --------------------------------------------------------------------- + + @Test + fun `with nothing on screen the whole feed may be announced`() { + val feed = listOf(ev("a", "n1"), ev("b", "n2", kind = InboxKind.DONE)) + val split = OnScreen.NOTHING.split(feed) + assertEquals(emptyList(), split.shown) + assertEquals(listOf("a", "b"), ids(split.offScreen)) + } + + @Test + fun `while the Inbox tab shows, every event of that computer is on screen`() { + // Questions and approvals are its cards; a finish is in its archive. None is announced. + val feed = listOf(ev("a", "n1"), ev("q", "n2", kind = InboxKind.QUESTION), ev("d", "n3", kind = InboxKind.DONE)) + val split = OnScreen(inbox = true).split(feed) + assertEquals(listOf("a", "q", "d"), ids(split.shown)) + assertEquals(emptyList(), split.offScreen) + } + + @Test + fun `a session open in a terminal hides only its own events, in feed order`() { + val feed = listOf(ev("a", "open"), ev("b", "other"), ev("c", "open", kind = InboxKind.DONE), ev("d", "other")) + val split = OnScreen(nodes = setOf("open")).split(feed) + assertEquals(listOf("a", "c"), ids(split.shown)) + assertEquals(listOf("b", "d"), ids(split.offScreen)) + assertTrue(OnScreen(nodes = setOf("open")).shows(ev("x", "open"))) + assertFalse(OnScreen(nodes = setOf("open")).shows(ev("x", "other"))) + } + + // --- One check of a fresh listing ----------------------------------------------------------- + + @Test + fun `nothing on screen claims exactly what the background check claims`() { + val feed = listOf( + ev("a", ts = t0 - hour), + ev("old", ts = t0 - SeenLog.ANNOUNCE_WINDOW_MS), + ev("resolved", resolved = true), + ev("b", "n2") + ) + val live = SeenLog(MemStorage(), Clock(t0)) + val background = SeenLog(MemStorage(), Clock(t0)) + assertEquals(ids(background.claimAnnounceable(feed)), ids(live.claimLive(feed, OnScreen.NOTHING, notify = true))) + assertEquals(listOf("a", "b"), ids(SeenLog(MemStorage(), Clock(t0)).claimLive(feed, OnScreen.NOTHING, notify = true))) + } + + @Test + fun `an event shown on the Inbox tab is never announced, by this check or a later one`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val feed = listOf(ev("a", "n1"), ev("d", "n2", kind = InboxKind.DONE)) + assertEquals(emptyList(), log.claimLive(feed, OnScreen(inbox = true), notify = true)) + // The user left the tab (or the app): the next refresh and the background check stay quiet. + clock.now += 60_000 + assertEquals(emptyList(), log.claimLive(feed, OnScreen.NOTHING, notify = true)) + assertEquals(emptyList(), log.claimAnnounceable(feed)) + // Something new after that is announced, once. + val next = feed + ev("b", "n1", ts = clock.now) + assertEquals(listOf("b"), ids(log.claimLive(next, OnScreen.NOTHING, notify = true))) + assertEquals(emptyList(), log.claimLive(next, OnScreen.NOTHING, notify = true)) + } + + @Test + fun `with a terminal open, its session is recorded and another session is announced once`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val onScreen = OnScreen(nodes = setOf("open")) + val feed = listOf(ev("mine", "open"), ev("theirs", "other")) + assertEquals(listOf("theirs"), ids(log.claimLive(feed, onScreen, notify = true))) + clock.now += 8_000 + assertEquals(emptyList(), log.claimLive(feed, onScreen, notify = true)) + // Back from the terminal: what it showed was seen there. + assertEquals(emptyList(), log.claimAnnounceable(feed)) + assertTrue(log.isSeen("mine")) + } + + @Test + fun `with notifications off nothing is claimed, but what is on screen is still recorded`() { + val log = SeenLog(MemStorage(), Clock(t0)) + val feed = listOf(ev("shown", "open"), ev("offscreen", "other")) + assertEquals(emptyList(), log.claimLive(feed, OnScreen(nodes = setOf("open")), notify = false)) + assertTrue(log.isSeen("shown")) + assertFalse(log.isSeen("offscreen"), "an event nobody saw was spent while notifications were off") + // Turned on later: only what the user never looked at is announced. + assertEquals(listOf("offscreen"), ids(log.claimLive(feed, OnScreen.NOTHING, notify = true))) + } + + @Test + fun `the 8 s check writes only what is new and could still be announced`() { + val storage = MemStorage() + val clock = Clock(t0) + val log = SeenLog(storage, clock) + val feed = listOf( + ev("a", "n1"), + ev("resolved", "n1", resolved = true), + ev("old", "n1", ts = t0 - SeenLog.ANNOUNCE_WINDOW_MS) + ) + assertEquals(emptyList(), log.claimLive(feed, OnScreen(inbox = true), notify = true)) + assertEquals(1, storage.writes) + assertEquals(setOf("a"), SeenLog.decode(storage.raw!!, clock.now).keys) + // The same tab, ten refreshes later: nothing new, nothing written, the anchor unmoved. + repeat(10) { + clock.now += 8_000 + log.claimLive(feed, OnScreen(inbox = true), notify = true) + } + assertEquals(1, storage.writes) + assertEquals(t0, SeenLog.decode(storage.raw!!, clock.now)["a"]) + // A feed with nothing announceable in it is not even read back. + val reads = storage.reads + assertEquals(emptyList(), log.claimLive(listOf(feed[1], feed[2]), OnScreen.NOTHING, notify = true)) + assertEquals(reads, storage.reads) + assertEquals(1, storage.writes) + } + + // --- Which screens are showing ---------------------------------------------------------------- + + @Test + fun `screens are counted, so an overlapping start and stop keeps what is still showing`() { + val tracker = OnScreenTracker() + assertSame(OnScreen.NOTHING, tracker.now()) + val first = tracker.showInbox() + val second = tracker.showInbox() // the next screen started before the last one stopped + first.close() + assertEquals(OnScreen(inbox = true), tracker.now()) + second.close() + assertEquals(OnScreen.NOTHING, tracker.now()) + } + + @Test + fun `a terminal's session is on screen until its last handle closes, and a handle closes once`() { + val tracker = OnScreenTracker() + val a = tracker.showNode("n1") + val b = tracker.showNode("n1") + val c = tracker.showNode("n2") + assertEquals(OnScreen(nodes = setOf("n1", "n2")), tracker.now()) + a.close() + a.close() // twice counts once: n1 is still open through b + assertEquals(OnScreen(nodes = setOf("n1", "n2")), tracker.now()) + b.close() + assertEquals(OnScreen(nodes = setOf("n2")), tracker.now()) + c.close() + assertEquals(OnScreen.NOTHING, tracker.now()) + } + + @Test + fun `what is reported is a copy, not a live view`() { + val tracker = OnScreenTracker() + val h = tracker.showNode("n1") + val seen = tracker.now() + h.close() + assertEquals(setOf("n1"), seen.nodes) + } + + // --- The wiring, pinned in the app's source ----------------------------------------------------- + + private val connections get() = AppSourcePins.app("conn/ConnectionManager.kt") + private val notifier get() = AppSourcePins.app("notify/InboxNotifier.kt") + + @Test + fun `every listing that arrives announces, outside the listing's own failure handling`() { + val refresh = AppSourcePins.blockAfter(connections, "suspend fun refreshNow(") + // A notification the system refused must not read as a transport failure (that disconnects). + AppSourcePins.assertInOrder( + refresh, + "val listed = try {", + "c.listProjects().also {", + "_snapshot.value = it", + "} catch (e: Exception) {", + "if (e !is HostException) disconnect()", + "return", + "runCatching { graph.announce(hostId, listed, onScreen.now()) }" + ) + // The 8 s poll re-lists through it. + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(connections, "fun startWatching()"), "refreshNow(trigger)", "delay(POLL_MS)") + val graph = AppSourcePins.app("NodetermApp.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(graph, "fun announce(hostId: String, snapshot: ProjectsSnapshot, onScreen: OnScreen)"), + "InboxNotifier.announce(appContext, host, snapshot, onScreen)" + ) + } + + @Test + fun `announce decides with what is on screen, and gates only the claim on the switch and permission`() { + val announce = AppSourcePins.blockAfter(notifier, "fun announce(") + AppSourcePins.assertInOrder( + announce, + "val notify = graph.hosts.notificationsEnabled && canPost(context) && permitted", + "graph.hosts.claimLive(snapshot.status?.inbox?.events.orEmpty(), onScreen, notify)", + "nm.notify(" + ) + // An early return on the switch would skip recording what is on screen. + assertFalse(announce.contains("if (!graph.hosts.notificationsEnabled"), "announce returns before recording what is on screen:\n$announce") + assertFalse(announce.contains("claimAnnounceable"), "announce ignores what is on screen:\n$announce") + } + + @Test + fun `the background check announces through the listing, not from a stale snapshot`() { + val work = AppSourcePins.blockAfter(notifier, "override suspend fun doWork()") + assertTrue(work.contains("session.refreshNow(RelayApprovalGate.Trigger.BACKGROUND)")) + assertFalse(work.contains("InboxNotifier.announce("), "the worker announces a second time, without what is on screen:\n$work") + } + + @Test + fun `the Inbox tab and the terminal say what they show while they are started`() { + val inbox = AppSourcePins.ui("InboxTab.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(inbox, "LifecycleStartEffect(hostId)"), + "val showing = session.onScreen.showInbox()", + "onStopOrDispose { showing.close() }" + ) + // Registered before the watch starts, so the first listing already knows; closed after it stops. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(AppSourcePins.ui("TerminalScreen.kt"), "LifecycleStartEffect(controller)"), + "val showing = session.onScreen.showNode(nodeId)", + "session.startWatching()", + "onStopOrDispose {", + "session.stopWatching()", + "showing.close()" + ) + } + + @Test + fun `the copy promises live notifications only for the computer on screen`() { + val promise = "Checked about every 15 minutes in the background, and live for the computer whose screen is open" + val settings = AppSourcePins.ui("SettingsScreen.kt") + assertTrue(settings.contains("\"$promise.\""), "Settings does not say what is live") + assertFalse(settings.contains("live while a computer is open")) + val kdoc = notifier.replace(Regex("\\s*\\n\\s*\\*\\s*"), " ") + assertTrue(kdoc.contains("checked about every 15 minutes in the background, and live for the computer whose screen is open")) + val readme = File(InteropHarness.repoRoot, "android/README.md").readText().replace(Regex("\\s+"), " ") + assertTrue(readme.contains("checked about every 15 minutes in the background (WorkManager's floor), and live for the computer whose screen is open")) + assertFalse(readme.contains("live every 8 seconds while a computer is open")) + assertTrue(readme.contains("Other paired computers are not polled")) + } +} diff --git a/docs/android.md b/docs/android.md index 84f1bf22ae..8b274bc2d6 100644 --- a/docs/android.md +++ b/docs/android.md @@ -224,6 +224,20 @@ preview setting. That is a difference in platform defaults, not an Android-only unit-tested; the notification's use of them and the setting's default are pinned in the source, and what a lock screen shows is a device check. +`LiveNotificationsTest` covers when notifications are posted (`A73`). The app said they were live +every 8 s while a computer was open, but only the 15-minute background check ever posted one; the +in-app refresh only updated the listing. Now every listing that arrives (the 8 s refresh of the +computer on screen, a change the computer pushes, the background check) runs the one announce path, +so notifications are live for the computer whose screen is open. What the user is looking at is left +out and recorded as seen instead, so no later check announces it: every event while that computer's +Inbox tab is on screen, and the events of a session open in a terminal. That is recorded even with +notifications off, so turning them on later does not announce it either. Other computers are not +polled while one is open, so theirs still come only from the background check, and the Settings +text, the README and the notifier's comment now say exactly that. It costs no network call (the +listing already arrived), and the check writes the phone's seen-log only when something is new. The +decision and the screen bookkeeping are unit-tested; the wiring into the refresh, the worker and the +two screens is pinned in the source, and whether a notification appears on a phone is a device check. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 9b4af7012544a1741e48aaab154234f4586c18dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 16:37:15 +0000 Subject: [PATCH 062/284] fix(android): keep the phone's data out of device transfers and tie the relay deviceId to its key (A51) allowBackup="false" stops cloud backup, but for an app targeting Android 12+ (targetSdk 35 here) it does not stop a device-to-device transfer; only the section of the data extraction rules does. The transfer copied nodeterm.hosts (every paired computer, its SSH pin and the relay deviceId) to the new phone, where the Keystore-sealed secrets could not follow, so the new phone regenerated its box key and kept presenting the OLD deviceId beside it. What changed: - The manifest names res/xml/data_extraction_rules.xml, which excludes every domain (root, file, database, sharedpref, external and the device_* variants, path ".") from both and . Each domain is backed up as its own tree, so each is excluded. allowBackup stays false; no fullBackupContent is added, because on Android 11 and lower allowBackup="false" already disables device transfer too. - New PhoneIdentity (protocol): a deviceId never outlives the box secret it was minted with. When the box secret has to be created (absent, or provably lost per SecretStoreCore), the stored deviceId is removed first, durably, and only then is the new key written; deviceId() resolves the box secret before it reads or mints an id. SecretStoreCore.getOrCreate32 gained a beforeCreate hook that runs under its lock only when a value is about to be created (nothing is created if it throws). The app reads the deviceId only through AppGraph.identity; HostStore's deviceId getter is gone and HostStore supplies the storage (same "deviceId" key, so existing installs keep their id while their key still opens). Durable writes use commit() and throw when it fails. Effect on the relay backend: it keys the device row on deviceId and the mint upserts on it. Per the desktop's note on priorDeviceToken (src/main/pairing-service.ts; the backend is not in this repo), a free-tier re-registration of a known deviceId asks for the previous device token and can refuse without it, leaving the pairing LAN-only (a 502 on a relay-only Windows desktop). A phone whose key was lost now registers under a new id instead, and no longer shares a row with the phone it was copied from, so removing one pairing on an entitled desktop cannot revoke the other phone. The old row is left unused, as after an uninstall. Verified: PhoneIdentityTest (9 tests: fresh phone order, restart, legacy id kept, transferred phone with a replaced Keystore key, preferences without the sealed file, remove-before-create ordering across both files, deviceId-before-key, transient keystore error, failed removal) and BackupRulesTest (2 tests parsing the manifest and rules, and checking every getSharedPreferences file is covered). Mutation checks: dropping the beforeCreate hook fails 5 PhoneIdentityTest cases, not resolving the key in deviceId() fails 7, running the hook after the write fails 3; removing dataExtractionRules from the manifest, removing , or narrowing the sharedpref exclusion each fail both BackupRulesTest cases. Full protocol suite (267 tests) and the offline app type-check pass. No desktop code changed. Not run on a device: what a real transfer copies is a device check. Departures from the audit: excludes everything rather than naming the two preference files (the test still accepts either form); the deviceId stays in nodeterm.hosts coupled by rule rather than moving into SecureStore, so existing installs keep their id without a migration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 6 + android/app/src/main/AndroidManifest.xml | 7 + .../dev/nodeterm/android/NodetermApp.kt | 10 +- .../android/conn/ConnectionManager.kt | 2 +- .../dev/nodeterm/android/data/HostStore.kt | 24 ++- .../dev/nodeterm/android/data/SecureStore.kt | 16 +- .../dev/nodeterm/android/ui/PairScreen.kt | 2 +- .../main/res/xml/data_extraction_rules.xml | 32 +++ .../nodeterm/protocol/secure/PhoneIdentity.kt | 70 +++++++ .../protocol/secure/SecretStoreCore.kt | 7 +- .../dev/nodeterm/protocol/BackupRulesTest.kt | 81 ++++++++ .../nodeterm/protocol/PhoneIdentityTest.kt | 195 ++++++++++++++++++ docs/android.md | 17 ++ 13 files changed, 457 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/PhoneIdentity.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackupRulesTest.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneIdentityTest.kt diff --git a/android/README.md b/android/README.md index 94bf74789c..cd978c25a1 100644 --- a/android/README.md +++ b/android/README.md @@ -100,6 +100,12 @@ computer's name either way. The iOS app does receive the detail, in the push the - The phone's relay identity (a Curve25519 box key) and SSH identity (an Ed25519 seed) are generated on the device and stored encrypted under an Android Keystore AES-GCM key. Only their public halves ever leave the phone. +- Nothing of the app's goes into a backup or a phone-to-phone transfer. `allowBackup="false"` stops + cloud backup, and the manifest's data extraction rules stop the Android 12+ device-to-device + transfer, which ignores `allowBackup`. A new phone starts unpaired; pair it again. +- The relay device id goes with the relay key: if the key ever has to be created again (it was + lost), a new device id is minted with it, so the phone never presents an old device id with new + keys. - Relay traffic is end-to-end encrypted (NaCl box under a per-session HKDF key) and checked exactly as the desktop checks it: role byte (no reflections), strictly increasing sequence numbers (no replays), no re-key once ready, and the host key pinned from pairing. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 60780f62c4..24c5a629dd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,9 +8,16 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/PhoneIdentity.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/PhoneIdentity.kt new file mode 100644 index 0000000000..2fad458fc7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/PhoneIdentity.kt @@ -0,0 +1,70 @@ +package dev.nodeterm.protocol.secure + +import java.util.UUID + +/** Where the phone's public identity values are kept (HostStore's preferences on Android). */ +interface PlainStorage { + fun get(name: String): String? + /** [durable] = do not return before the change is on disk. */ + fun put(name: String, value: String, durable: Boolean) + fun remove(name: String, durable: Boolean) +} + +/** + * The phone's relay identity: the NaCl box secret every paired desktop pins, and the `deviceId` the + * relay backend keys this phone's device row on (`/v1/relay/device` upserts on it). The two belong + * together, and this class keeps them together (audit A51): **a deviceId never outlives the box + * secret it was minted with.** + * + * The box secret is sealed under an Android Keystore key, so it cannot follow the phone's data to + * another device, and it is replaced when it is provably lost ([SecretStoreCore]). The deviceId is a + * plain preference and could follow (an Android 12+ device-to-device transfer ignores + * `allowBackup="false"`; the manifest's data extraction rules now stop that, and this rule does not + * depend on them). A phone that re-paired with an OLD deviceId, NEW keys and no readable device + * token would be re-registering a row it cannot prove it owns: per the desktop's note on + * `priorDeviceToken` (src/main/pairing-service.ts; the backend is not in this repo), the free-tier + * re-registration of a known deviceId asks for the previous device token and can refuse without it, + * which leaves the pairing LAN-only (and fails it outright on a relay-only Windows desktop). It + * would also share one row with the phone it was copied from, so removing either pairing on an + * entitled desktop would revoke both phones. A new deviceId is a first registration instead. + * + * The rule: when the box secret has to be created (absent, or provably lost), the stored deviceId + * is removed first, durably, and only then is the new secret written; and [deviceId] resolves the + * box secret before it reads or mints an id. A process that dies between the two steps leaves no + * deviceId, never an old one beside a new key. + */ +class PhoneIdentity( + private val secrets: SecretStoreCore, + private val plain: PlainStorage, + private val newDeviceId: () -> String = { UUID.randomUUID().toString() } +) { + private var box: ByteArray? = null + + /** The box secret key (32 bytes), created on first use. See the class comment for the rule. */ + @Synchronized + fun boxSecret(): ByteArray { + box?.let { return it.copyOf() } + val bytes = secrets.getOrCreate32(BOX_SECRET, beforeCreate = { plain.remove(DEVICE_ID, durable = true) }) + box = bytes + return bytes.copyOf() + } + + /** + * The phone's relay deviceId, minted on first use. The box secret is resolved first, so an id is + * only ever handed out beside the key it belongs to. Throws [SecretUnavailableException] when the + * box secret cannot be read right now (nothing is changed then). + */ + @Synchronized + fun deviceId(): String { + boxSecret() + plain.get(DEVICE_ID)?.takeIf { it.isNotBlank() }?.let { return it } + return newDeviceId().also { plain.put(DEVICE_ID, it, durable = true) } + } + + companion object { + /** In the sealed store (`nodeterm.secure`). */ + const val BOX_SECRET = "box.secret" + /** In the plain store (`nodeterm.hosts`), the key earlier builds already wrote. */ + const val DEVICE_ID = "deviceId" + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt index a75ae8c328..2dab5d20ec 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt @@ -104,13 +104,18 @@ class SecretStoreCore(private val storage: SecretStorage, private val sealer: Se /** * Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). Creates only when * nothing is stored or the stored value is provably lost; the first write is durable. + * + * [beforeCreate] runs, under this store's lock, only when a new value is about to be written, + * and before it is: what belonged to the old value can be dropped first ([PhoneIdentity] drops + * the relay deviceId). If it throws, nothing is created. */ @Synchronized - fun getOrCreate32(name: String): ByteArray { + fun getOrCreate32(name: String, beforeCreate: () -> Unit = {}): ByteArray { when (val r = read(name)) { is Read.Value -> if (r.bytes.size == 32) return r.bytes Read.Absent, is Read.Lost -> Unit } + beforeCreate() val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) } putBytes(name, fresh, durable = true) return fresh diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackupRulesTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackupRulesTest.kt new file mode 100644 index 0000000000..11b894e777 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackupRulesTest.kt @@ -0,0 +1,81 @@ +package dev.nodeterm.protocol + +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Audit A51: `allowBackup="false"` alone did not keep the phone's data on the phone. For an app that + * targets Android 12 (API 31) or later, it stops cloud backup but NOT a device-to-device transfer; + * only the `` section of the manifest's data extraction rules does. The transfer + * copied `nodeterm.hosts` (every paired computer, its SSH pin and the relay deviceId) to the new + * phone, where the Keystore-sealed secrets could not follow. + * + * The rules are resources a JVM cannot hand to Android's backup manager, so this pins the files: the + * manifest points at the rules, both sections exclude every domain, and every preferences file the + * app opens is covered. What a real transfer copies is a device check. The deviceId half of the fix + * (a new box key always comes with a new deviceId) is [PhoneIdentityTest]. + */ +class BackupRulesTest { + private val appMain = File(InteropHarness.repoRoot, "android/app/src/main") + private val androidNs = "http://schemas.android.com/apk/res/android" + + private fun parse(file: File): Element = + DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + .newDocumentBuilder().parse(file).documentElement + + private fun Element.children(tag: String): List = + (0 until childNodes.length).map { childNodes.item(it) }.filterIsInstance().filter { it.tagName == tag } + + /** Every domain Android's backup rules know; each is its own tree, so each needs its own exclusion. */ + private val domains = listOf( + "root", "file", "database", "sharedpref", "external", + "device_root", "device_file", "device_database", "device_sharedpref" + ) + + private fun application(): Element = parse(File(appMain, "AndroidManifest.xml")).children("application").single() + + private fun rules(): Element { + val ref = application().getAttributeNS(androidNs, "dataExtractionRules") + assertEquals("@xml/data_extraction_rules", ref, "the manifest must name the data extraction rules") + return parse(File(appMain, "res/xml/data_extraction_rules.xml")) + } + + /** The (domain, path) pairs a section excludes. */ + private fun excluded(section: Element): Set> = + section.children("exclude").map { it.getAttribute("domain") to it.getAttribute("path") }.toSet() + + @Test + fun `backups stay off, and the rules cover device transfer as well as the cloud`() { + assertEquals("false", application().getAttributeNS(androidNs, "allowBackup")) + val root = rules() + assertEquals("data-extraction-rules", root.tagName) + for (name in listOf("cloud-backup", "device-transfer")) { + val section = assertNotNull(root.children(name).singleOrNull(), "<$name> is missing: that path copies the app's data") + assertEquals(emptyList(), section.children("include"), "<$name> must not include anything back") + val missing = domains.filter { (it to ".") !in excluded(section) } + assertEquals(emptyList(), missing, "<$name> leaves these domains in: $missing") + } + } + + @Test + fun `every preferences file the app opens is excluded from both`() { + val names = appMain.walkTopDown().filter { it.extension == "kt" } + .flatMap { Regex("""getSharedPreferences\(\s*"([^"]+)"""").findAll(it.readText()).map { m -> m.groupValues[1] } } + .toSet() + assertTrue("nodeterm.hosts" in names && "nodeterm.secure" in names, "found: $names") + val root = rules() + for (name in listOf("cloud-backup", "device-transfer")) { + val ex = excluded(root.children(name).single()) + for (prefs in names) { + // A sharedpref path names the FILE, with its .xml suffix. + val covered = ("sharedpref" to ".") in ex || ("sharedpref" to "$prefs.xml") in ex + assertTrue(covered, "<$name> does not exclude $prefs.xml") + } + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneIdentityTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneIdentityTest.kt new file mode 100644 index 0000000000..75815b89a8 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneIdentityTest.kt @@ -0,0 +1,195 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.secure.PhoneIdentity +import dev.nodeterm.protocol.secure.PlainStorage +import dev.nodeterm.protocol.secure.SecretStorage +import dev.nodeterm.protocol.secure.SecretStoreCore +import dev.nodeterm.protocol.secure.SecretUnavailableException +import dev.nodeterm.protocol.secure.Sealer +import java.security.ProviderException +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Audit A51: an Android 12+ device-to-device transfer copied the phone's plain preferences (the + * paired computers and the relay `deviceId`) but not its Keystore key, so the new phone regenerated + * its box key and went on presenting the OLD deviceId beside it. [PhoneIdentity] keeps the two + * together: a deviceId never outlives the box secret it was minted with. The manifest half (the data + * extraction rules) is [BackupRulesTest]. + */ +class PhoneIdentityTest { + /** Both stores write into one journal, so the ORDER across the two preference files is visible. */ + private val journal = ArrayList() + + private inner class SecretMem : SecretStorage { + val map = HashMap() + override fun get(name: String) = map[name] + override fun put(name: String, value: String, durable: Boolean) { + map[name] = value + journal += "secret.put $name durable=$durable" + } + override fun remove(name: String) { + map.remove(name) + journal += "secret.remove $name" + } + } + + private inner class PlainMem : PlainStorage { + val map = HashMap() + override fun get(name: String) = map[name] + override fun put(name: String, value: String, durable: Boolean) { + map[name] = value + journal += "plain.put $name durable=$durable" + } + override fun remove(name: String, durable: Boolean) { + map.remove(name) + journal += "plain.remove $name durable=$durable" + } + } + + /** Real AES-GCM (the Keystore does the same), plus a switch to fail like a busy keystore. */ + private class GcmSealer(var key: SecretKey = newKey()) : Sealer { + var failOpenWith: Exception? = null + override fun seal(plain: ByteArray): ByteArray { + val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) + return iv + c.doFinal(plain) + } + override fun open(sealed: ByteArray): ByteArray { + failOpenWith?.let { throw it } + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, 12)) + return c.doFinal(sealed, 12, sealed.size - 12) + } + companion object { + fun newKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + } + } + + private val secretMem = SecretMem() + private val plainMem = PlainMem() + private val sealer = GcmSealer() + private var minted = 0 + + /** A fresh process over the same storage (what an app restart is). */ + private fun process() = PhoneIdentity(SecretStoreCore(secretMem, sealer), plainMem) { "device-${++minted}" } + + @Test + fun `a fresh phone creates the box key first, then its deviceId, both durably`() { + val id = process().deviceId() + assertEquals("device-1", id) + assertEquals( + listOf( + "plain.remove deviceId durable=true", // nothing to drop, but the rule runs before any key + "secret.put box.secret durable=true", + "plain.put deviceId durable=true" + ), + journal + ) + } + + @Test + fun `an ordinary restart keeps the deviceId and the key`() { + val first = process() + val id = first.deviceId() + val box = first.boxSecret() + val again = process() + assertEquals(id, again.deviceId()) + assertContentEquals(box, again.boxSecret()) + assertEquals(1, minted) + } + + @Test + fun `a deviceId stored by an earlier build is kept while its key still opens`() { + val box = process().boxSecret() // an earlier build created the key ... + plainMem.map[PhoneIdentity.DEVICE_ID] = "legacy-id" // ... and minted its id without this class + val later = process() + assertEquals("legacy-id", later.deviceId()) + assertContentEquals(box, later.boxSecret()) + } + + @Test + fun `a transferred phone (the Keystore key stayed behind) gets a new deviceId with its new key`() { + val old = process() + val oldId = old.deviceId() + val oldBox = old.boxSecret() + // The new phone: the same preference files, a different Keystore key. The sealed box secret + // no longer authenticates, which is positive evidence it is gone. + sealer.key = GcmSealer.newKey() + val moved = process() + val newBox = moved.boxSecret() + val newId = moved.deviceId() + assertNotEquals(oldBox.toList(), newBox.toList()) + assertNotEquals(oldId, newId) + assertEquals(newId, process().deviceId(), "and the new pair is kept from then on") + } + + @Test + fun `plain preferences without the sealed ones (the key is absent) also mean a new deviceId`() { + val oldId = process().deviceId() + secretMem.map.clear() // only nodeterm.hosts came across + assertNotEquals(oldId, process().deviceId()) + } + + @Test + fun `the old deviceId is off disk before the new key is written`() { + process().deviceId() + sealer.key = GcmSealer.newKey() + journal.clear() + process().deviceId() + // A process that dies between the two writes leaves NO deviceId, never the old one beside a + // new key; the next deviceId() mints one then. + val drop = journal.indexOf("plain.remove deviceId durable=true") + val create = journal.indexOf("secret.put box.secret durable=true") + assertTrue(drop in 0 until create, "journal: $journal") + } + + @Test + fun `asking for the deviceId before the key never hands out an id that is then dropped`() { + // PairScreen evaluates deviceId before boxKeys: the key must already exist by then. + val identity = process() + val id = identity.deviceId() + identity.boxSecret() + assertEquals(id, process().deviceId()) + assertEquals(1, minted) + } + + @Test + fun `a keystore that cannot answer right now changes nothing`() { + process().deviceId() + val before = HashMap(plainMem.map) + val sealedBefore = HashMap(secretMem.map) + sealer.failOpenWith = ProviderException("Keystore operation failed") + assertFailsWith { process().deviceId() } + assertFailsWith { process().boxSecret() } + assertEquals(before, plainMem.map, "the deviceId is not dropped over a transient error") + assertEquals(sealedBefore, secretMem.map) + sealer.failOpenWith = null + assertEquals("device-1", process().deviceId()) + } + + @Test + fun `if the deviceId cannot be dropped, no new key is written`() { + process().deviceId() + sealer.key = GcmSealer.newKey() + val sealedBefore = HashMap(secretMem.map) + val failing = object : PlainStorage by plainMem { + override fun remove(name: String, durable: Boolean) = throw IllegalStateException("disk full") + } + val identity = PhoneIdentity(SecretStoreCore(secretMem, sealer), failing) { "never" } + assertFailsWith { identity.deviceId() } + assertEquals(sealedBefore, secretMem.map) + assertFalse(plainMem.map[PhoneIdentity.DEVICE_ID] == "never") + } +} diff --git a/docs/android.md b/docs/android.md index 8b274bc2d6..89e9e1fe7c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -238,6 +238,23 @@ listing already arrived), and the check writes the phone's seen-log only when so decision and the screen bookkeeping are unit-tested; the wiring into the refresh, the worker and the two screens is pinned in the source, and whether a notification appears on a phone is a device check. +`PhoneIdentityTest` and `BackupRulesTest` cover what leaves the phone (`A51`). `allowBackup="false"` +stops cloud backup, but an app that targets Android 12 or later is still copied by a +device-to-device transfer unless its data extraction rules exclude it. The transfer carried +`nodeterm.hosts` (every paired computer, its SSH pin and the phone's relay deviceId) to the new +phone, while the Keystore-sealed secrets could not follow. The manifest now names rules that exclude +every domain from both cloud backup and device transfer, so a new phone starts unpaired. +`BackupRulesTest` pins those files and checks that every preferences file the app opens is covered; +what a real transfer copies is a device check. Independently of the rules, the relay deviceId now +goes with the box key (`PhoneIdentity`): when the key has to be created (absent, or provably lost), +the stored deviceId is removed first, durably, and a deviceId is minted only once the key exists. A +phone whose key was lost therefore registers with the relay backend under a new id instead of +re-registering the old one without its previous device token, which the free tier can refuse (per +the desktop's note on `priorDeviceToken` in `pairing-service.ts`; the backend is not in this repo). +It also no longer shares a device row with the phone it was copied from, so removing one pairing on +an entitled desktop cannot revoke the other phone. The old row is left unused on the backend, as +after an uninstall. An existing install keeps its id while its key still opens. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 0a2a1aa46563b3cdece12b9b8c60b3ae8f841e15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 16:46:00 +0000 Subject: [PATCH 063/284] fix(android): make room for the keyboard once on Android 15 (A77) The app targets API 35, so on Android 15 its window is edge-to-edge whether it asks or not: adjustResize no longer resizes it and the keyboard's inset reaches Compose. The terminal screen applied the Scaffold's padding (which already holds the navigation bar) and then imePadding(), whose inset also includes the navigation bar, so with the keyboard up the navigation bar was counted twice. Pair and Settings, whose text fields sit in a scrolling column, had no keyboard padding at all. One helper, Modifier.aboveKeyboard(padding) in ui/Insets.kt, now does padding(p).consumeWindowInsets(p).imePadding(), and the terminal, Pair and Settings Scaffold bodies use it (before the verticalScroll, so the keyboard shrinks the scrolled viewport). consumeWindowInsets and imePadding are in the Compose foundation-layout jar the type-check compiles against, with no opt-in marker, so no stub was needed. Departures from the audit's proposal, per the verifier: enableEdgeToEdge() is not called, so API 26-34 stays off the inset path (there the decor consumes the insets and the helper is the Scaffold padding alone). The dialogs were checked and left alone: NewSessionDialog has no text field, and the Board labels / session rename text fields live in AlertDialogs, which are floating windows. Android 15's PhoneWindow (android-all 15 bytecode) clears fit-insets types only for non-floating windows, so a dialog stays framed by the system; an imePadding() inside one could add the keyboard twice. A shared helper instead of three inline chains keeps the rule in one place. Verified: KeyboardInsetsTest (android/protocol, source pins through AppSourcePins) pins the helper's order, that nothing else in the app calls imePadding()/WindowInsets.ime, that every Scaffold body holding a text field uses the helper before its scroll, and that nothing calls enableEdgeToEdge()/setDecorFitsSystemWindows. Mutation-checked: with the three screens reverted 2 tests fail; with only PairScreen reverted, with the helper missing consumeWindowInsets, or with Settings' helper after the scroll, 1 test fails each. Full protocol suite (271 tests) and the offline app type-check pass. Nothing has run on a device; how the screens and dialogs look with the keyboard up is a device check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../kotlin/dev/nodeterm/android/ui/Insets.kt | 35 ++++++ .../dev/nodeterm/android/ui/PairScreen.kt | 2 +- .../dev/nodeterm/android/ui/SettingsScreen.kt | 2 +- .../dev/nodeterm/android/ui/TerminalScreen.kt | 3 +- .../nodeterm/protocol/KeyboardInsetsTest.kt | 108 ++++++++++++++++++ docs/android.md | 15 +++ 6 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/Insets.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeyboardInsetsTest.kt diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/Insets.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Insets.kt new file mode 100644 index 0000000000..f4adc86eb7 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Insets.kt @@ -0,0 +1,35 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier + +/** + * The body of a Scaffold that holds a text field: the Scaffold's padding, plus room for the soft + * keyboard (audit A77). The one place the app asks for the keyboard's inset. + * + * The app targets API 35, so on Android 15 its window is edge-to-edge whether it asks or not: the + * window is no longer resized for the keyboard (`adjustResize` stops doing it) and the system bars' + * and the keyboard's insets reach Compose instead. The Scaffold's padding already holds the + * navigation bar, and the keyboard's inset is measured from the bottom of the screen, navigation bar + * included, so a bare `imePadding()` after it counted the navigation bar twice. Consuming the padding + * first leaves `imePadding()` only what the keyboard covers beyond it. + * + * On API 26–34 the window is not edge-to-edge (the app does not call `enableEdgeToEdge()`, on + * purpose: every screen would then depend on its inset handling there too). The window's decor + * consumes those insets before they reach Compose and makes room for the keyboard itself + * (`adjustResize`), so this is the Scaffold's padding alone. + * + * Apply it before a `verticalScroll`: the keyboard then shrinks the scrolled viewport, which Compose + * keeps the focused field inside, instead of padding the content's end. + * + * Text fields in an `AlertDialog` do not go through here. A dialog is its own window, and a floating + * one (the theme's `dialogTheme`): the framework drops inset fitting only for a non-floating window, + * so a dialog stays framed by the system and does not rely on Compose's insets. An `imePadding()` + * inside one could add the keyboard a second time. How a dialog sits over the keyboard on Android 15 + * has not been checked on a device. + */ +fun Modifier.aboveKeyboard(scaffoldPadding: PaddingValues): Modifier = + padding(scaffoldPadding).consumeWindowInsets(scaffoldPadding).imePadding() diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt index 8bacbcf88d..3e66aa8324 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt @@ -92,7 +92,7 @@ fun PairScreen(nav: Navigator, initialCode: String? = null) { } ) { padding -> Column( - Modifier.fillMaxSize().padding(padding).padding(20.dp).verticalScroll(rememberScrollState()), + Modifier.fillMaxSize().aboveKeyboard(padding).padding(20.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp) ) { val p = payload diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt index c9ca8942bf..65e7465e97 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -114,7 +114,7 @@ fun SettingsScreen(nav: Navigator) { } ) { padding -> Column( - Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), + Modifier.fillMaxSize().aboveKeyboard(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text("This phone", style = MaterialTheme.typography.titleMedium) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index db7b092baf..a2ac03f7cf 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -89,7 +88,7 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String ) } ) { padding -> - Column(Modifier.fillMaxSize().padding(padding).imePadding()) { + Column(Modifier.fillMaxSize().aboveKeyboard(padding)) { Box(Modifier.weight(1f).fillMaxWidth().background(NtColors.canvas)) { // A new key = a new WebView: the old one was destroyed with its renderer (audit A45). key(controller.webViewKey) { diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeyboardInsetsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeyboardInsetsTest.kt new file mode 100644 index 0000000000..c6649bb654 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeyboardInsetsTest.kt @@ -0,0 +1,108 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A77: the app targets API 35, so on Android 15 its window is edge-to-edge and the keyboard no longer + * resizes it; the keyboard's inset reaches Compose instead. The terminal screen applied the Scaffold's + * padding and then `imePadding()` without consuming that padding, so with the keyboard up the + * navigation bar was counted twice (the keyboard's inset already includes it). Pair and Settings, + * whose text fields sit in a scrolling column, had no keyboard padding at all, so the keyboard could + * cover the field being typed in. + * + * Insets, the keyboard and a Scaffold cannot run on a JVM, so the rule is pinned in the app's source + * ([AppSourcePins]): one helper (`aboveKeyboard`) consumes the Scaffold's padding before it adds the + * keyboard's inset, no other code asks for that inset, and every Scaffold body that holds a text field + * uses the helper, before its scroll. How the screens look with the keyboard up is a device check. + */ +class KeyboardInsetsTest { + private val helper = "fun Modifier.aboveKeyboard(" + + /** + * Every Kotlin source in the app, by its path under the app's package root, without its block + * comments and whole-line comments: what the code does, not what its comments mention. + */ + private fun appSources(): Map = + AppSourcePins.appSrc.walkTopDown().filter { it.isFile && it.extension == "kt" } + .associate { it.relativeTo(AppSourcePins.appSrc).invariantSeparatorsPath to withoutComments(it.readText()) } + + private fun withoutComments(source: String): String = + source.replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("""(?m)^\s*//.*$"""), "") + + /** The body of the braces that open at [open] in [source], braces balanced. */ + private fun braces(source: String, open: Int): String = AppSourcePins.blockAfter(source.substring(open), "{") + + /** + * Each Scaffold's content lambda in [source]: its parameter name and its body. The content is the + * trailing lambda, the first `) { name ->` after `Scaffold(`. + */ + private fun scaffoldBodies(source: String): List> = + Regex("""\bScaffold\(""").findAll(source).map { call -> + val content = Regex("""\)\s*\{\s*(\w+)\s*->""").find(source, call.range.last) + ?: error("a Scaffold without a content lambda") + content.groupValues[1] to braces(source, content.range.first + content.value.indexOf('{')) + }.toList() + + @Test + fun `the helper consumes the Scaffold's padding before it adds the keyboard's inset`() { + val insets = AppSourcePins.ui("Insets.kt") + val at = insets.indexOf(helper) + assertTrue(at >= 0, "Insets.kt must define $helper") + val body = insets.substring(at) + AppSourcePins.assertInOrder( + body, + "padding(scaffoldPadding)", + "consumeWindowInsets(scaffoldPadding)", + "imePadding()" + ) + } + + @Test + fun `nothing but the helper asks for the keyboard's inset`() { + // A bare `imePadding()` after a Scaffold's padding counts the navigation bar twice; one in a + // dialog's content can add the keyboard to a window the system already keeps clear of it. + val callers = appSources().flatMap { (path, source) -> + Regex("""\bimePadding\(\)|WindowInsets\.ime\b""").findAll(source).map { path }.toList() + } + assertEquals(listOf("ui/Insets.kt"), callers) + } + + @Test + fun `every Scaffold body that holds a text field makes room for the keyboard, before its scroll`() { + val withField = mutableListOf() + for ((path, source) in appSources()) { + for ((param, body) in scaffoldBodies(source)) { + if (!body.contains("TextField(")) continue + withField += path + AppSourcePins.assertInOrder(body, ".aboveKeyboard($param)") + assertTrue( + !Regex("""\.padding\($param\)""").containsMatchIn(body), + "$path applies the Scaffold's padding itself as well as through aboveKeyboard" + ) + if (body.contains("verticalScroll(")) AppSourcePins.assertInOrder(body, ".aboveKeyboard($param)", ".verticalScroll(") + } + } + // The scan finds the screens it exists for, so it cannot pass by finding nothing. + assertTrue( + withField.containsAll(listOf("ui/TerminalScreen.kt", "ui/PairScreen.kt", "ui/SettingsScreen.kt")), + "Scaffold bodies with a text field: $withField" + ) + } + + @Test + fun `below Android 15 the window stays off the inset path`() { + // On API 26-34 the decor consumes the bars' and the keyboard's insets and adjustResize resizes + // the window, so the screens work there without them. enableEdgeToEdge() (or turning off + // decorFitsSystemWindows) would move those phones onto the inset path as well: a change for + // every screen on every phone, not part of this fix. + val sources = appSources() + assertTrue(sources.isNotEmpty()) + for ((path, source) in sources) { + assertTrue(!source.contains("enableEdgeToEdge("), "$path calls enableEdgeToEdge()") + assertTrue(!source.contains("setDecorFitsSystemWindows("), "$path changes decorFitsSystemWindows") + } + } +} diff --git a/docs/android.md b/docs/android.md index 89e9e1fe7c..a6c33a6e68 100644 --- a/docs/android.md +++ b/docs/android.md @@ -255,6 +255,21 @@ It also no longer shares a device row with the phone it was copied from, so remo an entitled desktop cannot revoke the other phone. The old row is left unused on the backend, as after an uninstall. An existing install keeps its id while its key still opens. +`KeyboardInsetsTest` covers the soft keyboard on Android 15 (`A77`). The app targets API 35, so on +Android 15 its window is edge-to-edge whether it asks or not: the keyboard no longer resizes the +window, and its inset reaches Compose instead. The terminal screen added the keyboard's inset on top +of the Scaffold's padding, which already held the navigation bar, so with the keyboard up the +navigation bar was counted twice. Pair and Settings, whose text fields sit in a scrolling column, +made no room for the keyboard at all. All three now go through one helper (`aboveKeyboard`), which +consumes the Scaffold's padding before it adds the keyboard's inset, and which comes before the +scroll, so the keyboard shrinks the visible part instead of padding the end. The app still does not +call `enableEdgeToEdge()`: on Android 8 to 14 the window keeps fitting the system bars and makes room +for the keyboard itself, as before, and the helper adds nothing there. Text fields in dialogs are left +alone: a dialog is a floating window, and the framework clears inset fitting only for non-floating +windows (read from Android 15's `PhoneWindow` classes, not measured). The test pins the helper, that +nothing else asks for the keyboard's inset, and that every Scaffold body with a text field uses it; +how the screens and the dialogs look with the keyboard up is a device check. + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From ac0923af630089d167c56d60059752fb6872eefa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:09:11 +0000 Subject: [PATCH 064/284] fix(android): make the R8 rules real and run R8 in CI (A37) The release build type had minification off, and proguard-rules.pro kept sshj/BouncyCastle/EdDSA but lacked the -dontwarn rules R8 in AGP 8 needs. Turning minification on as it stood would have failed on missing classes (sshj's GSSAPI org.ietf.jgss.* and javax.security.auth.login.LoginContext, eddsa's sun.security.x509.X509Key). It also had no keep for the two app classes reached by name. What changed: - proguard-rules.pro: added the three missing -dontwarn rules and kept javax.naming (BouncyCastle's LDAP/DANE code). Dropped -dontwarn org.slf4j.**, because slf4j-api 2.0.13 resolves completely. Added the runtime keeps R8 cannot infer: - the WebView bridge's @JavascriptInterface methods, which terminal.js calls by name (plus -keepattributes RuntimeVisibleAnnotations); - InboxWorker with its (Context, WorkerParameters) constructor, because WorkManager stores the class name and the periodic work is enqueued with KEEP; - com.hierynomus.sshj.** next to net.schmizz.sshj.**, since sshj is split over both package roots; - -keepnames for Throwable subclasses, because error text falls back to e.javaClass.simpleName. Comments now say what is and is not verified. - app/build.gradle.kts: release is minified. Debug stays unminified and is still the distributed APK. There is still no release signing config. - CI (android.yml): a separate "App release (R8, unsigned)" job runs :app:assembleRelease, then tools/check-r8-output.sh. The script checks R8's seeds.txt and mapping.txt: every bridge method and the worker constructor are seeds, and the worker, BouncyCastleProvider, AES$Mappings, the X25519 KeyAgreementSpi and HostException keep their names. The job uploads the mapping directory (including R8's missing_rules.txt on failure). It is separate so an R8 failure never withholds the debug APK. No signing secrets are involved. - R8RulesTest (android/protocol, JVM): reads the constant pools of the jars this module ships to the app (passed from build.gradle.kts as nodeterm.runtimeClasspath). It collects every reference Android cannot resolve and requires each to be covered by the app's -dontwarn or by the jar's own consumer rules, matching the missing class or its referrer as R8 does. It also flags stale app -dontwarn rules. It pins the bridge keep, the worker keep, the Throwable keepnames, a minified release, an unminified debug, and the CI leg. How it was verified: - jdeps over sshj 0.39.0, eddsa 0.3.0, bcprov/bcpkix/bcutil 1.78.1, OkHttp 4.12.0, okio, slf4j-api 2.0.13, kotlinx and kotlin-stdlib. The scan finds exactly jdeps' list. - javap: the reflective call sites are in sshj SecurityUtils and BouncyCastle's provider tables. OkHttp only reflects on platform classes. zxing-android-embedded 4.3.0 and zxing core 3.4.1 (fetched from Maven Central for inspection) neither reflect nor reference anything Android lacks. - Full protocol suite (277 tests) and the offline app type-check pass. - Mutation checks, each making the new test fail: - running R8RulesTest on the pre-fix rules file (5 of 6 fail; the missing-class list is exactly the audit's); - deleting the new -dontwarn lines; - dropping the JS keep; - adding allowobfuscation to the worker keep; - setting release isMinifyEnabled=false; - removing assembleRelease from CI; - adding a stale -dontwarn. - check-r8-output.sh was exercised against synthetic seeds/mapping files: all good, a missing seed, a renamed class, a missing directory. Not verified: R8 has not run. AGP needs Google Maven, which is blocked here, so the first real R8 run is CI. Some points are inferred, not read from bytes: - the seeds.txt/mapping.txt formats and location the script greps; - R8 honouring a -dontwarn that matches the referrer; - androidx's consumer rules (WorkManager, androidx.startup, Compose). A green run proves the build and that the keeps matched. It does not prove that reflection-loaded code works on a device; no release APK has run on one. Departures from the audit: the proposal only added -dontwarn lines. This also enables R8 for release and gates it in CI, as the task asked. It names LoginContext and X509Key exactly rather than .** wildcards. It drops the slf4j rule, which the verifier called optional. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 32 ++ android/README.md | 6 +- android/app/build.gradle.kts | 9 +- android/app/proguard-rules.pro | 83 +++++- android/protocol/build.gradle.kts | 15 +- .../dev/nodeterm/protocol/R8RulesTest.kt | 276 ++++++++++++++++++ android/tools/check-r8-output.sh | 75 +++++ docs/android.md | 8 +- 8 files changed, 492 insertions(+), 12 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/R8RulesTest.kt create mode 100755 android/tools/check-r8-output.sh diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 997c84a27a..cd3728defd 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -78,3 +78,35 @@ jobs: with: name: nodeterm-android-debug path: android/app/build/outputs/apk/debug/*.apk + + # R8 runs only for the release build type, and the debug APK above is the one distributed. Building + # the release here (unsigned; no signing secrets exist or are needed) makes a missing -dontwarn or + # -keep in app/proguard-rules.pro fail CI instead of the first real release (audit A37). A separate + # job so an R8 failure never withholds the debug APK. Green proves the build and that the runtime + # keeps matched (tools/check-r8-output.sh); it does not prove reflection-loaded code works on a phone. + app-release: + name: App release (R8, unsigned) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Build release APK (R8) + working-directory: android + run: ./gradlew :app:assembleRelease --stacktrace + + - name: Check the R8 keeps matched + working-directory: android + run: sh tools/check-r8-output.sh app/build/outputs/mapping/release + + # On a missing-class failure R8 writes the -dontwarn lines it wanted to missing_rules.txt here. + - uses: actions/upload-artifact@v4 + if: always() + with: + name: r8-release-outputs + path: android/app/build/outputs/mapping/release + if-no-files-found: ignore diff --git a/android/README.md b/android/README.md index cd978c25a1..478ad75949 100644 --- a/android/README.md +++ b/android/README.md @@ -44,7 +44,11 @@ cd android Needs JDK 17+ and the Android SDK (Android Studio's, or `ANDROID_HOME`). `minSdk` 26, `targetSdk` 35. CI builds the debug APK on every change under `android/` (`.github/workflows/android.yml`) and -attaches it to the run. +attaches it to the run. The debug APK is not minified. Only the release build type runs R8 +(`app/proguard-rules.pro`), and CI builds it too, unsigned and not published +(`./gradlew :app:assembleRelease`, then `tools/check-r8-output.sh`). A missing R8 rule therefore +fails CI rather than a first release. That proves the rules build and that their keeps matched, +not that a minified APK works on a phone; none has been run on one. **Debug builds are signed with a public key that is committed on purpose** (`app/debug.keystore`, password `android`). That is what lets you install a newer CI or local diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 209dbe8f63..7b2561de62 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -38,8 +38,13 @@ android { signingConfig = signingConfigs.getByName("debug") } release { - // sshj + BouncyCastle rely on reflection; shrinking needs keep rules nobody has written yet. - isMinifyEnabled = false + // R8 (shrink + obfuscate) runs for release only; debug stays unminified and is the APK that + // is distributed. CI builds this unsigned release on every change so a missing rule fails CI + // (audit A37). That proves R8 accepts proguard-rules.pro and that its keeps matched + // (tools/check-r8-output.sh); it does NOT prove the reflection-loaded code (BouncyCastle's + // provider tables, the WebView bridge, the WorkManager worker) works on a device, which + // nobody has run a release build on. There is no release signing config yet. + isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 6a44cea9a2..3c6296c18a 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,7 +1,82 @@ -# Minification is off (see build.gradle.kts). If it is ever turned on, sshj, BouncyCastle and -# EdDSA need keeps for their reflective provider/algorithm lookups. --keep class net.schmizz.sshj.** { *; } +# R8 rules for the RELEASE build type (audit A37). Debug is not minified, and the debug APK is the +# one distributed. CI builds an unsigned release APK (`:app:assembleRelease`) on every change, so R8 +# runs against these rules and a missing one fails CI instead of the first real release. +# +# What is verified, and by what: +# - R8 finishing in CI: the build accepts these rules (no missing-class errors). Nothing more. +# - tools/check-r8-output.sh (CI, after that build): the runtime keeps below MATCHED. The bridge +# methods, the worker, the BouncyCastle provider tables and an exception class are in R8's seeds +# and keep their names in its mapping. A keep whose class was renamed matches nothing, silently. +# - R8RulesTest (android/protocol, runs without AGP): re-derives the missing classes from the jars +# the app ships and checks every one is covered below or by the library's own consumer rules, and +# pins the runtime keeps against the app's sources. +# - NOT verified: that the reflection-loaded code works on a device. The release APK has never run +# on one. R8 passing proves the build, not the runtime. +# +# The reflection facts below were read from the jars with javap/jdeps (sshj 0.39.0, eddsa 0.3.0, +# bcprov/bcpkix/bcutil 1.78.1, OkHttp 4.12.0, slf4j-api 2.0.13, zxing-android-embedded 4.3.0, zxing +# core 3.4.1). Classes of the APP loaded by name: BouncyCastle's (by BouncyCastle, the JCA and sshj's +# SecurityUtils), and the two app classes below. OkHttp's reflection targets platform classes R8 never +# renames (com.android.org.conscrypt, dalvik.system.CloseGuard); slf4j's ServiceLoader finds no binding +# on Android either way and falls back to its no-op logger; eddsa and zxing do not reflect. What rests +# on a library's own consumer rules instead (androidx, Compose, WorkManager and androidx.startup) is +# inferred: those AARs are on Google Maven, which the sandbox these rules were written in cannot read. + +# ---- Runtime keeps: code reached by NAME, which R8's tracing cannot see --------------------------- + +# The terminal page (assets/terminal/terminal.js) calls TerminalController.Bridge's methods by name +# through WebView.addJavascriptInterface, and Android exposes only methods that still carry the +# @JavascriptInterface annotation. Without this R8 renames or drops onReady/onInput/onResize/…, and the +# terminal gets no keystrokes and never sizes its pty. AGP's default file carries the same rule; it is +# restated so the bridge does not hang on which default file is in use. +-keepattributes RuntimeVisibleAnnotations +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# WorkManager instantiates the background check from the class NAME it stored in its database, through +# the (Context, WorkerParameters) constructor. The periodic work is enqueued with KEEP, so a name that +# changed between two releases would leave the stored work pointing at a class that no longer exists, +# and notifications would stop with nothing on screen saying why. work-runtime ships a rule for every +# ListenableWorker (inferred, see above); this one does not depend on it. +-keep class dev.nodeterm.android.notify.InboxWorker { + public (android.content.Context, androidx.work.WorkerParameters); +} + +# BouncyCastle is a JCA provider, and that is reflection end to end: BouncyCastleProvider loads its +# algorithm tables by name ("org.bouncycastle.jcajce.provider.symmetric." + "AES" + "$Mappings"), and +# the JCA instantiates every Cipher / KeyAgreement / Signature SPI from a class-name string. sshj asks +# the "BC" provider for its ciphers and key exchanges, and its SecurityUtils also loads +# org.bouncycastle.jce.provider.BouncyCastleProvider with Class.forName. Which SPIs a connection needs +# is decided at runtime by what the computer offers, so all of it is kept. -keep class org.bouncycastle.** { *; } + +# sshj itself reflects only in SecurityUtils (above). It is kept whole anyway, BOTH of its package roots +# (the old rule named only net.schmizz.sshj), so the SSH transport the protocol tests run on the JVM is +# the one that ships; the cost is APK size, not behaviour. Same for EdDSA: no reflection and no +# provider registration (the phone's key is built with it directly), 33 classes. +-keep class net.schmizz.sshj.** { *; } +-keep class com.hierynomus.sshj.** { *; } -keep class net.i2p.crypto.eddsa.** { *; } + +# User-facing error text falls back to the exception's class name when it has no message +# (`e.message ?: e.javaClass.simpleName`: SshHostConnection, SshFallback, PairingClient, RelayApi, +# OkHttpRelayTransport, SecretStoreCore, ConnectionManager). Keep the names so it does not read "(a)". +-keepnames class * extends java.lang.Throwable + +# ---- Missing classes: referenced from the jars the app ships, absent from android.jar --------------- +# R8 in AGP 8 fails the build on each of these. Found with jdeps over the runtime jars; each is reached +# only by code the app never runs on a phone. The other missing references (OkHttp's Conscrypt / +# OpenJSSE / BouncyCastle-JSSE platforms, kotlinx-coroutines' java.lang.instrument and sun.misc agent, +# java.lang.ClassValue in coroutines and serialization) are covered by the consumer rules those jars +# ship. R8RulesTest re-derives the list; slf4j-api 2.x resolves completely, so it needs no rule. + +# sshj: GSSAPI (Kerberos) user authentication, AuthGssApiWithMic and SSHClient.authGssApiWithMic. +-dontwarn org.ietf.jgss.** +-dontwarn javax.security.auth.login.LoginContext +# eddsa: EdDSAEngine.engineInitVerify re-encodes an OpenJDK X509Key, a branch taken only for a key that +# is not an EdDSAPublicKey; sshj's Ed25519 keys (Ed25519PublicKey) always are. +-dontwarn sun.security.x509.X509Key +# BouncyCastle: LDAP certificate stores and the DANE fetcher (JNDI). OkHttp's consumer rules happen to +# -dontwarn every class under org.bouncycastle.** as the referrer too; this does not lean on that. -dontwarn javax.naming.** --dontwarn org.slf4j.** diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index bf7d0d0b40..6f6d267841 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -45,12 +45,21 @@ tasks.test { // from here and skip themselves (with a reason) when node or node_modules are missing. systemProperty("nodeterm.repoRoot", rootDir.resolve("../..").canonicalPath) // Files some tests run or read that are not on the test classpath: the node drivers, the app's - // terminal page (TerminalJs*Test) and the app's screen sources (the source pins in - // TerminalKeyboardChipTest and SettingsLeaveTest). + // terminal page (TerminalJs*Test), the app's sources (the source pins in TerminalKeyboardChipTest, + // SettingsLeaveTest and R8RulesTest), and the release build's R8 rules, its build type and the CI + // workflow that runs R8 (R8RulesTest). // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) inputs.dir(rootDir.resolve("../app/src/main/assets/terminal")).withPathSensitivity(PathSensitivity.RELATIVE) - inputs.dir(rootDir.resolve("../app/src/main/kotlin/dev/nodeterm/android/ui")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir(rootDir.resolve("../app/src/main/kotlin")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../app/proguard-rules.pro")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../app/build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../../.github/workflows/android.yml")).withPathSensitivity(PathSensitivity.RELATIVE) + // R8RulesTest reads the jars this module ships to the app (its runtime classpath, not the test one) + // for the classes they reference that Android lacks (audit A37). + val shippedJars: FileCollection = configurations.runtimeClasspath.get() + inputs.files(shippedJars).withPropertyName("shippedJars").withNormalizer(ClasspathNormalizer::class.java) + jvmArgumentProviders.add(CommandLineArgumentProvider { listOf("-Dnodeterm.runtimeClasspath=${shippedJars.asPath}") }) systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn") testLogging { events("failed", "skipped") diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/R8RulesTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/R8RulesTest.kt new file mode 100644 index 0000000000..d40ae650c6 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/R8RulesTest.kt @@ -0,0 +1,276 @@ +package dev.nodeterm.protocol + +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import java.io.File +import java.util.zip.ZipFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Audit A37: the release build's R8 rules, `android/app/proguard-rules.pro`. + * + * R8 itself runs in CI (`:app:assembleRelease` plus `tools/check-r8-output.sh`, in + * `.github/workflows/android.yml`); AGP needs Google Maven, so it cannot run everywhere these tests + * do. This checks what a JVM can: + * + * 1. Missing classes. R8 fails the build on a class that code it compiles references and neither the + * app nor android.jar defines, unless a `-dontwarn` names the missing class or the class that + * references it. The jars the app gets through this module (sshj, BouncyCastle, EdDSA, OkHttp, + * kotlinx) are read here, every reference Android cannot resolve is collected, and each must be + * covered by the app's rules or by the consumer rules the jar itself ships. The previous rules + * missed three (sshj's GSSAPI classes and `LoginContext`, EdDSA's `X509Key`). + * 2. Keeps for code reached by NAME, which R8's tracing cannot see and a successful build does not + * check: the WebView bridge (terminal.js calls its methods by name) and the WorkManager worker + * (instantiated from the class name WorkManager stored). + * + * "Android cannot resolve" is an approximation: not defined in the scanned jars, and either absent + * from the JDK or in a package android.jar does not ship ([androidLacks]). It over-approximates R8, + * which only reports references from code it keeps. The app's own dependencies (androidx, Compose, + * zxing) are not on this classpath; CI's R8 run covers them. None of this proves a release APK works + * on a device. + */ +class R8RulesTest { + private val root = InteropHarness.repoRoot + private val rulesFile = File(root, "android/app/proguard-rules.pro") + private val appSrc = File(root, "android/app/src/main/kotlin") + + /** The runtime jars of this module, i.e. what the app ships from it (set by build.gradle.kts). */ + private val shippedJars: List by lazy { + val path = System.getProperty("nodeterm.runtimeClasspath") + ?: error("nodeterm.runtimeClasspath is not set; run through Gradle (see protocol/build.gradle.kts)") + path.split(File.pathSeparator).filter { it.endsWith(".jar") }.map(::File) + } + + private fun rulesText(): String = rulesFile.readText().replace(Regex("#[^\n]*"), "") + + /** The scan of [shippedJars]; it must see the jars, or the tests below pass on nothing. */ + private val scan: Scan by lazy { + scanShippedJars().also { assertTrue(it.classes > 1000, "only ${it.classes} classes scanned from $shippedJars") } + } + + /** + * App-rule `-dontwarn` patterns for dependencies of the app that are not on this module's classpath + * (androidx, Compose, zxing), which the scan therefore cannot see used. Empty today. + */ + private val appOnlyDontwarns = emptySet() + + // ---- 1. missing classes ---------------------------------------------------------------------- + + @Test + fun `every class Android lacks that a shipped jar references is covered by a -dontwarn`() { + val appRules = dontwarnFilters(rulesText()) + val rules = appRules + scan.consumerDontwarns + val uncovered = scan.missing.mapNotNull { (missing, contexts) -> + if (rules.any { it.matches(missing) }) return@mapNotNull null + // R8 drops a missing-class report when every class referencing it is -dontwarn'ed too. + val open = contexts.filterNot { ctx -> rules.any { it.matches(ctx) } } + if (open.isEmpty()) null else "$missing <- ${open.sorted().take(3).joinToString()}" + }.sorted() + assertEquals( + emptyList(), uncovered, + "R8 would fail the release build on these (missing class <- referenced from). Add a -dontwarn " + + "to android/app/proguard-rules.pro once you have checked the code is never reached on a phone." + ) + } + + @Test + fun `every -dontwarn in the app rules still matches a reference in the shipped jars`() { + val names = scan.missing.keys + scan.missing.values.flatten() + val unused = dontwarnFilters(rulesText()).map { it.spec }.filter { it !in appOnlyDontwarns } + .filter { spec -> ClassFilter(spec).let { f -> names.none { f.matches(it) } } } + assertEquals( + emptyList(), unused, + "these -dontwarn rules match no missing reference in the shipped jars (a stale rule hides the next real " + + "one): drop them, or, for an app-only dependency this classpath does not contain, list them in appOnlyDontwarns" + ) + } + + // ---- 2. keeps for code reached by name -------------------------------------------------------- + + @Test + fun `the WebView bridge's @JavascriptInterface methods survive R8 with their annotation`() { + val bridges = appSrc.walkTopDown().filter { it.extension == "kt" && "@JavascriptInterface" in it.readText() }.toList() + assertTrue(bridges.isNotEmpty(), "no @JavascriptInterface in the app sources; was the bridge moved?") + val rules = rulesText().replace(Regex("\\s+"), " ") + assertTrue( + Regex("""-keepclassmembers class \* \{ @android\.webkit\.JavascriptInterface ?; ?}""").containsMatchIn(rules), + "terminal.js calls the bridge's methods by name; they need a -keepclassmembers rule" + ) + assertTrue( + Regex("""-keepattributes [^-]*RuntimeVisibleAnnotations""").containsMatchIn(rules), + "Android exposes only methods still annotated @JavascriptInterface" + ) + } + + @Test + fun `every WorkManager worker keeps its name and its (Context, WorkerParameters) constructor`() { + val workers = appSrc.walkTopDown().filter { it.extension == "kt" }.flatMap { f -> + val text = f.readText() + val pkg = Regex("""(?m)^package\s+([\w.]+)""").find(text)?.groupValues?.get(1).orEmpty() + Regex("""class\s+(\w+)\s*\([^)]*\)\s*:\s*(?:CoroutineWorker|Worker|ListenableWorker)\s*\(""") + .findAll(text).map { "$pkg.${it.groupValues[1]}" } + }.toList() + assertTrue(workers.isNotEmpty(), "no worker found in the app sources; was InboxWorker moved?") + val rules = rulesText().replace(Regex("\\s+"), " ") + for (w in workers) { + // `-keep class` with no allowobfuscation: WorkManager stores the class name across releases. + val keep = Regex( + "-keep class " + Regex.escape(w) + + """ \{[^}]*public ?\( ?android\.content\.Context ?, ?androidx\.work\.WorkerParameters ?\) ?;[^}]*}""" + ) + assertTrue(keep.containsMatchIn(rules), "$w needs `-keep class $w { public (android.content.Context, androidx.work.WorkerParameters); }`") + } + } + + @Test + fun `exception class names survive where error text falls back to them`() { + val sources = listOf(appSrc, File(root, "android/protocol/src/main/kotlin")) + val users = sources.flatMap { dir -> dir.walkTopDown().filter { it.extension == "kt" && "javaClass.simpleName" in it.readText() }.toList() } + if (users.isEmpty()) return + assertTrue( + Regex("""-keepnames class \* extends java\.lang\.Throwable""").containsMatchIn(rulesText().replace(Regex("\\s+"), " ")), + "${users.map { it.name }} show e.javaClass.simpleName to the user; obfuscated it reads \"a\"" + ) + } + + @Test + fun `the release build runs R8 and CI builds it, while debug stays unminified`() { + val gradle = File(root, "android/app/build.gradle.kts").readText() + val release = AppSourcePins.blockAfter(gradle, "release {") + val debug = AppSourcePins.blockAfter(gradle, "debug {") + assertTrue(Regex("""isMinifyEnabled\s*=\s*true""").containsMatchIn(release), "release must be minified:\n$release") + assertTrue("proguard-rules.pro" in release, "release must use proguard-rules.pro:\n$release") + assertTrue(!Regex("""isMinifyEnabled\s*=\s*true""").containsMatchIn(debug), "debug is the distributed APK and stays unminified") + val ci = File(root, ".github/workflows/android.yml").readText() + assertTrue(":app:assembleRelease" in ci, "CI must build the release APK so R8 runs on every change") + assertTrue("tools/check-r8-output.sh" in ci, "CI must check the R8 keeps matched") + } + + // ---- the scan --------------------------------------------------------------------------------- + + private class Scan(val classes: Int, val missing: Map>, val consumerDontwarns: List) + + /** + * Packages the JDK has and android.jar (API 35) does not. Only what a shipped jar could plausibly + * reference; the list decides nothing for a class that no jar references. + */ + private val androidLacks = listOf( + "sun.", "com.sun.", "jdk.", "org.ietf.jgss.", "javax.naming.", "java.lang.instrument.", + "java.lang.management.", "javax.management.", "java.awt.", "javax.swing.", "javax.imageio.", + "java.rmi.", "javax.rmi.", "javax.security.auth.kerberos.", "java.net.http.", "javax.script.", + "javax.tools.", "javax.lang.model.", "javax.annotation.processing." + ) + + private fun androidLacks(name: String): Boolean = + androidLacks.any { name.startsWith(it) } || + // android.jar's javax.security.auth.login holds LoginException and nothing else. + (name.startsWith("javax.security.auth.login.") && name != "javax.security.auth.login.LoginException") + + private fun inJdk(name: String): Boolean = + ClassLoader.getPlatformClassLoader().getResource(name.replace('.', '/') + ".class") != null + + private fun scanShippedJars(): Scan { + val defined = HashSet() + val refs = HashMap>() // referenced class -> classes referencing it + val consumer = ArrayList() + var count = 0 + for (jar in shippedJars) ZipFile(jar).use { zip -> + for (e in zip.entries()) { + val n = e.name + if (n.endsWith(".pro") && (n.startsWith("META-INF/proguard/") || n.startsWith("META-INF/com.android.tools/"))) { + consumer += dontwarnFilters(zip.getInputStream(e).readBytes().decodeToString().replace(Regex("#[^\n]*"), "")) + } + // D8/R8 ignore everything under META-INF (multi-release versions included) and module-info. + if (!n.endsWith(".class") || n.startsWith("META-INF/") || n.endsWith("module-info.class")) continue + val (self, used) = classReferences(zip.getInputStream(e).readBytes()) + defined += self + count++ + for (r in used) if (r != self) refs.getOrPut(r) { HashSet() } += self + } + } + val missing = refs.filterKeys { r -> + r !in defined && !r.startsWith("android.") && !r.startsWith("dalvik.") && (!inJdk(r) || androidLacks(r)) + } + return Scan(count, missing, consumer) + } + + /** This class's name and every class its constant pool and member descriptors name. */ + private fun classReferences(bytes: ByteArray): Pair> { + val input = DataInputStream(ByteArrayInputStream(bytes)) + require(input.readInt() == 0xCAFEBABE.toInt()) { "not a class file" } + input.readUnsignedShort(); input.readUnsignedShort() + val n = input.readUnsignedShort() + val utf8 = arrayOfNulls(n) + val classNameAt = IntArray(n) // CONSTANT_Class #i -> the Utf8 holding its name + val descIdx = ArrayList() + var i = 1 + while (i < n) { + when (val tag = input.readUnsignedByte()) { + 1 -> utf8[i] = input.readUTF() + 3, 4 -> input.readInt() + 5, 6 -> { input.readLong(); i++ } // takes two slots + 7 -> classNameAt[i] = input.readUnsignedShort() + 8, 19, 20 -> input.readUnsignedShort() + 16 -> descIdx += input.readUnsignedShort() // MethodType + 9, 10, 11, 17, 18 -> { input.readUnsignedShort(); input.readUnsignedShort() } + 12 -> { input.readUnsignedShort(); descIdx += input.readUnsignedShort() } // NameAndType + 15 -> { input.readUnsignedByte(); input.readUnsignedShort() } + else -> error("constant pool tag $tag") + } + i++ + } + input.readUnsignedShort() // access + val self = utf8[classNameAt[input.readUnsignedShort()]] ?: error("no this_class") + input.readUnsignedShort() // super_class, a CONSTANT_Class like any other + repeat(input.readUnsignedShort()) { input.readUnsignedShort() } + repeat(2) { // fields, then methods: their descriptors name types no instruction may touch + repeat(input.readUnsignedShort()) { + input.readUnsignedShort(); input.readUnsignedShort() + descIdx += input.readUnsignedShort() + repeat(input.readUnsignedShort()) { input.readUnsignedShort(); input.skipBytes(input.readInt()) } + } + } + val names = HashSet() + val inDescriptor = Regex("L([^;]+);") + for (idx in classNameAt) { + val s = if (idx == 0) continue else utf8[idx] ?: continue + if (s.startsWith("[")) inDescriptor.findAll(s).forEach { names += it.groupValues[1] } else names += s + } + for (idx in descIdx) utf8[idx]?.let { d -> inDescriptor.findAll(d).forEach { names += it.groupValues[1] } } + return self.replace('/', '.') to names.map { it.replace('/', '.') }.toSet() + } + + // ---- ProGuard class filters ------------------------------------------------------------------- + + /** A `-dontwarn` argument: a comma list of `?`/`*`/`**` patterns, each optionally negated with `!`. */ + private class ClassFilter(val spec: String) { + private val parts = spec.split(',').map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("**") }.map { p -> + val negated = p.startsWith("!") + negated to Regex(glob(p.removePrefix("!"))) + } + + fun matches(name: String): Boolean { + for ((negated, re) in parts) if (re.matches(name)) return !negated + return false + } + + private fun glob(g: String): String = buildString { + var i = 0 + while (i < g.length) { + when { + g.startsWith("**", i) -> { append(".*"); i += 2; continue } + g[i] == '*' -> append("[^.]*") + g[i] == '?' -> append("[^.]") + else -> append(Regex.escape(g[i].toString())) + } + i++ + } + } + } + + private fun dontwarnFilters(rules: String): List = + rules.lines().map { it.trim() }.filter { it.startsWith("-dontwarn") } + .map { ClassFilter(it.removePrefix("-dontwarn").trim()) } +} diff --git a/android/tools/check-r8-output.sh b/android/tools/check-r8-output.sh new file mode 100755 index 0000000000..921554f96c --- /dev/null +++ b/android/tools/check-r8-output.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# Checks the release build's R8 output against the runtime keeps in app/proguard-rules.pro (audit A37). +# CI runs it after `./gradlew :app:assembleRelease`, from android/: +# +# tools/check-r8-output.sh [app/build/outputs/mapping/release] +# +# R8 finishing proves only that it accepted the rules. A keep whose class was renamed or moved matches +# nothing and R8 does not say so, so this checks the keeps MATCHED what they exist for: +# seeds.txt every class and member a keep rule pinned, under its original name; +# mapping.txt "original -> output:" for every class that survived. +# It does not prove that the reflection-loaded code works on a device; nothing here runs the APK. +set -eu + +dir=${1:-app/build/outputs/mapping/release} +seeds=$dir/seeds.txt +mapping=$dir/mapping.txt +bridge_src=app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +bridge=dev.nodeterm.android.ui.TerminalController\$Bridge + +for f in "$seeds" "$mapping" "$bridge_src"; do + if [ ! -f "$f" ]; then + echo "check-r8-output: $f is missing (R8 did not run, AGP moved its outputs, or the bridge moved)." >&2 + ls -la "$dir" >&2 || true + exit 1 + fi +done + +fail=0 +ok() { echo "ok $1"; } +bad() { echo "FAIL $1" >&2; fail=1; } +# An extended-regex literal for a class or member name ('.' and '$' are the only specials they hold). +lit() { printf '%s' "$1" | sed 's/[.$]/\\&/g'; } + +# 1. Every @JavascriptInterface method of the bridge is a seed (kept, under its own name). The names +# come from the source, so a new bridge method is checked without editing this script. +methods=$(grep -A1 '@JavascriptInterface' "$bridge_src" | grep -oE 'fun [A-Za-z0-9_]+' | sed 's/^fun //') +[ -n "$methods" ] || bad "no @JavascriptInterface methods found in $bridge_src" +for m in $methods; do + if grep -Eq "^$(lit "$bridge"): .*[ .]$m\(" "$seeds"; then ok "bridge method $m kept"; else bad "bridge method $m is not a seed"; fi +done + +# 2. The worker keeps its name (WorkManager stored it) and its (Context, WorkerParameters) constructor. +worker=dev.nodeterm.android.notify.InboxWorker +if grep -Eq "^$(lit "$worker"): .*\(android\.content\.Context, ?androidx\.work\.WorkerParameters\)" "$seeds"; then + ok "$worker constructor kept" +else + bad "$worker (Context, WorkerParameters) constructor is not a seed" +fi + +# 3. Classes loaded by name keep their names: the worker, BouncyCastle's provider, one of the algorithm +# tables it loads by string concatenation, the X25519 SPI sshj's curve25519 key exchange asks it +# for, and an exception whose name user-facing error text can fall back to. A class R8 did not +# rename may be absent from mapping.txt; a pinned one is then still a line of its own in seeds.txt. +for c in \ + "$worker" \ + org.bouncycastle.jce.provider.BouncyCastleProvider \ + 'org.bouncycastle.jcajce.provider.symmetric.AES$Mappings' \ + 'org.bouncycastle.jcajce.provider.asymmetric.edec.KeyAgreementSpi$X25519' \ + dev.nodeterm.protocol.host.HostException +do + l=$(lit "$c") + if grep -Eq "^$l -> " "$mapping"; then + if grep -Eq "^$l -> $l:\$" "$mapping"; then ok "$c keeps its name"; else bad "$c was renamed: $(grep -E "^$l -> " "$mapping")"; fi + elif grep -Eq "^$l\$" "$seeds"; then + ok "$c keeps its name (a seed; not in mapping.txt)" + else + bad "$c is neither in mapping.txt nor a seed: removed" + fi +done + +if [ "$fail" -ne 0 ]; then + echo "check-r8-output: a keep rule in app/proguard-rules.pro did not match (see FAIL lines)." >&2 + exit 1 +fi +echo "check-r8-output: all runtime keeps matched." diff --git a/docs/android.md b/docs/android.md index a6c33a6e68..2997dbe411 100644 --- a/docs/android.md +++ b/docs/android.md @@ -115,8 +115,12 @@ unchanged. The **app** module is built by CI (`.github/workflows/android.yml`) against the runner's Android SDK. The first run, [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730) -on `2f58918`, built the debug APK successfully. The app has no instrumented tests and has **not been -run on a device**. An audit of the code found release blockers; the fixed ones are marked in its +on `2f58918`, built the debug APK successfully. CI also builds an unsigned release APK, the only build +type R8 minifies (audit `A37`). A missing `-dontwarn` or `-keep` then fails CI instead of the first +release, and `tools/check-r8-output.sh` checks that the keeps for reflection-loaded code (the WebView +bridge, the WorkManager worker, BouncyCastle's provider tables) matched. `R8RulesTest` re-derives the +classes Android lacks from the jars the app ships. The debug APK stays unminified and is the one +distributed. The app has no instrumented tests and has **not been run on a device**, minified or not. An audit of the code found release blockers; the fixed ones are marked in its index, and the rest are open: [`android-audit-2026-09.md`](android-audit-2026-09.md). The plan, the device checklist and the decisions still open are in [`android-handover.md`](android-handover.md). From 5fb3aea455786d35760e9f43e4ea22969e4a1780 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:38:02 +0000 Subject: [PATCH 065/284] fix(android): address review of A40 Going to the background while `conn.attach(...)` was in flight still dropped a stream and a phone launch. onStop() cancels the attach job, and a cancelled attach threw away a stream the transport had already opened, with no detach, before PendingLaunches.take ever ran: - Relay: the attach's continuation had no cancellation handling, so the late reply registered the stream and resumed a cancelled continuation (ignored). The host had already reserved it: a phone viewer on the node (an Eco shield and a size ceiling on the desktop) until the relay closed. - SSH: withContext(Dispatchers.IO) drops the finished block's result once the caller is cancelled, leaving the reader thread and the tmux client attached for the life of the connection. The session the host created from the create hint then had no launch line and no registerNode until the next ON_START, and none at all if the process died in the background (PendingLaunches is in memory only). What changed: - Both transports keep a contract now written on HostConnection.attach: a cancelled attach leaves nothing attached. The relay resumes with an onCancellation handler that forgets the stream and sends `pty.kill` (non-suspending `abandon()`; it also covers a cancel after the resume but before dispatch). SSH detaches, under NonCancellable, a stream whose open finished after the cancel (the A20 dial's shape). This also covers the other caller, SessionsTab's "End session" attach. - TerminalController.attach(): ON_STOP (and a renderer loss) still cancel the connect or relay approval wait, but not an attach already sent. After `ensureActive()`, the attach, the launch hand-off and the screen hand-off run in withContext(NonCancellable), so the stream always arrives, is let go of by the ticket, and a created session gets its launch at once. - The relay approval code a dial reports is shown only while its attach is current, so a superseded dial cannot put its code over the current screen. Departure from the review's first suggestion: onStop keeps cancelling, rather than not cancelling at all. Not cancelling would keep a connect or an approval wait running behind a screen nobody sees, and the next attach would wait on that dial's lock without its own approval code (which the new ticket gate now also hides). The boundary is the one the review offered as the alternative: stop cancelling once the request is on the wire. Verified: RelayInteropTest (the desktop's real host through host-fixture.ts, which now has a slow `term-slow-*` probe and emits remoteViewer attach/detach) cancels an attach after the host received it and checks the viewer is released; SshTransportTest (MINA sshd + sandboxed tmux) cancels the attach exactly while the server starts `attach-session` and checks the channel's process exits and no tmux client stays; TerminalHandoffTest pins the controller's attach order and the approval gate in source (only a device runs it). Mutation checks: without the relay handler the relay test fails (no viewer-detached), without the SSH cleanup the SSH test fails (the process never exits), and removing NonCancellable or the ticket gate fails the source pin. Full protocol suite (280 tests) and the offline app type-check pass; the fixture also type-checks with tsc. Nothing was run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/android/ui/TerminalController.kt | 51 ++++++++++++------- .../nodeterm/protocol/host/HostConnection.kt | 6 +++ .../protocol/host/RelayHostConnection.kt | 17 ++++++- .../protocol/ssh/SshHostConnection.kt | 39 +++++++++----- .../protocol/src/test/interop/host-fixture.ts | 12 +++++ .../dev/nodeterm/protocol/RelayInteropTest.kt | 21 ++++++++ .../dev/nodeterm/protocol/SshTransportTest.kt | 50 +++++++++++++++++- .../nodeterm/protocol/TerminalHandoffTest.kt | 23 +++++++++ docs/android.md | 10 +++- 9 files changed, 196 insertions(+), 33 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index c324ee969a..9d00980a07 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -47,8 +47,11 @@ import dev.nodeterm.protocol.model.InputBar import dev.nodeterm.protocol.model.Keys import dev.nodeterm.protocol.model.Osc52 import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream sealed interface TermState { @@ -459,7 +462,8 @@ class TerminalController( try { val conn = if (useRelay) { session.viaRelay { st -> - if (st is RelayConnectStatus.AwaitingApproval) main.post { state = TermState.AwaitingApproval(st.sas) } + // A superseded attach's dial must not put its code over the current screen. + if (st is RelayConnectStatus.AwaitingApproval) main.post { if (slot.isCurrent(ticket)) state = TermState.AwaitingApproval(st.sas) } } } else { session.ensureConnected() @@ -469,21 +473,31 @@ class TerminalController( // A session this phone is starting: let the host create it in its project, under // the chosen account (audit A33). val hint = PendingLaunches.peek(nodeId)?.let { NewSessionHint(it.projectId, it.accountId, it.agentId) } - val s = conn.attach(nodeId, c, r, sink, hint) - val lease = StreamLease(s) { st -> graph.scope.launch { runCatching { st.detach() } } } - // The host created this session for the launch just now, and the request is consumed - // here: the launch holds the stream until it is done, whatever this screen does next - // (audit A40). Started BEFORE the hand-off, so a screen that already left cannot - // detach the stream under it. - val launch = if (hint != null) PendingLaunches.take(nodeId) else null - if (launch != null) startLaunch(launch, lease, conn) - // The hand-off re-checks the screen: it may have left, or started a newer attach, - // since this one began. Then the stream is let go of instead of installed (A40). - main.post { - if (!slot.accept(ticket, lease)) return@post - attachedAt = System.currentTimeMillis() - state = TermState.Attached - if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) + // ON_STOP cancels this job so nothing keeps connecting, or waiting for an approval, + // behind a screen nobody sees. That stops here: a screen that left before the attach + // went out sends nothing (a pending launch stays for its next attach). From the + // request on, nothing is cancelled: the host may already be creating the session and + // has reserved a viewer on it, and only a stream that arrives can be let go of (the + // hand-off below) and hand its launch on. A cancelled attach dropped both (audit A40). + ensureActive() + val (s, launch) = withContext(NonCancellable) { + val s = conn.attach(nodeId, c, r, sink, hint) + val lease = StreamLease(s) { st -> graph.scope.launch { runCatching { st.detach() } } } + // The host created this session for the launch just now, and the request is + // consumed here: the launch holds the stream until it is done, whatever this + // screen does next (audit A40). Started BEFORE the hand-off, so a screen that + // already left cannot detach the stream under it. + val launch = if (hint != null) PendingLaunches.take(nodeId) else null + if (launch != null) startLaunch(launch, lease, conn) + // The hand-off re-checks the screen: it may have left, or started a newer attach, + // since this one began. Then the stream is let go of instead of installed (A40). + main.post { + if (!slot.accept(ticket, lease)) return@post + attachedAt = System.currentTimeMillis() + state = TermState.Attached + if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) + } + s to launch } if (launch == null && slot.isCurrent(ticket)) afterAttach(s, conn, ticket) } catch (e: NeedsRelayException) { @@ -494,7 +508,8 @@ class TerminalController( else TermState.Ended("$msg Remote access isn't set up for this computer, so open it in nodeterm on the computer.") } } catch (e: Exception) { - // Includes our own cancel on ON_STOP: the ticket is stale by then, so nothing is shown. + // Includes our own cancel on ON_STOP (while connecting, or in afterAttach): the ticket + // is stale by then, so nothing is shown. main.post { if (slot.isCurrent(ticket)) state = TermState.Ended(e.message ?: "Couldn't open the terminal.") } } finally { // Only this attach's own reference: a newer attach may have replaced it meanwhile. @@ -644,6 +659,8 @@ class TerminalController( fun onStop() { if (disposed || stopped) return stopped = true + // Stops a connect or an approval wait; an attach already sent still lands, and is let go of + // by the hand-off (see attach(), A40). onStart begins its own. attachJob?.cancel() attachJob = null // Detaches the stream, unless a launch still holds it: then once that launch is done (A40). diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt index 6d82bb4f99..3008d1287c 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -74,6 +74,12 @@ interface HostConnection : Closeable { val capabilities: HostCapabilities suspend fun listProjects(): ProjectsSnapshot + + /** + * Open a terminal stream on [nodeId]'s session. Cancelling the caller while this is in flight + * leaves nothing attached: a stream the transport opened anyway (the request was already on the + * wire, or the blocking open finished) is detached, never handed to anyone (audit A40). + */ suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint? = null): TerminalStream suspend fun wake(nodeId: String) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt index 13668eac89..276b0bb364 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt @@ -97,6 +97,16 @@ class RelayHostConnection private constructor() : HostConnection, RelaySocketLis runCatching { call("pty.kill", buildJsonObject { put("streamId", id) }) } } + /** + * [detach] for a stream nobody was handed: its attach was cancelled while the request was on + * the wire (audit A40). Non-suspending and quick, since it runs as a continuation's + * cancellation handler; the host's answer is not waited for. + */ + fun abandon() { + streams.remove(id) + socket.request("pty.kill", buildJsonObject { put("streamId", id) }) {} + } + override suspend fun endSession() { try { call("pty.destroy", buildJsonObject { put("streamId", id) }) @@ -165,7 +175,12 @@ class RelayHostConnection private constructor() : HostConnection, RelaySocketLis // follow the response are processed. val s = Stream(streamId, o.b("fresh") == true, sink) streams[streamId] = s - cont.resume(s) + // A caller cancelled while the request was on the wire never gets this + // stream, and the host has already reserved it (a viewer on the node: an + // Eco shield and a size ceiling on the desktop). Let it go, or nothing + // ever does (audit A40). Also covers a cancel that lands after this + // resume but before the caller runs again. + cont.resume(s) { _, stream, _ -> stream.abandon() } } }, onFailure = { cont.resumeWithException(HostException(it.message ?: "Attach failed.")) } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index 87d3003b9a..861088f3f0 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -24,8 +24,10 @@ import dev.nodeterm.protocol.model.ProjectInfo import dev.nodeterm.protocol.model.ProjectsParser import dev.nodeterm.protocol.model.ProjectsSnapshot import dev.nodeterm.protocol.pairing.SshIdentity +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.JsonElement @@ -215,20 +217,33 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } } - override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = - withContext(Dispatchers.IO) { - refuseRemoteNode(nodeId) - if (run(SshScripts.hasSession(nodeId)).second.trim() != "yes") throw notRunning(nodeId) - val session = client.startSession() - try { - session.allocatePTY("xterm-256color", cols, rows, 0, 0, emptyMap()) - val cmd = session.exec("/bin/sh -c " + SshScripts.q(SshScripts.attach(nodeId))) - SshStream(session, cmd, fresh = false, sink = sink).also { it.start() } - } catch (e: Exception) { - runCatching { session.close() } - throw HostException("Couldn't open the terminal over SSH: ${e.message}") + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream { + // The blocking open finishes even when the caller is cancelled meanwhile, and withContext + // then drops its result: a tmux client attached for the life of the connection, which + // nobody holds and nothing detaches (audit A40; the same shape as A20's dial). + var opened: SshStream? = null + try { + return withContext(Dispatchers.IO) { + refuseRemoteNode(nodeId) + if (run(SshScripts.hasSession(nodeId)).second.trim() != "yes") throw notRunning(nodeId) + val session = client.startSession() + try { + session.allocatePTY("xterm-256color", cols, rows, 0, 0, emptyMap()) + val cmd = session.exec("/bin/sh -c " + SshScripts.q(SshScripts.attach(nodeId))) + SshStream(session, cmd, fresh = false, sink = sink).also { + opened = it + it.start() + } + } catch (e: Exception) { + runCatching { session.close() } + throw HostException("Couldn't open the terminal over SSH: ${e.message}") + } } + } catch (e: CancellationException) { + opened?.let { s -> withContext(NonCancellable) { s.detach() } } + throw e } + } /** Node ids of the desktop's SSH projects → `user@host`, from the latest listing. */ @Volatile private var remoteNodes: Map = emptyMap() diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index f58b608291..dbd9590fe2 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -113,6 +113,12 @@ async function runRelay(): Promise { return persistKey === 'term-big-1' ? bigSnapshot : `screen of ${persistKey}` }, async sessionExists(persistKey) { + // `term-slow-*`: the host is still deciding when the phone gives up on the attach (A40). The + // request has arrived (and the host has reserved the stream) once `probe` is emitted. + if (persistKey.startsWith('term-slow-')) { + emit({ event: 'probe', persistKey }) + await new Promise((r) => setTimeout(r, 300)) + } return !persistKey.startsWith('term-new-') }, write(clientId, sessionId, data) { @@ -196,6 +202,12 @@ async function runRelay(): Promise { subscribeCanvas: () => () => {}, applyMutation: () => {}, listProjects: async () => blob, + // A viewer on a node is an Eco shield and a size ceiling on the desktop (A18): the phone must + // never leave one behind. + remoteViewer: { + attached: (nodeId) => emit({ event: 'viewer-attached', nodeId }), + detached: (nodeId) => emit({ event: 'viewer-detached', nodeId }) + }, registerNode: async (projectId, node) => { emit({ event: 'registerNode', projectId, node }) return true diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index e6f750f02c..2249927319 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -16,6 +16,9 @@ import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.NodeKind import dev.nodeterm.protocol.model.SessionBucket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -201,6 +204,24 @@ class RelayInteropTest { } } + @Test + fun `an attach cancelled while its request is on the wire leaves no viewer on the host`() = runBlocking { + // A40 review: the host reserves the stream (a viewer on the node: an Eco shield and a size + // ceiling on the desktop) before it replies. A caller that gave up meanwhile never gets the + // stream, so the transport must let it go, or nothing ever does. + val h = start() + connect(h).connection.use { conn -> + val job = launch(Dispatchers.Default) { conn.attach("term-slow-1", 80, 24, RecordingSink()) } + assertEquals("term-slow-1", h.awaitEvent("probe").str("persistKey"), "the request reached the host") + assertEquals("term-slow-1", h.awaitEvent("viewer-attached").str("nodeId")) + job.cancelAndJoin() + assertTrue(job.isCancelled) + assertEquals("term-slow-1", h.awaitEvent("viewer-detached", 5_000).str("nodeId"), "the stream was let go of") + // The connection is still good for the next attach. + conn.attach("term-abc-1", 80, 24, RecordingSink()).detach() + } + } + @Test fun `a multi-chunk snapshot survives a code point split across chunks`() = runBlocking { val h = start() diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index b3ffa293ab..a9e2895ccd 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -13,6 +13,10 @@ import dev.nodeterm.protocol.ssh.HostKeyChangedException import dev.nodeterm.protocol.ssh.HostKeyPin import dev.nodeterm.protocol.ssh.SshHostConnection import dev.nodeterm.protocol.ssh.SshScripts +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.apache.sshd.server.Environment import org.apache.sshd.server.ExitCallback @@ -30,6 +34,7 @@ import java.io.InputStream import java.io.OutputStream import java.nio.file.Files import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -75,12 +80,17 @@ class SshTransportTest { return p.waitFor() to out } + /** Called with each command the server is asked to run, before it runs (and before sshj hears back). */ + @Volatile private var onCommand: ((String, ShCommand) -> Unit)? = null + private inner class ShCommand(private val command: String) : Command { private lateinit var input: InputStream private lateinit var output: OutputStream private lateinit var error: OutputStream private lateinit var exit: ExitCallback private var process: Process? = null + /** Counted down once the command's process has exited (for a pty, `script` and what it ran). */ + val exited = java.util.concurrent.CountDownLatch(1) override fun setInputStream(`in`: InputStream) { input = `in` } override fun setOutputStream(out: OutputStream) { output = out } @@ -120,6 +130,7 @@ class SshTransportTest { val code = p.waitFor() outPump.join(2000) errPump.join(2000) + exited.countDown() exit.onExit(code) }.apply { isDaemon = true; start() } } @@ -143,7 +154,9 @@ class SshTransportTest { server.publickeyAuthenticator = org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator { user, key, _ -> user == "dev" && key.encoded.contentEquals(expected) } - server.commandFactory = org.apache.sshd.server.command.CommandFactory { _, command -> ShCommand(command) } + server.commandFactory = org.apache.sshd.server.command.CommandFactory { _, command -> + ShCommand(command).also { onCommand?.invoke(command, it) } + } server.start() port = server.port layOutDesktop() @@ -303,6 +316,41 @@ class SshTransportTest { } } + @Test + fun `an attach cancelled while it opens leaves no tmux client behind`() = runBlocking { + // A40 review: the blocking open finishes even when its caller is cancelled meanwhile, and + // withContext then drops the stream. Nothing held it, so its tmux client stayed attached for + // the life of the connection. The cancel lands exactly while the server starts the attach. + connect().use { conn -> + conn.listProjects() + val attaching = AtomicReference() + val attachCommand = AtomicReference() + onCommand = { command, cmd -> + if (command.contains("attach-session")) { + attachCommand.set(cmd) + attaching.get().cancel() + } + } + try { + val job = launch(Dispatchers.Default, start = CoroutineStart.LAZY) { conn.attach("term-a-1", 100, 30, Sink()) } + attaching.set(job) + job.start() + job.join() + assertTrue(job.isCancelled) + val cmd = assertNotNull(attachCommand.get(), "the attach reached the server") + assertTrue(cmd.exited.await(10, TimeUnit.SECONDS), "the stream nobody holds was closed") + val deadline = System.currentTimeMillis() + 5_000 + while (tmux("list-clients", "-t", "=nt-term-a-1").second.isNotBlank()) { + if (System.currentTimeMillis() > deadline) throw AssertionError("a tmux client is still attached: ${tmux("list-clients").second}") + Thread.sleep(100) + } + } finally { + onCommand = null + } + assertEquals(0, tmux("has-session", "-t", "=nt-term-a-1").first, "letting go never ends the session") + } + } + @Test fun `resizes and key chips from the main thread never touch the socket there, and keep order`() = runBlocking { // A01/A04: before the fix the resize threw on the "main" thread after sshj had advanced its diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt index fc5fa31d17..fb3cbfe508 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt @@ -250,4 +250,27 @@ class TerminalHandoffTest { runBlocking { PhoneLaunch.start(this, l, "claude", register = null, settle = {}) } } } + + // ---- (c) going to the background never drops an attach that is on the wire --------------- + + @Test + fun `the screen's attach is not cancelled once it is sent, so the hand-off and the launch always run`() { + // ON_STOP cancels the attach job. Cancelled inside conn.attach, the attach threw away a + // stream the host had already reserved (and was creating the session for) and never took the + // pending launch. What the screen does from the request to the hand-off must not be + // cancellable; only a device runs the controller, so its source is pinned. + val body = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "fun attach()") + AppSourcePins.assertInOrder( + body, + "ensureActive()", + "withContext(NonCancellable) {", + "conn.attach(nodeId, c, r, sink, hint)", + "PendingLaunches.take(nodeId)", + "startLaunch(launch, lease, conn)", + "slot.accept(ticket, lease)", + "s to launch" + ) + // A superseded attach's dial must not put its approval code over the current screen. + AppSourcePins.assertInOrder(body, "RelayConnectStatus.AwaitingApproval", "slot.isCurrent(ticket)", "TermState.AwaitingApproval(") + } } diff --git a/docs/android.md b/docs/android.md index 2997dbe411..c63e2f1dcb 100644 --- a/docs/android.md +++ b/docs/android.md @@ -163,8 +163,14 @@ after the screen left (back, or the app going to the background), or from an att replaced, is detached instead of shown. A session the phone starts keeps its stream until its launch line is typed and the node is registered, even when the user leaves during the settle delay: the request was consumed to get there, so a launch dropped half-way could not be retried, and would -leave an unregistered shell no canvas shows. The terminal screen's use of these rules is only -type-checked. +leave an unregistered shell no canvas shows. Going to the background cancels a connect or an +approval wait, but never an attach already sent: that one still arrives, so it can be let go of and +hand its launch on. The terminal screen's use of these rules is only type-checked (its source is +pinned). Both transports also keep the `HostConnection.attach` contract that a cancelled attach +leaves nothing attached: the relay sends `pty.kill` for a stream whose caller gave up while the +request was on the wire (`RelayInteropTest`, through the desktop's real host: its viewer on the node +is released), and SSH detaches a stream whose blocking open finished after its caller was cancelled +(`SshTransportTest`: no tmux client stays attached). `InputBarTest` pins the terminal input bar's Send (`A34`, `A41`). While no stream is attached (connecting, disconnected, ended), nothing is sent: the screen keeps the draft and an armed Ctrl From 0507555324bf8fc0c101c25385560734d42f4e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:46:20 +0000 Subject: [PATCH 066/284] fix(android): address review of A41 The A41 fix claimed Resume "keeps the offer" while the stream is down, but every way back to Attached goes through attach(), which cleared the offer first, and the reattach is always warm (the phone's own cold attach created the session; an SSH stream is never fresh), so afterAttach never offered it again. The disabled Resume button promised to come back and never did. The unanswered offer is now carried through a reattach of the same screen: - protocol: ResumeOffer.afterAttach takes the offer the screen still shows (`carried`). On a warm reattach a carried RESUME is kept only when the current snapshot builds exactly that offer again for a cold pane: same line, and the node's mirror entry `updatedAt` unchanged (new field `statusAt`). The mirror moves `updatedAt` only on a hook event from the node, so a CLI started in the pane while the phone was away (where the line would arrive as a prompt), another conversation, or a removed node drops it. A carried resume still wins over a stale Sleeping flag, as the first offer did. A fresh reattach builds the line anew. A wake is not carried; every attach re-derives it, as before. - TerminalController: attach() no longer clears the offer; it marks it unsettled. afterAttach re-lists first when it is carrying a resume over a warm attach (the pre-drop listing cannot show what happened meanwhile), then settles the offer on main with the offer still on screen as `carried` (so one the user dismissed is not revived). acceptResume and the button (`canResume`) require attached AND settled, so a carried offer cannot be typed before the reattach has checked it. An attach that carries a launch clears the offer (it types its own line). - TerminalScreen: the Resume button is enabled on `canResume`. - docs/android.md: says what is carried and when it is dropped. Verified: ResumeOfferTest (15 tests, 5 new) passes. Mutation checks: disabling the carry in afterAttach fails 2 of the new tests; building offers without `statusAt` fails the "heard from the node since" case. Full protocol suite (gradle test --offline) and the offline app type-check (gradle compileKotlin --offline) pass. The controller wiring is only type-checked; nothing has run on a device. Departures from the review's suggestion: the carry is re-validated against a fresh listing rather than re-installed blindly, and the offer cannot be tapped until that check is done. Leaving the screen and reopening the node is a new controller and still offers nothing (out of scope; noted in docs/android.md). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/android/ui/TerminalController.kt | 38 +++++++++-- .../dev/nodeterm/android/ui/TerminalScreen.kt | 2 +- .../dev/nodeterm/protocol/host/ResumeOffer.kt | 39 ++++++++++-- .../dev/nodeterm/protocol/ResumeOfferTest.kt | 63 ++++++++++++++++++- docs/android.md | 10 ++- 5 files changed, 137 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 9d00980a07..4027cb369f 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -94,9 +94,23 @@ class TerminalController( * Offered after an attach of an agent node: its resume line after a COLD attach (the desktop's * cold restore), or its wake line when the session is Sleeping over direct SSH (audit A76). * Typed only when the user taps it. See [ResumeOffer]. + * + * It stays on screen while the stream is down: a reattach of this screen is warm (the cold attach + * created the session), so an unanswered resume is handed back to [ResumeOffer.afterAttach] as + * `carried` and kept while the computer still describes that conversation (the A41 review). */ var resumeOffer by mutableStateOf(null) private set + + /** + * [resumeOffer] was decided by the attach the screen shows now, so a tap may type it. False from + * the start of every attach until its offer is settled: a carried offer is not typed on the strength + * of the previous attach while the reattach is still checking it. + */ + private var resumeSettled by mutableStateOf(false) + + /** The offer's button may be tapped: attached, and the offer settled by this attach. */ + val canResume: Boolean get() = attached && resumeSettled /** A desktop viewer sized the shared pty differently from this screen. */ var sizedElsewhere by mutableStateOf?>(null) private set @@ -454,7 +468,9 @@ class TerminalController( fun attach() { if (disposed || stopped) return state = TermState.Connecting - resumeOffer = null + // The offer stays on screen (A41 review): this attach settles it, keeping an unanswered + // resume or dropping it (afterAttach). Until then it cannot be typed. + resumeSettled = false val ticket = slot.begin() val sink = sinkFor(ticket) attachJob = graph.scope.launch { @@ -495,6 +511,8 @@ class TerminalController( if (!slot.accept(ticket, lease)) return@post attachedAt = System.currentTimeMillis() state = TermState.Attached + // A session started for a launch types its own line: nothing to offer. + if (launch != null) resumeOffer = null if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) } s to launch @@ -537,15 +555,22 @@ class TerminalController( } private suspend fun afterAttach(s: TerminalStream, conn: HostConnection, ticket: Long) { + // A resume carried through a warm reattach is checked against what the computer says NOW: + // the listing from before the drop cannot show a CLI started in the pane meanwhile. (Read + // here only to decide on the re-list; the carry itself reads the offer again on main.) + if (!s.fresh && resumeOffer?.kind == ResumeOffer.Kind.RESUME) session.refreshNow() val snap = session.snapshot.value val status = snap.statusOf(nodeId) // A cold pane (a reboot) gets the agent's own resume, built like the desktop's cold restore // (A15/A16); a Sleeping one over direct SSH gets the desktop's wake line, since nothing tells // the desktop about an SSH attach (A76). Over the relay the attach itself asks the desktop to // wake it, so nothing is offered there. Never typed unasked into a pane we cannot see. - ResumeOffer.afterAttach(s.fresh, conn.kind, snap, nodeId)?.let { offer -> - // Only for the attach the screen still shows: a newer one cleared the offer when it began. - main.post { if (slot.isCurrent(ticket)) resumeOffer = offer } + main.post { + // Only for the attach the screen still shows: a newer one settles the offer itself. + if (!slot.isCurrent(ticket)) return@post + // The offer still on screen is carried: one the user answered or dismissed is not. + resumeOffer = ResumeOffer.afterAttach(s.fresh, conn.kind, snap, nodeId, carried = resumeOffer) + resumeSettled = true } // Reading a finished session on the phone is a READ: tell the computer (unread clears there, // other phones archive the card), exactly what the SSH read-ack file does. @@ -560,8 +585,9 @@ class TerminalController( fun acceptResume() { val offer = resumeOffer ?: return - // Not attached (the stream dropped under the offer): keep it rather than spend it on nothing. - if (!attached) return + // Not attached (the stream dropped under the offer), or a reattach is still settling it: keep + // it rather than spend it on nothing. The reattach keeps it or drops it (see [resumeOffer]). + if (!canResume) return resumeOffer = null // Re-asked at the tap (A76): the desktop may have woken the session since the offer appeared, // and a wake line typed into that CLI would arrive as a prompt. diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index a2ac03f7cf..8b6f44c160 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -149,7 +149,7 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String Text(offer.message) Text(offer.command, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { controller.acceptResume() }, enabled = controller.attached) { Text(offer.button) } + Button(onClick = { controller.acceptResume() }, enabled = controller.canResume) { Text(offer.button) } OutlinedButton(onClick = { controller.dismissResume() }) { Text("Not now") } } } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt index efc9ba1d65..9d4eeebe7f 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt @@ -33,8 +33,19 @@ import dev.nodeterm.protocol.model.ProjectsSnapshot * shallow-paused node reads as Sleeping here too; an offer the user taps is that explicit Resume, * never an automatic one. A deep pause ("pause & end session") recycles the tmux session and leaves * `hibernated` unset, so it gets no wake offer. + * + * A reattach of the same screen (the stream dropped, the app went to the background) is warm: the + * cold attach before it created the session. An unanswered [Kind.RESUME] is therefore handed back in + * as `carried` and kept, not re-derived from `fresh` (which would drop it: the A41 review). It is kept + * only while the computer still describes the conversation it was made for, with nothing reported + * from the node since ([statusAt]): the phone did not see the pane while it was away, and a CLI + * started there meanwhile would take the resume line as a prompt. A wake needs no carrying; every + * attach re-derives it. + * + * [statusAt] is the node's mirror entry `updatedAt` the offer was built against (null: the computer + * had no entry). The mirror moves it only when a hook event from the node arrives. */ -data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String) { +data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String, val statusAt: Long?) { enum class Kind { RESUME, WAKE } /** What accepting the offer writes into the pane. */ @@ -76,8 +87,25 @@ data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String) { /** * The offer after an attach of [nodeId] over [transport], or null. [fresh] is the attach's * own answer ([TerminalStream.fresh]); [snapshot] is the phone's latest view of the computer. + * [carried] is the offer the screen still shows from its previous attach, unanswered: a + * [Kind.RESUME] survives a warm reattach while the computer builds exactly that offer again + * for a cold pane (same line, and no hook event from the node since, [statusAt]); otherwise + * it is dropped. A fresh reattach is a new cold pane and gets the line built now. */ - fun afterAttach(fresh: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String): ResumeOffer? { + fun afterAttach( + fresh: Boolean, + transport: TransportKind, + snapshot: ProjectsSnapshot, + nodeId: String, + carried: ResumeOffer? = null + ): ResumeOffer? { + // The pane is still the cold one the earlier attach created, so the cold line, and it + // wins over a Sleeping flag as it did then. + if (!fresh && carried?.kind == Kind.RESUME) return build(cold = true, transport, snapshot, nodeId).takeIf { it == carried } + return build(fresh, transport, snapshot, nodeId) + } + + private fun build(cold: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String): ResumeOffer? { val found = snapshot.findNode(nodeId) val project = found?.first val node = found?.second @@ -85,13 +113,14 @@ data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String) { val agent = Agent.of(node?.agentId ?: status?.agentId) ?: return null val sid = status?.sessionId ?: node?.agentSessionId ?: return null val settings = snapshot.status?.settings + val at = status?.updatedAt return when { // A cold pane wins over a stale Sleeping flag: the shell the CLI exited to is gone // with the old tmux session, so the new one needs the full cold-restore line. - fresh -> Launch.resumeLine(agent, sid, settings, node?.accountId, project?.absoluteCwdOf(node), project?.defaultPermissionMode) - ?.let { ResumeOffer(Kind.RESUME, agent, it) } + cold -> Launch.resumeLine(agent, sid, settings, node?.accountId, project?.absoluteCwdOf(node), project?.defaultPermissionMode) + ?.let { ResumeOffer(Kind.RESUME, agent, it, at) } status?.hibernated == true && transport == TransportKind.SSH -> - Launch.wakeLine(agent, sid, settings, project?.defaultPermissionMode)?.let { ResumeOffer(Kind.WAKE, agent, it) } + Launch.wakeLine(agent, sid, settings, project?.defaultPermissionMode)?.let { ResumeOffer(Kind.WAKE, agent, it, at) } else -> null } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt index 587ef713ce..977684607f 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt @@ -16,8 +16,9 @@ import kotlin.test.assertNull import kotlin.test.assertTrue /** - * Audit A76 (and A15): what the terminal screen offers to type after an attach. The screen - * (TerminalController) is only type-checked; these pin the rule it delegates to. + * Audit A76 (and A15, and the A41 review): what the terminal screen offers to type after an attach, + * and what it keeps across a reattach. The screen (TerminalController) is only type-checked; these + * pin the rule it delegates to. */ class ResumeOfferTest { private val account = "/Users/me/Library/Application Support/node-terminal/claude-accounts/acct-1" @@ -135,6 +136,64 @@ class ResumeOfferTest { assertTrue(resume.stillOffered(snapshot(awake), "n1"), "a cold pane's resume does not depend on the flag") } + /** + * The A41 review: a reattach of the same screen (the stream dropped, the app went to the + * background) is warm, because the cold attach before it created the session. Re-deriving the + * offer from `fresh` alone dropped an unanswered resume on every reconnect. + */ + @Test + fun `an unanswered resume is kept through a warm reattach of the pane it was made for`() { + val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) + for (transport in TransportKind.entries) { + assertEquals(offered, ResumeOffer.afterAttach(fresh = false, transport, snapshot(awake), "n1", carried = offered)) + } + // Nothing carried (answered, dismissed, or never offered): a warm attach still offers nothing. + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(awake), "n1", carried = null)) + } + + @Test + fun `a carried resume still wins over a stale Sleeping flag`() { + // The pane is the cold one the first attach created, not the shell a CLI exited back to, so + // the wake line (no cd, no account) would still be the wrong one there. + val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(sleeping), "n1")) + val kept = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", carried = offered)) + assertEquals(ResumeOffer.Kind.RESUME, kept.kind) + assertEquals(offered, kept) + } + + @Test + fun `a carried resume is dropped once the computer describes something else`() { + val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) + // A hook event from the node since the offer: a CLI ran in that pane while the phone was away + // (say, resumed on the computer), and the resume line would reach it as a prompt. + val heard = """{"state":"done","agentId":"claude","sessionId":"sid-7","updatedAt":9}""" + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(heard), "n1", carried = offered)) + // Another conversation. + val other = """{"state":"done","agentId":"claude","sessionId":"sid-8","updatedAt":5}""" + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(other), "n1", carried = offered)) + // The node is gone from the canvas and the mirror. + val gone = ProjectsParser.parseBlob("""{"version":2,"projects":[{"id":"p1","name":"Repo","cwd":"/repo","nodes":[]}]}""") + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, gone, "n1", carried = offered)) + } + + @Test + fun `a fresh reattach is a new cold pane and gets the line built now`() { + val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) + val other = """{"state":"done","agentId":"claude","sessionId":"sid-8","updatedAt":9}""" + val rebuilt = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(other), "n1", carried = offered)) + assertEquals(ResumeOffer.Kind.RESUME, rebuilt.kind) + assertTrue(rebuilt.command.contains("--resume sid-8"), rebuilt.command) + } + + @Test + fun `a wake is not carried, since each attach re-derives it`() { + val wake = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1")) + assertEquals(wake, ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", carried = wake)) + // Woken on the computer meanwhile, or reopened through the relay (whose attach wakes it). + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(awake), "n1", carried = wake)) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1", carried = wake)) + } + @Test fun `the wake line is the resume with the permission mode and nothing else`() { val s = MirrorSettings("auto", autoSupported = false, claudeAccounts = listOf(ManagedAccount("a1", account)), codexApprovalValues = emptyList()) diff --git a/docs/android.md b/docs/android.md index c63e2f1dcb..8d72fb6134 100644 --- a/docs/android.md +++ b/docs/android.md @@ -177,7 +177,8 @@ is released), and SSH detaches a stream whose blocking open finished after its c stays armed, instead of clearing text that reached nothing. Attached, Ctrl plus one character sends that control byte alone, and anything else goes as a paste followed by Enter. The screen disables Send, the Resume offer and the sending key chips while nothing is attached; that part is only -type-checked. +type-checked. An unanswered Resume offer stays on screen, disabled, through the reattach, and can +be tapped again once the reattach has settled it (see `ResumeOfferTest`). `TerminalPageTest` pins what happens when the terminal WebView's renderer process goes away (`A45`). The app handles `onRenderProcessGone` instead of being killed with the renderer: it @@ -199,6 +200,13 @@ desktop's kill-line) and re-checks that the node is still Sleeping. Through the node gets no offer: the attach already asked the desktop to wake it. A shallow "Pause session" is Sleeping in the mirror (it carries no `paused`), so it too gets only the offer, which is the explicit Resume the desktop's PAUSED chip is; a deep pause leaves no Sleeping flag and gets nothing. +A reattach of the same screen (the stream dropped, the app went to the background) is warm, because +the cold attach created the session, so an unanswered resume is carried through it rather than +re-derived from the attach: it is kept while the computer still builds that same line and has heard +nothing from the node since (its mirror entry's `updatedAt`), re-listed right after the reattach, and +dropped otherwise, since a CLI started in the pane meanwhile would take the line as a prompt. Until +the reattach has settled it, it cannot be tapped. A wake is not carried; every attach re-derives it. +Leaving the screen and opening the node again is a new screen, whose warm attach offers nothing. `SshTransportTest` runs the offer end to end against the real tmux: the line it types starts the stand-in CLI in the node's own folder even with a half-typed line left at the prompt. The banner itself is only type-checked. From a371483d07cb70b8a7b430cba0385a66a09b1869 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:52:26 +0000 Subject: [PATCH 067/284] fix(android): address review of A45 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rendererGone() replaced whatever the terminal screen showed. With the screen on "The session ended (exit N).", "Disconnected." or the relay offer, a renderer kill moved it to Connecting and the new page's first resize attached by itself, in the foreground and unasked. Over the relay that creates a new, empty tmux session for a node whose pane had exited (host-service handleAttach falls through to attachDetached's `new-session -A`). A crash put up "... The session is still running on the computer." over an answer that had just said otherwise, a liveness the controller never checked. The same held after an earlier crash offer: a kill of the replacement page reattached what the user had been asked about. - protocol: `RendererRecovery.onGone` now takes what the screen was `Showing` and returns an `Outcome`: - SETTLED (Ended, RelayOffer, ViewLost: an answer with its own button, nothing in flight) -> Keep. Not counted against the automatic reattaches, since none happens. - ATTACHED / OPENING -> Reattach (a kill, within the bound) or Offer(message), as before. - The offer says the session is still running only for ATTACHED. While the terminal was still opening nothing was known about the session. - TerminalController: maps the pre-loss state to `Showing` with an exhaustive `when` and leaves a kept state alone. The Ended "Reattach" (new `reattach()`) and "Open through the relay" now go through attachWhenPageReady(), because those answers can now be on screen over a page that is still being replaced, and attaching then would claim the pty at 80x24. `attach()` is private; both buttons ignore a stale tap from another state. A pending auto-reattach behind "Disconnected. Reconnecting…" (A36) still fires, as the screen announced. - docs/android.md: the kept answer and the narrower claim. Departure from the review: AwaitingApproval is not kept. rendererGone retires the attach in flight (the approval dial with it), so the code would stand for a dial that no longer exists, with no button to leave it. It counts as OPENING, like Connecting: a kill redials once the new page reports its size, a crash offers "Reopen terminal" with no claim about the session. onStop still moves every state to Connecting, as the review noted; that is unchanged. Verified: TerminalPageTest (13 tests, 3 new). Mutation-checked: removing the Keep branch (the reviewed behaviour) fails 2 tests; always claiming the session is running, counting a kept answer against the automatic reattaches, and keeping only on a kill each fail 1. Full protocol suite (gradle test --offline, 288 tests) and the offline app type-check (gradle compileKotlin --offline) pass. The controller and screen are only type-checked; nothing has run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../nodeterm/android/ui/TerminalController.kt | 48 ++++++++--- .../dev/nodeterm/android/ui/TerminalScreen.kt | 2 +- .../nodeterm/protocol/host/TerminalPage.kt | 80 ++++++++++++++----- .../dev/nodeterm/protocol/TerminalPageTest.kt | 73 +++++++++++++---- docs/android.md | 6 +- 5 files changed, 162 insertions(+), 47 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 4027cb369f..4467e3dfeb 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -355,12 +355,16 @@ class TerminalController( * The renderer behind [view] is gone (audit A45): Android killed it to reclaim memory, or it * crashed. That WebView can never be used again. The stream is detached (it would paint into * nothing), the WebView leaves the view tree and is destroyed, and [webViewKey] changes so the - * screen builds a new one. Then, per [RendererRecovery], the screen reattaches by itself once the - * new page is ready (a kill), or offers "Reopen terminal" (a crash, or kills in a loop). + * screen builds a new one. Then, per [RendererRecovery] and what the screen was showing: an answer + * with its own button (ended, disconnected, relay offer, view lost) stays, and nothing reattaches + * unasked; otherwise the screen reattaches by itself once the new page is ready (a kill), or offers + * "Reopen terminal" (a crash, or kills in a loop). */ private fun rendererGone(view: WebView, didCrash: Boolean) { // A view this screen already let go of (a repeated callback, or one after dispose). if (view !== webView) return + // Read before anything below changes it. + val showing = showing(state) webView = null page.lost() // Detached as on ON_STOP. A launch still holding the stream finishes first (A40), and the @@ -379,17 +383,30 @@ class TerminalController( sizedElsewhere = null destroyWebView(view, rendererAlive = false) webViewKey++ - state = when (recovery.onGone(didCrash, System.currentTimeMillis())) { + when (val outcome = recovery.onGone(didCrash, System.currentTimeMillis(), showing)) { + // The screen keeps its answer, and its button reattaches once the new page is ready + // (attachWhenPageReady). Moving it to Connecting would attach unasked: over the relay, to + // a pane that exited, that creates a new, empty session. + RendererRecovery.Outcome.Keep -> Unit // Reattached by attachIfWaiting once the new page reports in (or by onStart, when the // screen is in the background now). - RendererRecovery.Action.REATTACH -> TermState.Connecting - RendererRecovery.Action.OFFER -> TermState.ViewLost( - (if (didCrash) "The terminal view crashed." else "Android keeps closing the terminal view to free memory.") + - " The session is still running on the computer." - ) + RendererRecovery.Outcome.Reattach -> state = TermState.Connecting + is RendererRecovery.Outcome.Offer -> state = TermState.ViewLost(outcome.message) } } + /** + * What [st] tells [RendererRecovery] (A45). Exhaustive on purpose: a new state has to say whether + * an attach is in flight behind it, and whether the screen knew the session was running. + */ + private fun showing(st: TermState): RendererRecovery.Showing = when (st) { + TermState.Attached -> RendererRecovery.Showing.ATTACHED + // An attach in flight (the loss retires it), or waiting for a page or for onStart. The code of + // a retired approval dial is not kept: nothing would be behind it, and it has no button. + TermState.Connecting, is TermState.AwaitingApproval -> RendererRecovery.Showing.OPENING + is TermState.Ended, is TermState.RelayOffer, is TermState.ViewLost -> RendererRecovery.Showing.SETTLED + } + /** * Take [view] out of the view tree, then destroy it: WebView.destroy() expects a view that is no * longer attached. A view whose renderer is gone is not used for anything else. @@ -422,6 +439,14 @@ class TerminalController( if (state is TermState.ViewLost) attachWhenPageReady() } + /** + * "Reattach", from an ended or disconnected session. Through [attachWhenPageReady]: the screen + * keeps this answer over a page that is being replaced after a renderer loss (A45 review). + */ + fun reattach() { + if (state is TermState.Ended) attachWhenPageReady() + } + private var attachedAt = 0L private var autoReattaches = 0 @@ -461,11 +486,14 @@ class TerminalController( private var useRelay = false fun openThroughRelay() { + if (state !is TermState.RelayOffer) return useRelay = true - attach() + // The offer can be on screen over a page being replaced (A45 review): wait for its size. + attachWhenPageReady() } - fun attach() { + /** Attach now. The screen's buttons go through [attachWhenPageReady] ([reattach], [openThroughRelay]). */ + private fun attach() { if (disposed || stopped) return state = TermState.Connecting // The offer stays on screen (A41 review): this attach settles it, keeping an unanswered diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 8b6f44c160..03a0aebafb 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -109,7 +109,7 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text(st.message) - Button(onClick = { controller.attach() }) { Text("Reattach") } + Button(onClick = { controller.reattach() }) { Text("Reattach") } } is TermState.RelayOffer -> Column( Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt index 709371b0dd..a691829fe2 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt @@ -82,37 +82,77 @@ class TerminalPage { } /** - * What the terminal screen does once its page's renderer is gone (audit A45). Either way a new page - * is built; the question is only who reattaches the terminal to it. + * What the terminal screen does once its page's renderer is gone (audit A45). A new page is built + * either way; the question is what the screen shows over it, and who reattaches the terminal. * - * - The system KILLED the renderer (`didCrash` false): nothing is wrong with what the page showed, so - * the screen reattaches by itself once the new page is ready. With the renderer's priority waived - * while the screen is not visible, this is the normal way a backgrounded terminal loses its page. - * - The renderer CRASHED: the cause may be what the pane printed, and a reattach paints the same - * screen again, so reattaching unasked could crash it over and over. The screen offers "Reopen - * terminal" and the user decides. - * - A kill also falls back to the offer after [maxAutomatic] automatic reattaches within [windowMs]: - * a device that keeps killing a visible renderer should not have the app fight it in a loop. + * It depends first on what the screen was [Showing]: + * - An ANSWER with its own button ([Showing.SETTLED]: the session ended or the connection dropped, + * the session opens through the relay, or the view was already lost) is kept as it was, and its + * button drives the next attach. Nothing reattaches unasked: over the relay an attach to a pane that + * has exited creates a new, empty session, and after a crash offer an automatic reattach would repaint + * the screen the user was asked about. + * - Otherwise ([Showing.ATTACHED] or [Showing.OPENING]) the attach the loss retired is replaced: + * - the system KILLED the renderer (`didCrash` false): nothing is wrong with what the page showed, + * so the screen reattaches by itself once the new page is ready. With the renderer's priority + * waived while the screen is not visible, this is the normal way a backgrounded terminal loses its + * page; + * - the renderer CRASHED: the cause may be what the pane printed, and a reattach paints the same + * screen again, so reattaching unasked could crash it over and over. The screen offers "Reopen + * terminal" and the user decides; + * - a kill also falls back to the offer after [maxAutomatic] automatic reattaches within [windowMs]: + * a device that keeps killing a visible renderer should not have the app fight it in a loop. + * + * The offer says the session is still running only when a stream was attached ([Showing.ATTACHED]): + * that is the only case in which the screen knew. While it was still opening the terminal, it knew + * nothing about the session yet (it may not exist, or not on this host). * * Thread-safe; [now] is a millisecond clock supplied by the caller. */ class RendererRecovery(private val windowMs: Long = 60_000L, private val maxAutomatic: Int = 2) { - enum class Action { + /** What the terminal screen was showing when its renderer went away. */ + enum class Showing { + /** A stream was attached: the session was running a moment ago. */ + ATTACHED, + + /** + * Opening the terminal: connecting, waiting for the computer to approve this phone, waiting + * for a page, or in the background. The loss retires the attach in flight. + */ + OPENING, + + /** An answer with its own button (ended, disconnected, relay offer, view lost). Nothing in flight. */ + SETTLED + } + + sealed interface Outcome { + /** Keep what the screen shows; its own button drives the next attach. */ + data object Keep : Outcome + /** Reattach by itself once the new page is ready. */ - REATTACH, + data object Reattach : Outcome - /** Offer "Reopen terminal"; the user decides when to reattach. */ - OFFER + /** Offer "Reopen terminal" with [message]; the user decides when to reattach. */ + data class Offer(val message: String) : Outcome } private val automatic = ArrayDeque() @Synchronized - fun onGone(didCrash: Boolean, now: Long): Action { - if (didCrash) return Action.OFFER - while (automatic.isNotEmpty() && now - automatic.first() >= windowMs) automatic.removeFirst() - if (automatic.size >= maxAutomatic) return Action.OFFER - automatic.addLast(now) - return Action.REATTACH + fun onGone(didCrash: Boolean, now: Long, showing: Showing): Outcome { + // Not counted against the automatic reattaches: none happens. + if (showing == Showing.SETTLED) return Outcome.Keep + if (!didCrash) { + while (automatic.isNotEmpty() && now - automatic.first() >= windowMs) automatic.removeFirst() + if (automatic.size < maxAutomatic) { + automatic.addLast(now) + return Outcome.Reattach + } + } + return Outcome.Offer(offerMessage(didCrash, showing)) + } + + private fun offerMessage(didCrash: Boolean, showing: Showing): String { + val what = if (didCrash) "The terminal view crashed." else "Android keeps closing the terminal view to free memory." + return if (showing == Showing.ATTACHED) "$what The session is still running on the computer." else what } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt index 816fb30d93..a3712a12c1 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt @@ -1,19 +1,21 @@ package dev.nodeterm.protocol import dev.nodeterm.protocol.host.RendererRecovery -import dev.nodeterm.protocol.host.RendererRecovery.Action +import dev.nodeterm.protocol.host.RendererRecovery.Outcome +import dev.nodeterm.protocol.host.RendererRecovery.Showing import dev.nodeterm.protocol.host.TerminalPage import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue /** * Audit A45: the terminal WebView's renderer can go away (killed by the system, or crashed), and the * screen then builds a new WebView. The screen (TerminalController) is only type-checked; these pin - * the rules it delegates to: which page's callbacks count, where queued JavaScript goes, and who - * reattaches. + * the rules it delegates to: which page's callbacks count, where queued JavaScript goes, who + * reattaches, and what the screen shows meanwhile. */ class TerminalPageTest { // ---- the page generation ----------------------------------------------------------------- @@ -96,35 +98,76 @@ class TerminalPageTest { // ---- who reattaches -------------------------------------------------------------------- + private fun RendererRecovery.gone(didCrash: Boolean, now: Long) = onGone(didCrash, now, Showing.ATTACHED) + @Test fun `a crashed renderer is offered to the user, never reattached by itself`() { val recovery = RendererRecovery() - assertEquals(Action.OFFER, recovery.onGone(didCrash = true, now = 0)) - assertEquals(Action.OFFER, recovery.onGone(didCrash = true, now = 3_600_000)) + assertIs(recovery.gone(didCrash = true, now = 0)) + assertIs(recovery.gone(didCrash = true, now = 3_600_000)) + assertIs(recovery.onGone(didCrash = true, now = 3_600_001, showing = Showing.OPENING)) } @Test fun `a renderer the system killed is reattached by itself`() { - val recovery = RendererRecovery() - assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 0)) + assertEquals(Outcome.Reattach, RendererRecovery().gone(didCrash = false, now = 0)) + assertEquals(Outcome.Reattach, RendererRecovery().onGone(didCrash = false, now = 0, showing = Showing.OPENING)) } @Test fun `repeated kills fall back to the offer, and recover once the window has passed`() { val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 2) - assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 0)) - assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 10_000)) - assertEquals(Action.OFFER, recovery.onGone(didCrash = false, now = 20_000)) - assertEquals(Action.OFFER, recovery.onGone(didCrash = false, now = 59_999)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 0)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 10_000)) + assertIs(recovery.gone(didCrash = false, now = 20_000)) + assertIs(recovery.gone(didCrash = false, now = 59_999)) // The first automatic reattach has left the window: one more is allowed. - assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 60_000)) - assertEquals(Action.OFFER, recovery.onGone(didCrash = false, now = 60_001)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 60_000)) + assertIs(recovery.gone(didCrash = false, now = 60_001)) } @Test fun `an offer is not counted as an automatic reattach`() { val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 1) - assertEquals(Action.OFFER, recovery.onGone(didCrash = true, now = 0)) - assertEquals(Action.REATTACH, recovery.onGone(didCrash = false, now = 1)) + assertIs(recovery.gone(didCrash = true, now = 0)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 1)) + } + + // ---- what the screen was showing (A45 review) -------------------------------------------- + + @Test + fun `an answer on screen is kept, whether the renderer was killed or crashed`() { + // "The session ended (exit 1).", "Disconnected.", the relay offer, or an earlier "Reopen + // terminal": the screen keeps it and its button. A kill must not turn an ended session into an + // attach of its own (over the relay that creates a new, empty session), and a kill after a + // crash offer must not reattach what the user was asked about. + val recovery = RendererRecovery() + assertEquals(Outcome.Keep, recovery.onGone(didCrash = false, now = 0, showing = Showing.SETTLED)) + assertEquals(Outcome.Keep, recovery.onGone(didCrash = true, now = 1, showing = Showing.SETTLED)) + } + + @Test + fun `a kept answer does not use up the automatic reattaches`() { + val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 2) + repeat(5) { recovery.onGone(didCrash = false, now = it.toLong(), showing = Showing.SETTLED) } + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 10)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 11)) + assertIs(recovery.gone(didCrash = false, now = 12)) + } + + @Test + fun `the offer says the session is running only when a stream was attached`() { + val running = "The session is still running on the computer." + val crashed = assertIs(RendererRecovery().gone(didCrash = true, now = 0)) + assertEquals("The terminal view crashed. $running", crashed.message) + val opening = assertIs(RendererRecovery().onGone(didCrash = true, now = 0, showing = Showing.OPENING)) + assertEquals("The terminal view crashed.", opening.message) + assertFalse(running in opening.message, "nothing was known about the session while the terminal was opening") + + val loop = RendererRecovery(maxAutomatic = 0) + val killedAttached = assertIs(loop.gone(didCrash = false, now = 0)) + assertEquals("Android keeps closing the terminal view to free memory. $running", killedAttached.message) + val killedOpening = assertIs(loop.onGone(didCrash = false, now = 1, showing = Showing.OPENING)) + assertEquals("Android keeps closing the terminal view to free memory.", killedOpening.message) } } diff --git a/docs/android.md b/docs/android.md index 8d72fb6134..04fa81531c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -189,7 +189,11 @@ the new page exists is kept for it. A renderer the system killed (the terminal's is waived while the screen is not visible) is reattached automatically once the new page has reported its size. A crash is not, because the reattach would repaint the same screen: the screen offers "Reopen terminal" instead. More than two kills within a minute fall back to that offer too. -The WebView handling itself is only type-checked. +A screen that was showing an answer with its own button (the session ended, the connection +dropped, "Open through the relay", or an earlier "Reopen terminal") keeps it, and nothing +reattaches unasked: over the relay, an attach to a pane that has exited creates a new, empty +session. The offer says the session is still running only when a stream was attached. The WebView +handling itself is only type-checked. `ResumeOfferTest` pins what the terminal screen offers to type after an attach (`A15`, `A76`). A cold attach (the computer rebooted; only the relay creates a session) gets the cold-restore line: `cd` into From 4ef21f0e6a5ebac8cec02bcd51c48bcdcd156b67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:08:52 +0000 Subject: [PATCH 068/284] fix(android): address review of A76 The A76 wake offer trusted the mirror's `hibernated` flag, and that flag outlives the sleep. Nothing reported the renderer's live-state self-heal to the mirror, and a codex SessionStart arrives as `working`, never as the session start Canvas also clears on. So a codex session the phone woke over SSH stayed Sleeping on the phone, and every later open or reattach offered `codex resume` into the running CLI, where a tap would send it as a prompt. The same happens for any agent when the desktop app is not running during the wake, since then nothing hears the resumed CLI. Desktop: - agentStatus.setState reports `hibernated:false` when a live state (working/blocked/waiting) clears the flag, as setHibernated already does. - The core mirror drops `hibernated` itself on the same edges: a committed live state, or a session START. Never on `done` or a session end, which is what the hibernating /exit fires. This covers both shells and the case where no renderer hears the event (a Server Edition with no tab, a reloading window). The file's shape is unchanged, so neither the Android parser nor the interop fixture needs anything. Phone: - A wake is offered only while a SHELL owns the pane, the gate the desktop's own wake keeps. New `Pane.isShell` ports the desktop's `isShellCommand` and is pinned against src/shared/agents/pane.ts. `HostConnection.paneCommand` (SSH: tmux `#{pane_current_command}` on the `=nt-:` pane target; null for unknown, and the relay default) is read after an SSH attach of a Sleeping node (`ResumeOffer.wantsPane`) and again at the tap, right before typing. `stillOffered` now needs the flag AND a shell. An unread pane, or a shell outside the desktop's list (nu, pwsh), gets no wake. Docs (issue 2): the phone's wake line and cold-resume line carry the permission mode for Claude only. The desktop appends each capable agent's approval flag. The README, docs/android.md, ResumeOffer and Launch KDoc said "plus the permission mode" for every agent; they now say Claude only. The "Codex/Gemini/... launch flags" known gap now covers wakes and resumes, and names the looser cases: gemini/grok in `plan`, and codex `manual` on a CLI before 0.149. CLAUDE.md notes the mirror self-heal in the Eco section. Verified: - agent-status-mirror.test.ts: live states, including a real normalizeCodex SessionStart, and a session start drop the flag; `done` and a session end keep it. - agentStatus.persist.test.ts: the self-heal reports false; `done` and a node that was not asleep report nothing. - ResumeOfferTest: a codex wake, then a reattach over a running CLI (codex/node/path) with the flag still set, offers nothing and withdraws at the tap. It also covers the shell-list pin and a source pin of TerminalController's two pane reads. - SshTransportTest: against the real sandboxed tmux, the phone wakes a stand-in codex that stays in the foreground, the flag stays set, and the next open reads `sleep` in the pane and offers nothing. Unreadable panes are null. - Mutation-checked: removing the renderer report, either mirror drop, the pane gate in the build branch or in stillOffered, or the tap-time pane read in the controller each fails a test. - Passing: the full protocol suite (293 tests, none skipped), the offline app type-check, npm run typecheck, and 71 affected vitest files. Not run on a device. Departures from the review's suggestions: I did both desktop fixes, not one. The phone guard measures the pane rather than remembering which (nodeId, sessionId) it woke. A remembered wake cannot see a CLI started some other way, and it would also suppress a legitimate wake after the CLI exits again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- CLAUDE.md | 5 +- android/README.md | 2 +- .../nodeterm/android/ui/TerminalController.kt | 31 ++++- .../nodeterm/protocol/host/HostConnection.kt | 8 ++ .../dev/nodeterm/protocol/host/ResumeOffer.kt | 59 +++++++--- .../dev/nodeterm/protocol/model/Agents.kt | 15 ++- .../dev/nodeterm/protocol/model/Pane.kt | 21 ++++ .../protocol/ssh/SshHostConnection.kt | 13 +++ .../dev/nodeterm/protocol/ssh/SshScripts.kt | 15 +++ .../dev/nodeterm/protocol/ResumeOfferTest.kt | 106 +++++++++++++++--- .../dev/nodeterm/protocol/SshTransportTest.kt | 84 +++++++++++++- docs/android.md | 31 ++++- src/core/agent-status-mirror.test.ts | 33 +++++- src/core/agent-status-mirror.ts | 17 ++- .../state/agentStatus.persist.test.ts | 24 ++++ src/renderer/state/agentStatus.ts | 10 ++ 16 files changed, 417 insertions(+), 57 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Pane.kt diff --git a/CLAUDE.md b/CLAUDE.md index 0b546fdacb..ef03467ebe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -824,7 +824,10 @@ Lifecycle, by intent: (`dismissed: true`); clearing it would let Eco `/exit` a CLI whose cron wakeup lives in that process. (2) **Fire-time re-asks**: still-offscreen, remote, eligibility — a plan-time verdict is stale by seconds. (3) `hibernated` **self-heals** on live hook states + SessionStart (never - on `done` — a late Stop POST must not undo a just-performed hibernate); cold restore (`fresh`) + on `done` — a late Stop POST must not undo a just-performed hibernate), and so does the + agent-status mirror the phone reads SLEEPING (and offers a wake) from: the renderer reports its + clear, and the mirror applies the same rule to the hook events it records, renderer or not (a + codex SessionStart arrives as `working`, never as a session start); cold restore (`fresh`) clears `hibernated` UNCONDITIONALLY and normally lets auto-resume own the node — **`paused` (see below) is what makes that auto-resume itself conditional**, the one deliberate exception: the flag it gates is still cleared, only the relaunch is skipped. (4) **Ordering with offscreen diff --git a/android/README.md b/android/README.md index 478ad75949..be9058bc70 100644 --- a/android/README.md +++ b/android/README.md @@ -23,7 +23,7 @@ it is talking to. | Late relay adoption (paired while remote access was off) | ✓ | Reads `~/.nodeterm/relay.json` over SSH, mints its own device token | | Sessions, grouped like the desktop sidebar | ✓ | Needs you / Running / Sleeping, activity + context % | | Terminal (co-attach to the live tmux session) | ✓ | xterm.js renderer, native input bar, special keys, swipe = tmux scroll, OSC 52 copy | -| Sleeping (Eco) session opened | ✓ | Through the relay the computer wakes it on open. Over SSH the phone offers the desktop's wake line (`--resume ` + permission mode), typed only on a tap | +| Sleeping (Eco) session opened | ✓ | Through the relay the computer wakes it on open. Over SSH the phone offers the desktop's wake line (`--resume `, + permission mode for Claude only), while a shell owns the pane, typed only on a tap | | Cold-start resume offer (the computer rebooted) | relay | Offers the agent's own `--resume `; never types it unasked. Over SSH a session that is not running is never created (it would lack its hook environment): the phone offers to open it through the relay | | Sessions of the computer's SSH projects | relay | They run on another host; the computer attaches them over its SSH connection. Over direct SSH the phone offers the relay instead | | New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches | diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 4467e3dfeb..ee6305f6d2 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -102,6 +102,9 @@ class TerminalController( var resumeOffer by mutableStateOf(null) private set + /** The connection of the attach that settled [resumeOffer]: a tapped wake re-reads its pane. */ + private var resumeConn: HostConnection? = null + /** * [resumeOffer] was decided by the attach the screen shows now, so a tap may type it. False from * the start of every attach until its offer is settled: a carried offer is not typed on the strength @@ -589,6 +592,10 @@ class TerminalController( if (!s.fresh && resumeOffer?.kind == ResumeOffer.Kind.RESUME) session.refreshNow() val snap = session.snapshot.value val status = snap.statusOf(nodeId) + // A Sleeping flag is only half the answer: a wake is offered while a SHELL owns the pane, the + // desktop's own gate (the A76 review: the flag outlives a CLI resumed outside the desktop's + // wake, this phone's included, and the line would land in that CLI as a prompt). + val pane = if (ResumeOffer.wantsPane(s.fresh, conn.kind, snap, nodeId)) conn.paneCommand(nodeId) else null // A cold pane (a reboot) gets the agent's own resume, built like the desktop's cold restore // (A15/A16); a Sleeping one over direct SSH gets the desktop's wake line, since nothing tells // the desktop about an SSH attach (A76). Over the relay the attach itself asks the desktop to @@ -597,7 +604,8 @@ class TerminalController( // Only for the attach the screen still shows: a newer one settles the offer itself. if (!slot.isCurrent(ticket)) return@post // The offer still on screen is carried: one the user answered or dismissed is not. - resumeOffer = ResumeOffer.afterAttach(s.fresh, conn.kind, snap, nodeId, carried = resumeOffer) + resumeOffer = ResumeOffer.afterAttach(s.fresh, conn.kind, snap, nodeId, carried = resumeOffer, paneCommand = pane) + resumeConn = conn resumeSettled = true } // Reading a finished session on the phone is a READ: tell the computer (unread clears there, @@ -617,13 +625,24 @@ class TerminalController( // it rather than spend it on nothing. The reattach keeps it or drops it (see [resumeOffer]). if (!canResume) return resumeOffer = null - // Re-asked at the tap (A76): the desktop may have woken the session since the offer appeared, - // and a wake line typed into that CLI would arrive as a prompt. - if (!offer.stillOffered(session.snapshot.value, nodeId)) { - notice = "This session is no longer sleeping on the computer, so nothing was typed." + val s = stream ?: return + if (offer.kind != ResumeOffer.Kind.WAKE) { + s.write(offer.keys) return } - stream?.write(offer.keys) + val conn = resumeConn + // Re-asked at the tap (A76 and its review), the pane read LAST: the desktop may have woken the + // session since the offer appeared, or something else started a CLI in the pane, and a wake + // line typed into that CLI would arrive as a prompt. + graph.scope.launch { + val pane = conn?.paneCommand(nodeId) + val snap = session.snapshot.value + main.post { + // The screen moved on meanwhile (a reattach, or it left): not this tap's stream. + if (stream !== s) return@post + if (offer.stillOffered(snap, nodeId, pane)) s.write(offer.keys) else notice = ResumeOffer.WITHDRAWN + } + } } fun dismissResume() { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt index 3008d1287c..37389dc109 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -105,6 +105,14 @@ interface HostConnection : Closeable { /** Type raw keys into a node's pane (quick approve / answer fallback). */ suspend fun sendKeys(nodeId: String, keys: String) + /** + * What owns [nodeId]'s pane right now (tmux `#{pane_current_command}`: a shell, or the CLI + * running in it), or null when it cannot be read. Never throws for a failed read: null is + * "unknown", and every caller treats unknown as "not a shell". The relay serves no such verb; + * nothing asks it there, since through the relay the desktop does its own wake ([ResumeOffer]). + */ + suspend fun paneCommand(nodeId: String): String? = null + suspend fun git(verb: GitVerb, cwd: String, args: Map = emptyMap()): JsonElement? /** Fires when the host signals a change worth a re-list (a canvas push, a reconnect). */ diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt index 9d4eeebe7f..df780c3775 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt @@ -2,6 +2,7 @@ package dev.nodeterm.protocol.host import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.Pane import dev.nodeterm.protocol.model.ProjectsSnapshot /** @@ -12,13 +13,23 @@ import dev.nodeterm.protocol.model.ProjectsSnapshot * - [Kind.RESUME] (audit A15): the attach had to CREATE the tmux session, because the computer * rebooted. The conversation is on disk and the pane is a new shell (in `$HOME` when the relay * created it), so the line is built like the desktop's cold restore: `cd` into the node's folder, - * the node's managed Claude account, the permission mode ([Launch.resumeLine]). + * the node's managed Claude account, and for Claude the permission mode ([Launch.resumeLine]). * - [Kind.WAKE] (audit A76): the session is Sleeping. Eco (or a shallow "Pause session") exited the * CLI and left the pane's shell behind. That shell already sits in the node's folder, and its tmux - * env already carries the account's CLAUDE_CONFIG_DIR / CODEX_HOME, so the line is what the - * desktop's own wake types: the bare resume plus the permission mode ([Launch.wakeLine]), after a - * kill-line so a half-typed line left at that prompt is not spliced into it. A `cd` or an account - * prefix could only be wrong there: the pane's shell and env are the authority, not project.json. + * env already carries the account's CLAUDE_CONFIG_DIR / CODEX_HOME, so the line is the desktop's + * own wake line: the bare resume, plus the permission mode for Claude only ([Launch.wakeLine]; the + * desktop also adds each other agent's own approval flag, which the phone cannot build, so those + * wake in their CLI's default policy: docs/android.md "Known gaps"). It is typed after a kill-line + * so a half-typed line left at that prompt is not spliced into it. A `cd` or an account prefix + * could only be wrong there: the pane's shell and env are the authority, not project.json. + * + * A wake is offered only while a SHELL owns the pane ([Pane.isShell] of the pane's foreground + * command, read over SSH right before), the gate the desktop's own wake keeps. The mirror's + * `hibernated` flag alone is not enough, because it can outlive the sleep. A desktop older than the + * A76 review dropped its own copy on a resumed CLI's first live hook event without telling the + * mirror (and codex reports its start only that way), so a CLI started outside the desktop's own + * wake, this phone's included, stayed Sleeping there. With the desktop app not running, nothing + * hears the resumed CLI at all. A running CLI would take the wake line as a prompt. * * A wake is offered over DIRECT SSH only. A relay attach tells the desktop that a phone opened the * node (host-service `remoteViewer.attached` → `agent:wake`), and the desktop wakes it itself, after @@ -70,20 +81,32 @@ data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String, va } /** - * Re-asked when the user taps the offer, against the snapshot at that moment (the same rule as - * the Inbox quick actions: act only on what is still true). A wake is withdrawn once the node is - * no longer Sleeping: the desktop resumed it meanwhile, and a resume line typed now would land - * in that CLI's composer as a prompt. + * Re-asked when the user taps the offer, against the snapshot at that moment and the pane's + * foreground command read just then ([paneCommand]; the same rule as the Inbox quick actions: + * act only on what is still true). A wake is withdrawn once the node is no longer Sleeping, or + * once anything but a shell owns the pane: a CLI started there meanwhile (the desktop woke it, or + * someone typed the resume) would take the line as a prompt. An unread pane (null) withdraws it. */ - fun stillOffered(current: ProjectsSnapshot, nodeId: String): Boolean = when (kind) { + fun stillOffered(current: ProjectsSnapshot, nodeId: String, paneCommand: String? = null): Boolean = when (kind) { Kind.RESUME -> true - Kind.WAKE -> current.statusOf(nodeId)?.hibernated == true + Kind.WAKE -> current.statusOf(nodeId)?.hibernated == true && Pane.isShell(paneCommand) } companion object { /** `KILL_LINE` (src/shared/shell-kill-line.ts): Ctrl-U, the POSIX shells' line discard. */ const val KILL_LINE = "\u0015" + /** Shown when a tapped wake is withdrawn ([stillOffered]): nothing was typed. */ + const val WITHDRAWN = "This session is no longer sleeping on the computer, so nothing was typed." + + /** + * Whether [afterAttach] could offer a wake for this attach, i.e. whether the caller must read + * the pane's foreground command ([HostConnection.paneCommand]) to pass it in. Only then: the + * read is one more command over SSH. + */ + fun wantsPane(fresh: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String): Boolean = + !fresh && transport == TransportKind.SSH && snapshot.statusOf(nodeId)?.hibernated == true + /** * The offer after an attach of [nodeId] over [transport], or null. [fresh] is the attach's * own answer ([TerminalStream.fresh]); [snapshot] is the phone's latest view of the computer. @@ -91,21 +114,24 @@ data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String, va * [Kind.RESUME] survives a warm reattach while the computer builds exactly that offer again * for a cold pane (same line, and no hook event from the node since, [statusAt]); otherwise * it is dropped. A fresh reattach is a new cold pane and gets the line built now. + * [paneCommand] is the pane's foreground command, read after this attach when [wantsPane] + * said so; a wake needs it to name a shell, and omitting it (null: unread) offers no wake. */ fun afterAttach( fresh: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String, - carried: ResumeOffer? = null + carried: ResumeOffer? = null, + paneCommand: String? = null ): ResumeOffer? { // The pane is still the cold one the earlier attach created, so the cold line, and it // wins over a Sleeping flag as it did then. - if (!fresh && carried?.kind == Kind.RESUME) return build(cold = true, transport, snapshot, nodeId).takeIf { it == carried } - return build(fresh, transport, snapshot, nodeId) + if (!fresh && carried?.kind == Kind.RESUME) return build(cold = true, transport, snapshot, nodeId, null).takeIf { it == carried } + return build(fresh, transport, snapshot, nodeId, paneCommand) } - private fun build(cold: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String): ResumeOffer? { + private fun build(cold: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String, paneCommand: String?): ResumeOffer? { val found = snapshot.findNode(nodeId) val project = found?.first val node = found?.second @@ -119,7 +145,8 @@ data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String, va // with the old tmux session, so the new one needs the full cold-restore line. cold -> Launch.resumeLine(agent, sid, settings, node?.accountId, project?.absoluteCwdOf(node), project?.defaultPermissionMode) ?.let { ResumeOffer(Kind.RESUME, agent, it, at) } - status?.hibernated == true && transport == TransportKind.SSH -> + // Sleeping by the mirror AND a shell in the pane: the flag can outlive the sleep. + status?.hibernated == true && transport == TransportKind.SSH && Pane.isShell(paneCommand) -> Launch.wakeLine(agent, sid, settings, project?.defaultPermissionMode)?.let { ResumeOffer(Kind.WAKE, agent, it, at) } else -> null } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt index 31c80859f1..1b0896fdb3 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt @@ -79,7 +79,8 @@ object Launch { /** * The resume line for a cold attach, built like the desktop's cold restore (audit A15): in the - * node's own directory, under the node's managed Claude account, with the permission mode. A + * node's own directory, under the node's managed Claude account, with the permission mode (for + * Claude only, as in [launchCommand]). A * bare `claude --resume ` typed into a relay-created pane in `$HOME`, under the default * config dir, finds no transcript for a managed account (and, by the transcript path's encoded * cwd, likely none at all). Null when the agent or id cannot be resumed. @@ -94,11 +95,13 @@ object Launch { ): String? = resumeCommand(agent, sessionId)?.let { compose(agent, it, settings, accountId, cwd, projectMode) } /** - * The line that wakes a Sleeping (Eco-hibernated) session, as the desktop's own wake types it - * (TerminalNode's wake closure: the resume with the permission mode re-resolved, audit A76). It - * carries no `cd` and no account: the pane's shell is the one the CLI exited back to, so it already - * sits in the node's directory and its tmux env already names the account's config dir. Null - * when the agent or id cannot be resumed. + * The line that wakes a Sleeping (Eco-hibernated) session, after the desktop's own wake + * (TerminalNode's wake closure: the resume with the permission mode re-resolved, audit A76). Only + * CLAUDE gets that mode, as in [launchCommand]: the desktop also appends each other agent's own + * approval flag, which the phone does not build, so they wake bare, in their CLI's default + * policy. It carries no `cd` and no account: the pane's shell is the one the CLI exited back to, + * so it already sits in the node's directory and its tmux env already names the account's config + * dir. Null when the agent or id cannot be resumed. */ fun wakeLine( agent: Agent, diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Pane.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Pane.kt new file mode 100644 index 0000000000..08434472e5 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Pane.kt @@ -0,0 +1,21 @@ +package dev.nodeterm.protocol.model + +/** + * What a tmux pane's foreground command (`#{pane_current_command}`) says about a session: the + * desktop's `isShellCommand` (src/shared/agents/pane.ts), the gate its own wake keeps before it types + * a resume line. True means a SHELL owns the pane, so the agent CLI has let go of it. + * + * The same seven names, and deliberately not "anything but a known agent": an npm-installed CLI + * reports `node`, a native one its own name, and a shell the list does not know (nu, pwsh, xonsh) + * reads as "not a shell". For the question asked here (may a launch line be typed into this pane?), + * an unknown answers no. A login shell reports as `-zsh`, and tmux may report a full path. + */ +object Pane { + /** `SHELLS` in src/shared/agents/pane.ts (pinned against that file by a test). */ + val SHELLS: Set = setOf("zsh", "bash", "sh", "fish", "dash", "ksh", "tcsh") + + fun isShell(command: String?): Boolean { + if (command.isNullOrEmpty()) return false + return command.removePrefix("-").substringAfterLast('/') in SHELLS + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index 861088f3f0..9a0e22be26 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -447,6 +447,19 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos } } + override suspend fun paneCommand(nodeId: String): String? = withContext(Dispatchers.IO) { + // A remote node's pane is on ITS host; this computer's tmux has nothing to say about it. + if (remoteNodes.containsKey(nodeId)) return@withContext null + try { + val (code, out) = run(SshScripts.paneCommand(nodeId)) + out.trim().takeIf { code == 0 && it.isNotEmpty() && '\n' !in it } + } catch (e: HostException) { + null // the transport dropped; `run` has already reported it through onClosed + } catch (e: IllegalArgumentException) { + null // not a node id this app generates: no tmux target is ever built from it + } + } + /** Kill the node's tmux session (the node stays on the canvas; the desktop shows it as ended). */ suspend fun killSession(nodeId: String) = withContext(Dispatchers.IO) { refuseRemoteNode(nodeId) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt index ceebfa08b5..ee90386ac7 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt @@ -130,6 +130,21 @@ object SshScripts { """.trimIndent() } + /** + * What owns the node's pane: tmux's `#{pane_current_command}`, the fact the desktop's wake reads + * (`PtyManager.paneCommand`) before it types a resume line. Prints nothing when there is no tmux + * or no such session. + */ + fun paneCommand(nodeId: String): String { + // A PANE target, like [sendKeys]: the exact-session form for a pane command is `=name:`. + val pane = q("=" + target(nodeId) + ":") + return """ + $PRELUDE + [ -n "${'$'}NT_TMUX" ] || exit 127 + "${'$'}NT_TMUX" -L ${TmuxNames.SOCKET} display-message -p -t $pane '#{pane_current_command}' + """.trimIndent() + } + /** * Type into a pane. `-l --` so text is literal and a leading `-` is never an option (the * leading-dash hazard documented in tmux-naming.ts). A lone ESC is sent as the `Escape` key. diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt index 977684607f..62c665ee5e 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt @@ -6,6 +6,7 @@ import dev.nodeterm.protocol.model.Agent import dev.nodeterm.protocol.model.Launch import dev.nodeterm.protocol.model.ManagedAccount import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.Pane import dev.nodeterm.protocol.model.ProjectsParser import dev.nodeterm.protocol.model.ProjectsSnapshot import kotlin.test.Test @@ -14,6 +15,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import java.io.File /** * Audit A76 (and A15, and the A41 review): what the terminal screen offers to type after an attach, @@ -59,9 +61,13 @@ class ResumeOfferTest { } } + /** What the pane's foreground command reads while Eco's shell owns it (a login shell). */ + private val shell = "-zsh" + @Test fun `a Sleeping session opened over SSH offers the desktop's wake line (A76)`() { - val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1")) + assertTrue(ResumeOffer.wantsPane(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1"), "the pane is read first") + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", paneCommand = shell)) assertEquals(ResumeOffer.Kind.WAKE, offer.kind) // No cd and no account prefix, although the node has both: the pane's shell is the one the CLI // exited back to, already in the node's folder and under the account's env. @@ -75,14 +81,16 @@ class ResumeOfferTest { fun `a Sleeping session opened through the relay offers nothing, since the desktop wakes it on attach`() { // host-service reports the attach (`remoteViewer.attached` → `agent:wake`); an offer as well // would type `--resume` into the CLI the desktop just started. - assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1")) + assertFalse(ResumeOffer.wantsPane(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1")) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1", paneCommand = shell)) } @Test fun `a warm attach of an awake session offers nothing, on either transport`() { for (transport in TransportKind.entries) { - assertNull(ResumeOffer.afterAttach(fresh = false, transport, snapshot(awake), "n1")) - assertNull(ResumeOffer.afterAttach(fresh = false, transport, snapshot(null), "n1")) + assertFalse(ResumeOffer.wantsPane(fresh = false, transport, snapshot(awake), "n1"), "no pane read for a node that is awake") + assertNull(ResumeOffer.afterAttach(fresh = false, transport, snapshot(awake), "n1", paneCommand = shell)) + assertNull(ResumeOffer.afterAttach(fresh = false, transport, snapshot(null), "n1", paneCommand = shell)) } } @@ -99,43 +107,102 @@ class ResumeOfferTest { // A shallow "Pause session" is an Eco exit plus `paused`, and the mirror carries only the // `hibernated` half: the phone sees Sleeping. The desktop never wakes a paused node on its own, // but its PAUSED chip is an explicit Resume, and a tapped offer is that same explicit Resume. - val shallow = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1")) + val shallow = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", paneCommand = shell)) assertEquals(ResumeOffer.Kind.WAKE, shallow.kind) // "Pause & end session" recycles the tmux session and leaves `hibernated` unset: no offer. val deep = """{"agentId":"claude","sessionId":"sid-7","updatedAt":5}""" - assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(deep), "n1")) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(deep), "n1", paneCommand = shell)) } @Test fun `a Sleeping entry with no agent or session of its own falls back to the node's`() { // `setNodeHibernated` can create an entry that carries only the flag. val bare = """{"hibernated":true,"updatedAt":5}""" - val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(bare), "n1")) + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(bare), "n1", paneCommand = "bash")) assertEquals("claude --resume minted-1 --permission-mode plan", offer.command) } @Test fun `no offer for an id that cannot reach a command line, or an agent the phone cannot resume`() { val unsafe = """{"agentId":"claude","sessionId":"x; rm -rf ~","hibernated":true,"updatedAt":5}""" - assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(unsafe), "n1")) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(unsafe), "n1", paneCommand = shell)) val custom = ProjectsParser.parseBlob( """{"version":2,"projects":[{"id":"p1","name":"R","nodes":[{"id":"c1","agentId":"custom:abc","agentSessionId":"s"}]}]} ${ProjectsParser.STATUS_MARK} {"nodes":{"c1":{"hibernated":true,"updatedAt":1}}}""" ) - assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, custom, "c1")) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, custom, "c1", paneCommand = shell)) } @Test fun `a wake is withdrawn at the tap once the node is no longer Sleeping`() { - val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1")) - assertTrue(offer.stillOffered(snapshot(sleeping), "n1")) - // The desktop resumed it meanwhile (its SessionStart clears the flag). - assertFalse(offer.stillOffered(snapshot(awake), "n1")) + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", paneCommand = shell)) + assertTrue(offer.stillOffered(snapshot(sleeping), "n1", shell)) + // The desktop resumed it meanwhile, and its first live hook event cleared the flag. + assertFalse(offer.stillOffered(snapshot(awake), "n1", shell)) + // Or the flag is still set, but a CLI owns the pane now (the desktop woke it and its hook events + // have not reached the phone yet, or a resume was typed by hand): nothing is typed into it. + assertFalse(offer.stillOffered(snapshot(sleeping), "n1", "claude")) + assertFalse(offer.stillOffered(snapshot(sleeping), "n1", null), "a pane that could not be read withdraws it") val resume = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) assertTrue(resume.stillOffered(snapshot(awake), "n1"), "a cold pane's resume does not depend on the flag") } + /** + * The A76 review. The mirror's `hibernated` flag can outlive the sleep: a desktop older than the + * review dropped its own copy on a resumed CLI's first live hook event without telling the mirror + * (codex reports its start only that way), and with the desktop app not running nothing hears the + * resumed CLI at all. So after the phone wakes a codex session, the node still reads Sleeping, and + * every later open would offer `codex resume` into the running CLI, as a prompt. + */ + @Test + fun `a Sleeping flag over a running CLI offers no wake, so a phone-woken codex is not offered again`() { + val codex = ProjectsParser.parseBlob( + """{"version":2,"projects":[{"id":"p1","name":"Repo","cwd":"/repo", + "nodes":[{"id":"x1","kind":"terminal","agentId":"codex","cwd":"./sub"}]}]} + ${ProjectsParser.STATUS_MARK} + {"nodes":{"x1":{"state":"done","agentId":"codex","sessionId":"t-1","hibernated":true,"updatedAt":5}}}""" + ) + // Eco left a shell in the pane: the wake is offered, and the phone types it. + val wake = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", paneCommand = "bash")) + assertEquals("\u0015codex resume t-1\r", wake.keys) + // Codex is running there now, and nothing has cleared the flag: a reattach (the stream dropped, + // the app came back) and a new open both find Sleeping in the listing, and a CLI in the pane. + assertTrue(ResumeOffer.wantsPane(fresh = false, TransportKind.SSH, codex, "x1")) + for (running in listOf("codex", "node", "/usr/local/bin/codex")) { + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", paneCommand = running), running) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", carried = wake, paneCommand = running), running) + assertFalse(wake.stillOffered(codex, "x1", running), running) + } + // A pane that could not be read, and a shell the desktop's list does not know, offer nothing + // either: an unknown owner is never typed into. + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1")) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", paneCommand = "nu")) + // Once the CLI exits back to its shell, the conversation can be woken again. + assertEquals(wake, ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", paneCommand = "-bash")) + } + + /** The screen is only type-checked: pin that it feeds the pane read into both decisions. */ + @Test + fun `the terminal screen reads the pane before it offers a wake, and again before it types one`() { + val controller = AppSourcePins.ui("TerminalController.kt") + val attach = AppSourcePins.blockAfter(controller, "private suspend fun afterAttach(") + AppSourcePins.assertInOrder(attach, "ResumeOffer.wantsPane(", "conn.paneCommand(nodeId)", "ResumeOffer.afterAttach(", "paneCommand = pane") + val tap = AppSourcePins.blockAfter(controller, "fun acceptResume()") + AppSourcePins.assertInOrder(tap, "ResumeOffer.Kind.WAKE", "conn?.paneCommand(nodeId)", "offer.stillOffered(snap, nodeId, pane)", "s.write(offer.keys)") + } + + @Test + fun `the pane gate is the desktop's shell list`() { + for (sh in listOf("zsh", "-zsh", "bash", "/bin/sh", "fish", "dash", "ksh", "tcsh")) assertTrue(Pane.isShell(sh), sh) + for (other in listOf(null, "", "node", "claude", "codex", "sleep", "nu", "pwsh", "zsh/", "-")) assertFalse(Pane.isShell(other), "$other") + // Pinned against the desktop's own list (src/shared/agents/pane.ts `SHELLS`), so the phone's + // wake and the desktop's wake cannot disagree about what a Sleeping pane looks like. + val source = File(InteropHarness.repoRoot, "src/shared/agents/pane.ts").readText() + val list = assertNotNull(Regex("const SHELLS = new Set\\(\\[([^\\]]*)\\]\\)").find(source), "SHELLS in pane.ts").groupValues[1] + assertEquals(Regex("'([^']+)'").findAll(list).map { it.groupValues[1] }.toSet(), Pane.SHELLS) + } + /** * The A41 review: a reattach of the same screen (the stream dropped, the app went to the * background) is warm, because the cold attach before it created the session. Re-deriving the @@ -156,7 +223,7 @@ class ResumeOfferTest { // The pane is the cold one the first attach created, not the shell a CLI exited back to, so // the wake line (no cd, no account) would still be the wrong one there. val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(sleeping), "n1")) - val kept = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", carried = offered)) + val kept = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", carried = offered, paneCommand = shell)) assertEquals(ResumeOffer.Kind.RESUME, kept.kind) assertEquals(offered, kept) } @@ -187,11 +254,11 @@ class ResumeOfferTest { @Test fun `a wake is not carried, since each attach re-derives it`() { - val wake = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1")) - assertEquals(wake, ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", carried = wake)) + val wake = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", paneCommand = shell)) + assertEquals(wake, ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", carried = wake, paneCommand = shell)) // Woken on the computer meanwhile, or reopened through the relay (whose attach wakes it). - assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(awake), "n1", carried = wake)) - assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1", carried = wake)) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(awake), "n1", carried = wake, paneCommand = shell)) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1", carried = wake, paneCommand = shell)) } @Test @@ -200,7 +267,10 @@ class ResumeOfferTest { assertEquals("claude --resume abc", Launch.wakeLine(Agent.CLAUDE, "abc", s), "auto on an old claude degrades to no flag") assertEquals("claude --resume abc --permission-mode auto", Launch.wakeLine(Agent.CLAUDE, "abc", s.copy(autoSupported = true))) assertEquals("claude --resume abc --permission-mode plan", Launch.wakeLine(Agent.CLAUDE, "abc", s, "plan")) + // The desktop's wake appends each capable agent's own approval flag; the phone builds claude's + // alone (docs/android.md "Known gaps"), so codex/gemini/grok wake in their CLI's default policy. assertEquals("codex resume abc", Launch.wakeLine(Agent.CODEX, "abc", s, "plan"), "only claude gets the flag") + assertEquals("gemini --resume abc", Launch.wakeLine(Agent.GEMINI, "abc", s, "plan")) assertNull(Launch.wakeLine(Agent.CLAUDE, "-flag", s)) } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index a9e2895ccd..9c9c212b5d 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -8,6 +8,7 @@ import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.Pane import dev.nodeterm.protocol.pairing.SshIdentity import dev.nodeterm.protocol.ssh.HostKeyChangedException import dev.nodeterm.protocol.ssh.HostKeyPin @@ -471,7 +472,10 @@ class SshTransportTest { connect().use { conn -> val snap = conn.listProjects() val stream = conn.attach("term-a-1", 100, 30, Sink()) - val offer = assertNotNull(ResumeOffer.afterAttach(stream.fresh, conn.kind, snap, "term-a-1")) + assertTrue(ResumeOffer.wantsPane(stream.fresh, conn.kind, snap, "term-a-1")) + val owner = conn.paneCommand("term-a-1") + assertTrue(Pane.isShell(owner), "Eco's shell owns the pane: $owner") + val offer = assertNotNull(ResumeOffer.afterAttach(stream.fresh, conn.kind, snap, "term-a-1", paneCommand = owner)) assertEquals(ResumeOffer.Kind.WAKE, offer.kind) assertEquals("claude --resume sid-7 --permission-mode plan", offer.command, "no cd: the pane is already there") Thread.sleep(400) @@ -496,6 +500,84 @@ class SshTransportTest { } } + /** + * The A76 review: the mirror's `hibernated` flag outlives a CLI resumed outside the desktop's own + * wake (here the desktop app is not running at all, so nothing hears the resumed CLI). The phone + * wakes a Sleeping codex session, codex keeps running, and the node still reads Sleeping; the next + * open must not offer `codex resume` into it, where it would be sent as a prompt. + */ + @Test + fun `a codex session the phone woke is not offered the wake again while it runs, though the flag stays`() = runBlocking { + val status = File(home, ".config/node-terminal/agent-status.json") + val before = status.readText() + // term-a-1 runs codex for this test. + val project = File(repo, ".nodeterm/project.json") + val projectBefore = project.readText() + project.writeText(projectBefore.replace("\"agentId\":\"claude\"", "\"agentId\":\"codex\"")) + // A stand-in codex that stays in the foreground, like the real TUI (its argv[0] is not a shell). + val bin = File(root, "fake-codex").apply { mkdirs() } + File(bin, "codex").apply { + writeText("#!/bin/sh\necho \"codex-up:\$*\"\nexec sleep 60\n") + setExecutable(true) + } + status.writeText( + """{"v":1,"updatedAt":1,"nodes":{"term-a-1":{"state":"done","agentId":"codex","sessionId":"t-9","hibernated":true,"updatedAt":5}}, + "inbox":{"events":[],"nodes":{}}}""" + ) + fun paneOwner(): String = tmux("display-message", "-p", "-t", "=nt-term-a-1:", "#{pane_current_command}").second.trim() + try { + connect().use { conn -> + val snap = conn.listProjects() + assertEquals(true, snap.statusOf("term-a-1")?.hibernated) + val first = conn.attach("term-a-1", 100, 30, Sink()) + val wake = assertNotNull( + ResumeOffer.afterAttach(first.fresh, conn.kind, snap, "term-a-1", paneCommand = conn.paneCommand("term-a-1")) + ) + assertEquals("codex resume t-9", wake.command) + Thread.sleep(400) + first.write("PATH='${bin.path}':\$PATH; export PATH\r") + first.write(wake.keys) + val end = System.currentTimeMillis() + 8_000 + while (paneOwner() != "sleep" && System.currentTimeMillis() < end) Thread.sleep(100) + assertEquals("sleep", paneOwner(), "the stand-in codex holds the pane") + first.detach() + + // Nothing cleared the flag: the listing still says Sleeping. The next open reads the pane. + val again = conn.listProjects() + assertEquals(true, again.statusOf("term-a-1")?.hibernated, "the flag outlived the sleep") + val second = conn.attach("term-a-1", 100, 30, Sink()) + assertTrue(ResumeOffer.wantsPane(second.fresh, conn.kind, again, "term-a-1")) + val pane = conn.paneCommand("term-a-1") + assertEquals("sleep", pane) + assertNull(ResumeOffer.afterAttach(second.fresh, conn.kind, again, "term-a-1", paneCommand = pane), "no wake over a running CLI") + // An offer still on screen from before is withdrawn at the tap by the same read. + assertFalse(wake.stillOffered(again, "term-a-1", pane)) + second.detach() + } + } finally { + status.writeText(before) + project.writeText(projectBefore) + // Hand the shared session back to its shell for the other tests. + tmux("send-keys", "-t", "=nt-term-a-1:", "C-c") + val end = System.currentTimeMillis() + 5_000 + while (!Pane.isShell(paneOwner()) && System.currentTimeMillis() < end) Thread.sleep(100) + } + } + + @Test + fun `a pane that cannot be read is unknown, never a shell`() = runBlocking { + connect().use { conn -> + // No such session, a node of the desktop's SSH projects (its pane is on another host), an id + // no tmux target can be built from, and shell text (it becomes a session name that does not + // exist): all null, none of them a shell. + assertNull(conn.paneCommand("term-z-9")) + conn.rememberRemoteNodes(conn.listProjects()) + assertNull(conn.paneCommand("term-b-2")) + assertNull(conn.paneCommand("")) + assertNull(conn.paneCommand("x; rm -rf ~")) + } + } + @Test fun `the attach script itself refuses to create a missing session`() { // Belt and braces for the race where the session ends between the check and the attach. diff --git a/docs/android.md b/docs/android.md index 04fa81531c..559afef164 100644 --- a/docs/android.md +++ b/docs/android.md @@ -62,7 +62,7 @@ describes as the future. The Android client implements what the host actually se | Type / resize | `OP.Input` / `OP.Resize` frames | channel stdin / window-change | | Scroll | `pty.scroll` (host writes SGR wheel events) | the phone writes the same SGR wheel events | | Detach / end | `pty.kill` / `pty.destroy` | close channel / `kill-session` | -| Wake on open | the attach itself: host-service reports the viewer (`remoteViewer.attached` → `agent:wake`) and the desktop wakes a Sleeping node it has mounted; the phone offers nothing, so it never types a second `--resume` | nothing reaches the desktop, so opening a Sleeping node offers the desktop's wake line (the agent's `--resume ` plus the permission mode, no `cd` or account: the pane's shell already has both), typed only on a tap, after a kill-line | +| Wake on open | the attach itself: host-service reports the viewer (`remoteViewer.attached` → `agent:wake`) and the desktop wakes a Sleeping node it has mounted; the phone offers nothing, so it never types a second `--resume` | nothing reaches the desktop, so opening a Sleeping node offers the desktop's wake line (the agent's `--resume `, plus the permission mode for Claude only, no `cd` or account: the pane's shell already has both), only while a shell owns the pane (`#{pane_current_command}`, read on open and again at the tap), typed only on a tap, after a kill-line | | Wake, refresh, rename | `node.wake|refresh|rename` | — (needs the desktop app) | | Board | `projects.ensureBoard|setCardColumn|editCardLabels` | read-only | | New session | `pty.attach` of a fresh `term-…` id, launch line, then `projects.registerNode` | — | @@ -197,10 +197,21 @@ handling itself is only type-checked. `ResumeOfferTest` pins what the terminal screen offers to type after an attach (`A15`, `A76`). A cold attach (the computer rebooted; only the relay creates a session) gets the cold-restore line: `cd` into -the node's folder, its managed account, the permission mode. A Sleeping (Eco-hibernated) node opened over direct +the node's folder, its managed account, and for Claude the permission mode. A Sleeping (Eco-hibernated) node opened over direct SSH gets the desktop's own wake line instead, with no `cd` and no account prefix, since the pane's -shell is the one the CLI exited back to; accepting it clears the prompt's line first (Ctrl-U, the -desktop's kill-line) and re-checks that the node is still Sleeping. Through the relay a Sleeping +shell is the one the CLI exited back to (again the permission mode for Claude only). It is offered +only while a shell owns the pane: the phone reads the pane's foreground command +(`#{pane_current_command}`) and requires one of the desktop's own shell names (`isShellCommand`, +src/shared/agents/pane.ts, pinned by the test), the gate the desktop's wake keeps. The Sleeping flag +alone is not enough, because it can outlive the sleep. Until the A76 review the desktop cleared it +in the mirror only through its own wake, so a CLI resumed any other way stayed Sleeping there (codex +reports its start as a live state, which the renderer cleared only in its own store). The renderer +now reports that clear and the mirror applies the same rule to the hook events it records, but an +older desktop does not, and with the desktop app not running nothing hears the resumed CLI at all. So after the phone wakes a codex session, the node can still +read Sleeping, and without the pane check every later open would offer `codex resume` into the +running CLI, as a prompt. Accepting a wake clears the prompt's line first (Ctrl-U, the desktop's +kill-line) and re-checks, at the tap, that the node is still Sleeping and that a shell still owns +the pane. A shell outside the desktop's list (nu, pwsh) gets no offer. Through the relay a Sleeping node gets no offer: the attach already asked the desktop to wake it. A shallow "Pause session" is Sleeping in the mirror (it carries no `paused`), so it too gets only the offer, which is the explicit Resume the desktop's PAUSED chip is; a deep pause leaves no Sleeping flag and gets nothing. @@ -212,7 +223,9 @@ dropped otherwise, since a CLI started in the pane meanwhile would take the line the reattach has settled it, it cannot be tapped. A wake is not carried; every attach re-derives it. Leaving the screen and opening the node again is a new screen, whose warm attach offers nothing. `SshTransportTest` runs the offer end to end against the real tmux: the line it types starts the -stand-in CLI in the node's own folder even with a half-typed line left at the prompt. The banner +stand-in CLI in the node's own folder even with a half-typed line left at the prompt, and a stand-in +codex the phone woke, still running under a flag nothing cleared, is not offered the wake again on +the next open. The banner itself is only type-checked. `TerminalKeyboardChipTest` covers the key row's ⌨ chip (`A46`), which used to leave the soft keyboard @@ -333,7 +346,13 @@ how the screens and the dialogs look with the keyboard up is a device check. as a prompt. Typing the resume by hand, or opening the node over direct SSH, works. - **Codex/Gemini/… launch flags.** A phone-started non-Claude agent launches bare (its own default approval mode): the per-agent approval table needs host facts (codex's vocabulary moved between - releases, #785) the mirror only partly publishes. + releases, #785) the mirror only partly publishes. The same holds for the resume lines the phone + offers: a cold-attach resume and a Sleeping node's wake carry the permission mode for Claude only, + where the desktop's own wake and cold restore append each capable agent's flag. Some of those + sessions come back looser than their mode: a Gemini or Grok session whose mode is `plan` (read-only) + resumes in its CLI's default, which can edit after asking, and a Codex node in `manual` on a + codex before 0.149 loses `--ask-for-approval untrusted` and resumes in `on-request`, where the + model decides when to ask. - **The SSH pin is not anchored in pairing, and the LAN address is frozen at pairing** (audit `A49`/`A74`). Neither the QR nor the sealed `/pair` answer carries the computer's SSH host key, so the first connect is trust on first use (on the pairing LAN, right after the QR, so normally the diff --git a/src/core/agent-status-mirror.test.ts b/src/core/agent-status-mirror.test.ts index 15e8202ff1..8dc045f15f 100644 --- a/src/core/agent-status-mirror.test.ts +++ b/src/core/agent-status-mirror.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import fs from 'fs' import os from 'os' import path from 'path' -import type { NormalizedAgentEvent } from '@shared/agents/normalize' +import { normalizeCodex, type NormalizedAgentEvent } from '@shared/agents/normalize' import { syntheticAnsweredEvent } from './agents/pending-approvals' import { reduceEntry, @@ -2532,6 +2532,37 @@ describe('hibernated flag (Eco × phone — SLEEPING on external readers)', () = expect(_snapshot().n1.state).toBeUndefined() expect(_snapshot().n1.hibernated).toBe(true) }) + + // The A76 review: the phone offers to type a wake line off this flag, so a flag left standing on + // a running CLI is a resume line typed into that CLI's composer. The renderer drops its own copy + // on these edges; the mirror hears the same hook events (with or without a renderer) and does too. + it('a live state drops the flag — including a codex SessionStart, which arrives as `working`', () => { + for (const state of ['working', 'blocked', 'waiting'] as const) { + recordAgentEvent(ev({ nodeId: 'n1', state: 'done', sessionId: 's1' })) + setNodeHibernated('n1', true) + recordAgentEvent(ev({ nodeId: 'n1', state, sessionId: 's1', newTurn: true })) + expect(_snapshot().n1.hibernated, state).toBeUndefined() + } + // The resume the phone types into a Sleeping codex pane, through the real normalizer: codex + // maps SessionStart to a state, never to the session start Canvas also clears on. + setNodeHibernated('cx', true) + const start = normalizeCodex({ nodeId: 'cx', agentId: 'codex', payload: { hook_event_name: 'SessionStart', session_id: 't1' } }) + expect(start?.kind).toBe('state') + recordAgentEvent(start!) + expect(_snapshot().cx.hibernated).toBeUndefined() + }) + + it('a session START drops the flag; `done` and a session END leave it', () => { + recordAgentEvent(ev({ nodeId: 'n1', state: 'done', sessionId: 's1' })) + setNodeHibernated('n1', true) + // A late Stop after the /exit must not undo the hibernation just performed. + recordAgentEvent(ev({ nodeId: 'n1', state: 'done', sessionId: 's1' })) + recordAgentEvent(ev({ nodeId: 'n1', kind: 'session', sessionPhase: 'end', sessionId: 's1' })) + expect(_snapshot().n1.hibernated).toBe(true) + // A claude/gemini/grok resume starts its session: a CLI is in the pane again. + recordAgentEvent(ev({ nodeId: 'n1', kind: 'session', sessionPhase: 'start', sessionId: 's1' })) + expect(_snapshot().n1.hibernated).toBeUndefined() + }) }) // ---- The observed Claude account (which account a RUNNING session is on) --------------------- diff --git a/src/core/agent-status-mirror.ts b/src/core/agent-status-mirror.ts index 8a60aa7aa9..c120058b33 100644 --- a/src/core/agent-status-mirror.ts +++ b/src/core/agent-status-mirror.ts @@ -138,6 +138,12 @@ export interface MirrorEntry { * render SLEEPING instead of an unexplained idle shell (`setNodeHibernated`). Present = true, * absent = not hibernated — like `restored`/`idleInferred`, so old files keep their shape. * + * The one thing the mirror decides for itself is the renderer's own self-heal: a live state + * (working/blocked/waiting) or a session START recorded for the node drops the flag + * (`reduceEffectiveEntry`). The phone offers to type a wake line off this flag, so a stale one is + * not cosmetic, and the mirror hears every hook event even when no renderer does (a Server + * Edition with no browser tab, a reloading window). A codex SessionStart arrives as `working`. + * * A hibernated entry is EXEMPT from the expiry sweep: hibernation is precisely "idle for hours", * so the 6 h staleness rule would erase the one durable fact this field exists to carry. The * renderer re-reports its persisted set at boot, and a wake (or `clearNode`) drops the flag. @@ -543,6 +549,11 @@ function reduceEffectiveEntry( // it was. `restored` means "this state came off disk", not "we have heard something since // boot", and gate 2 will read it as the former. delete next.restored + // A LIVE state is the CLI reporting from inside the pane, so "its CLI was exited" (Eco's + // `hibernated`) no longer holds — the renderer's own self-heal (`agentStatus.setState`), + // applied here too. Never on `done` (a late Stop must not undo a hibernation just performed) + // and never on a stateless commit (the /exit's own SessionEnd lands as one). + if (state === 'working' || state === 'blocked' || state === 'waiting') delete next.hibernated } // Unrelated tool hooks (including untagged child hooks) are not answers. Keep both // the state and its original evidence/identity until a correlated result or explicit reset. @@ -640,6 +651,9 @@ function reduceEffectiveEntry( // and is what makes a refusal retryable. commitState(undefined, false) next.awaitingInput = undefined + // A START is a CLI launching in that pane (Canvas's session-start clear, applied here too); + // an END is what the hibernating /exit itself fires, so it must leave the flag alone. + if (ev.sessionPhase === 'start') delete next.hibernated // The boundary proves nothing about a state (and leaves `verifiedAt` alone), but a VERIFIED // start arms the idle rescue above. if (ev.sessionPhase === 'start' && ev.verified === true && ev.sessionId) { @@ -1967,7 +1981,8 @@ export function setNodeSessionName(nodeId: string, name: string): boolean { /** * Record (or clear) a node's Eco hibernation flag — the `agent:hibernated` cast's only writer. * The renderer owns the flag; this is a mirror of it, like `terminalFocused` in main (see - * MirrorEntry.hibernated). Unlike `setNodeSessionName`, an UNKNOWN node id creates a minimal + * MirrorEntry.hibernated, which also names the live-state self-heal `reduceEffectiveEntry` + * applies on its own). Unlike `setNodeSessionName`, an UNKNOWN node id creates a minimal * entry: a hibernated session is typically one the mirror has expired (hibernation is hours of * idleness) or one reported at boot before any hook event of this run — exactly when the flag * matters most. Clearing an unknown id stays a no-op. diff --git a/src/renderer/state/agentStatus.persist.test.ts b/src/renderer/state/agentStatus.persist.test.ts index 5713736e7d..4250709f9b 100644 --- a/src/renderer/state/agentStatus.persist.test.ts +++ b/src/renderer/state/agentStatus.persist.test.ts @@ -76,6 +76,30 @@ describe('loop persistence (cron/schedule survive an app restart)', () => { } }) + it('reports the live-state self-heal to the mirror, like every other change of the flag', async () => { + // The A76 review: the phone reads SLEEPING — and offers to type a wake line — off the core's + // mirror, which only carries what this store reports. A codex resume started outside our own + // wake closure (the phone's, over SSH) arrives as `working`, and an unreported clear left the + // phone offering `codex resume` into the running CLI. + vi.stubGlobal('localStorage', memStorage()) + const reportHibernated = vi.fn() + vi.stubGlobal('window', { nodeTerminal: { reportHibernated } }) + const { useAgentStatus } = await import('./agentStatus') + for (const state of ['working', 'blocked', 'waiting'] as const) { + useAgentStatus.getState().setHibernated('n13', true) + reportHibernated.mockClear() + useAgentStatus.getState().setState('n13', state, 'codex') + expect(reportHibernated.mock.calls, state).toEqual([['n13', false]]) + useAgentStatus.getState().setState('n13', undefined) + } + // `done` keeps the flag, so it reports nothing either; neither does a node that was not asleep. + useAgentStatus.getState().setHibernated('n13', true) + reportHibernated.mockClear() + useAgentStatus.getState().setState('n13', 'done', 'codex') + useAgentStatus.getState().setState('n14', 'working', 'codex') + expect(reportHibernated).not.toHaveBeenCalled() + }) + it('keeps `hibernated` through a `done` event — a late Stop must not undo the exit', async () => { vi.stubGlobal('localStorage', memStorage()) const { useAgentStatus } = await import('./agentStatus') diff --git a/src/renderer/state/agentStatus.ts b/src/renderer/state/agentStatus.ts index 6ef7f0856f..c912ffda06 100644 --- a/src/renderer/state/agentStatus.ts +++ b/src/renderer/state/agentStatus.ts @@ -603,6 +603,16 @@ export function createAgentStatusSession( if (alive && prev.hibernated) { next.hibernated = undefined next.hibernatedContext = undefined // goes with the flag, always + // Reported like every other change of the flag (see `setHibernated`): the agent-status + // mirror only carries it, and the phone reads SLEEPING — and offers a wake line — off + // that mirror. Unreported, a CLI resumed by anything but our own wake closure (the + // phone's wake over SSH, a hand-typed resume) stayed SLEEPING there for good: a codex + // SessionStart arrives as `working`, never as the session start Canvas also clears on. + try { + window.nodeTerminal?.reportHibernated?.(id, false) + } catch { + /* the mirror is a side-channel; a failed report must never break the store */ + } } // Same self-heal, same reasoning: a live hook event is proof the CLI is running, whatever // brought it back (our own resume, or the user typing the launch line by hand) — a standing From 582bdaedee7d164de2193ae2fe72a36048b48da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:13:40 +0000 Subject: [PATCH 069/284] fix(android): address review of A44 The A44 fix (7988087) stored the relay address on every leave of Settings, the unedited default included. HostStore answers RelayApi.DEFAULT_API_BASE while no address is stored, so a phone that never set one follows the default of whichever build it runs. Storing the default pinned it to this build's default for good. Before 7988087 only the top-bar arrow did that; the system back, what most users press, stored nothing. The fix had spread the pin to every exit, and its test asserted Save for an unedited default. ApiBaseSetting.onLeave now returns Keep for an accepted address equal to the stored one, as the review suggested. It also gets a UseDefault outcome for the built-in default, checked first, and SettingsScreen answers it with a new HostStore.useDefaultApiBase(), which removes the key. This goes beyond the review: "Reset to default" over a custom address used to store the default and pin the phone the same way, and the field cannot show a pinned default apart from a followed one, so the two are treated alike. Checking the default first also unpins a phone an earlier build stored it on. On a phone that stores no address the removal changes nothing. The phone name is stored only when it differs from the stored one (compared trimmed, as the setter trims), so leaving without an edit no longer pins Build.MODEL. That was the review's optional point; A51 (device-to-device migration still copies these preferences) makes a pinned model name less harmless than allowBackup=false suggests. Tests (SettingsLeaveTest): leaving without an edit gives Keep; the default gives UseDefault, unedited or after a reset; an edit gives Save; the old Save(default) expectation is gone. The source pins now name the conditional name write, the UseDefault branch, one useDefaultApiBase() call, and HostStore's removal plus its default-answering getter. docs/android.md says what leaving stores. Mutation checks: with the previous onLeave decision, the Keep and UseDefault tests fail (2 of 11); with SettingsScreen.kt and HostStore.kt at HEAD, the three source-pin tests fail. Protocol suite: 296 tests, 0 skipped, 0 failed. The offline app type-check passes. Nothing has run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/android/data/HostStore.kt | 9 ++++ .../dev/nodeterm/android/ui/SettingsScreen.kt | 5 ++- .../nodeterm/protocol/relay/ApiBaseSetting.kt | 31 ++++++++++--- .../nodeterm/protocol/SettingsLeaveTest.kt | 44 +++++++++++++++++-- docs/android.md | 9 ++-- 5 files changed, 86 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt index 2e20224464..bd5651b984 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt @@ -108,6 +108,15 @@ class HostStore(context: Context) { prefs.edit().putString("apiBase", value.trim().trimEnd('/')).apply() } + /** + * Forget the stored relay address, so [apiBase] answers the built-in default, a later build's + * included (dev.nodeterm.protocol.relay.ApiBaseSetting.OnLeave.UseDefault). Removing a key + * that is not there changes nothing. + */ + fun useDefaultApiBase() { + prefs.edit().remove("apiBase").apply() + } + var notificationsEnabled: Boolean get() = prefs.getBoolean("notify", true) set(value) { diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt index 65e7465e97..01e3231db9 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -87,10 +87,13 @@ fun SettingsScreen(nav: Navigator) { // by the system back (gesture or button), which used to pop without saving (audit A44). Not per // keystroke: a half-typed address would be the one the relay calls used. An address that is not // https is not stored, and the user is told so rather than finding the old one still in place. + // Leaving without an edit stores nothing. While nothing is stored, the name follows the phone's + // model and the address the build's default relay; storing either would pin it. fun leave() { - graph.hosts.deviceName = name + if (name.trim() != graph.hosts.deviceName) graph.hosts.deviceName = name when (val edit = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase)) { is ApiBaseSetting.OnLeave.Save -> graph.hosts.apiBase = edit.value + ApiBaseSetting.OnLeave.UseDefault -> graph.hosts.useDefaultApiBase() ApiBaseSetting.OnLeave.Keep -> {} ApiBaseSetting.OnLeave.Rejected -> Toast.makeText( context, diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt index 94aa7588a9..127b758ac5 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt @@ -7,6 +7,12 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull * does with it (audit A44). The address is stored only when the user leaves the screen, never per * keystroke: a half-typed value such as `https://a` would otherwise be the one every relay call * used until the typing was done. + * + * Leaving without an edit stores nothing, and the built-in default is never stored as an address. + * The app's store answers [RelayApi.DEFAULT_API_BASE] while it holds no address, so a phone that + * holds none follows the default of whichever build it runs. Storing the default (as the first A44 + * fix did on every leave) would pin the phone to this build's default for good, and the field could + * not show the difference: the pinned and the followed default read the same. */ object ApiBaseSetting { /** What leaving Settings does with the typed address. */ @@ -15,8 +21,15 @@ object ApiBaseSetting { data class Save(val value: String) : OnLeave /** - * Not an address we accept, but it is what is already stored (a value an older build - * saved): nothing to store, and nothing to tell a user who did not type it. + * The built-in default: forget any stored address, so the phone follows the default, + * including a later build's. It is also the answer for an unedited field on a phone that + * stores no address, where forgetting one changes nothing. + */ + data object UseDefault : OnLeave + + /** + * What is already stored: nothing to store. That is an unedited address, or one we would + * not accept that an older build saved, which a user who did not type it is not told about. */ data object Keep : OnLeave @@ -43,10 +56,18 @@ object ApiBaseSetting { return base } - /** What leaving Settings does with [typed], given the address [stored] now. */ + /** + * What leaving Settings does with [typed], given the address [stored] now ([RelayApi.DEFAULT_API_BASE] + * when none is stored). The default is checked first: it heals a phone an earlier build pinned to it. + */ fun onLeave(typed: String, stored: String): OnLeave { - accept(typed)?.let { return OnLeave.Save(it) } - return if (normalize(typed) == normalize(stored)) OnLeave.Keep else OnLeave.Rejected + val unchanged = normalize(typed) == normalize(stored) + val accepted = accept(typed) ?: return if (unchanged) OnLeave.Keep else OnLeave.Rejected + return when { + accepted == RelayApi.DEFAULT_API_BASE -> OnLeave.UseDefault + unchanged -> OnLeave.Keep + else -> OnLeave.Save(accepted) + } } /** The form the app stores an address in (HostStore's setter trims the same way). */ diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt index 608635f318..036f61b4c3 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt @@ -53,15 +53,38 @@ class SettingsLeaveTest { } @Test - fun `leaving stores an accepted address and reports a refused one`() { + fun `leaving stores an edited address and reports a refused one`() { val stored = RelayApi.DEFAULT_API_BASE assertEquals(OnLeave.Save("https://relay.example.com"), ApiBaseSetting.onLeave("https://relay.example.com/", stored)) - assertEquals(OnLeave.Save(stored), ApiBaseSetting.onLeave(stored, stored)) + assertEquals(OnLeave.Save("https://other.example.com"), ApiBaseSetting.onLeave("https://other.example.com", "https://relay.example.com")) assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("http://relay.example.com", stored)) assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("", stored)) assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("https://", stored)) } + @Test + fun `leaving without an edit stores nothing`() { + // The first A44 fix stored the unedited address on every leave. Only the arrow used to, and + // the system back (what most users press) stored nothing. + val custom = "https://relay.example.com" + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave(custom, custom)) + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave(" $custom/ ", custom)) + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave("https://relay.example.com:8443/api", "https://relay.example.com:8443/api/")) + } + + @Test + fun `the built-in default is never stored as an address`() { + // A phone that stores no address reads the default, and follows a later build's default. The + // first A44 fix stored it on every leave, pinning the phone to this build's default for good. + // The unedited default: forgetting an address the phone does not have writes nothing, and + // it unpins a phone an earlier build did store the default on (Keep would leave it pinned). + val default = RelayApi.DEFAULT_API_BASE + assertEquals(OnLeave.UseDefault, ApiBaseSetting.onLeave(default, default)) + // "Reset to default" over a custom address forgets it rather than storing the default. + assertEquals(OnLeave.UseDefault, ApiBaseSetting.onLeave(default, "https://relay.example.com")) + assertEquals(OnLeave.UseDefault, ApiBaseSetting.onLeave(" $default/ ", "https://relay.example.com")) + } + @Test fun `a refused address that is already the stored one is left alone without a message`() { // Only an older build could have stored it (its check let "https://" through, and HostStore @@ -89,15 +112,29 @@ class SettingsLeaveTest { val leave = AppSourcePins.blockAfter(settings, "fun leave()") AppSourcePins.assertInOrder( leave, - "graph.hosts.deviceName = name", + // An unedited name is not stored: a phone that stores no name is named after its model. + "if (name.trim() != graph.hosts.deviceName) graph.hosts.deviceName = name", "ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase)", "OnLeave.Save -> graph.hosts.apiBase = edit.value", + "OnLeave.UseDefault -> graph.hosts.useDefaultApiBase()", + "OnLeave.Keep -> {}", "OnLeave.Rejected -> Toast.makeText(", ".show()", "nav.pop()" ) } + @Test + fun `using the default forgets the stored address, so the getter falls back to the default`() { + val store = AppSourcePins.app("data/HostStore.kt") + val forget = AppSourcePins.blockAfter(store, "fun useDefaultApiBase()") + assertEquals("""prefs.edit().remove("apiBase").apply()""", forget.removeSurrounding("{", "}").trim()) + assertTrue( + store.contains("""get() = prefs.getString("apiBase", null) ?: dev.nodeterm.protocol.relay.RelayApi.DEFAULT_API_BASE"""), + "HostStore.apiBase no longer answers the built-in default while no address is stored" + ) + } + @Test fun `nothing else pops or stores the edits`() { val src = settings @@ -105,6 +142,7 @@ class SettingsLeaveTest { assertEquals(1, Regex("""nav\.pop\(\)""").findAll(src).count()) assertEquals(1, Regex("""graph\.hosts\.apiBase\s*=(?!=)""").findAll(src).count()) assertEquals(1, Regex("""graph\.hosts\.deviceName\s*=(?!=)""").findAll(src).count()) + assertEquals(1, Regex("""graph\.hosts\.useDefaultApiBase\(\)""").findAll(src).count()) // The field warns with the same decision leaving acts on. assertTrue(src.contains("val apiBaseRejected = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase) == ApiBaseSetting.OnLeave.Rejected")) assertTrue(src.contains("isError = apiBaseRejected")) diff --git a/docs/android.md b/docs/android.md index 559afef164..524ec83cb9 100644 --- a/docs/android.md +++ b/docs/android.md @@ -241,9 +241,12 @@ Whether the keyboard comes up, and stays up, is a device check. the screen without storing the edited phone name or relay API address; only the top-bar arrow stored them. Both now run one `leave()`, which stores, then pops. Nothing is stored per keystroke. The relay address must be a full `https://` URL (`ApiBaseSetting`: a host, no query or fragment); one that is -not is left unstored, the field says so while it is being typed, and leaving shows a message. The -address rule is unit-tested; the wiring is pinned in the source, and whether the back gesture reaches -it is a device check. +not is left unstored, the field says so while it is being typed, and leaving shows a message. +Leaving without an edit stores nothing, and the built-in relay address is never stored: "Reset to +default" forgets the stored address instead. A phone that stores none follows the default of the build +it runs, so storing the default would pin it to this build's for good. The address rule and the leave +decision are unit-tested; the wiring is pinned in the source, and whether the back gesture reaches it +is a device check. `InboxNotificationTextTest` pins what an Inbox notification says (`A52`). An approval's notification used to carry the desktop's tool summary (the command's first line, a file path, a fetched URL), and From 27ee194d220fe8c9ad5ba305244d6dfe29cfbaa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:22:29 +0000 Subject: [PATCH 070/284] fix(android): address review of A73 The live notifications (e055f37) treated every Inbox event of a session open in a terminal as on screen and recorded it as seen, permanently. Two cases were not on screen at all: - A held hook-reply approval (it carries a pendingId; on by default for claude). Claude applies the hook's decision before it paints the prompt, and paints it only when the hold ends (45 s by default, docs/hook-reply-approvals.md). A subagent's concurrent approval is one too, published on the parent node while its pane shows the parent's question. Such an approval got no notification while the terminal was open, and none later either. - A terminal that does not show its pane: the Ended, RelayOffer, AwaitingApproval and ViewLost overlays. The screen registered its session for the whole STARTED lifetime. What changed: - OnScreen.inPane: every event is in its session's pane except an approval with a pendingId. OnScreen.shows uses it for the terminal half; the Inbox tab (which lists the card) still shows everything. The ticket stays on the event after the hold ends, so such an approval may be announced although its prompt is painted by then; a notification too many beats one lost. - OnScreenTracker.showNode takes a pane predicate (OnScreen.Pane SHOWN / OPENING / HIDDEN), asked at every listing. TerminalController.pane maps Attached to SHOWN, Connecting to OPENING and every overlay to HIDDEN; TerminalScreen passes it, still registered before the watch starts. - A terminal still connecting is neither recorded nor announced: its session's in-pane events wait (OnScreen.opening, Split.waiting, left untouched by SeenLog.claimLive) for a later listing. If the attach fails, the next listing announces them; if it succeeds, TerminalScreen calls HostSession.notePaneShown on the transition to SHOWN, which records what the pane shows of the latest listing (claimLive with notify = false: records only, no network call), so a user who looks and leaves before the next 8 s refresh is not told about it afterwards. - README, docs/android.md and the KDocs say what a terminal hides now. Verification: LiveNotificationsTest gains 5 tests (inPane and the split, a held approval of the open session announced once and not recorded while notifications are off, a connecting terminal leaving its events for a later listing, the attach recording and leaving announcing nothing, and the tracker asking the pane at every listing) plus source pins for the pane mapping, the predicate registration and notePaneShown. Mutation-checked, each fails at least one test: dropping inPane from shows; the tracker treating every pane as SHOWN; no waiting (opening events go off screen); OPENING counted as shown in the tracker; claimLive recording waiting events; registering showNode without the predicate; removing the notePaneShown call; mapping Connecting to SHOWN. Full protocol suite (301 tests) and the offline app type-check pass. Nothing has run on a device. Departures from the review's suggestion: it proposed registering showNode only while Attached. That would announce, as the user comes back to a terminal, what its pane is about to show (the first listing after a start arrives while connecting), so a connecting terminal defers instead of hiding or announcing, and the attach settles the deferral at once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 8 +- .../android/conn/ConnectionManager.kt | 18 ++- .../nodeterm/android/ui/TerminalController.kt | 14 ++ .../dev/nodeterm/android/ui/TerminalScreen.kt | 17 ++- .../dev/nodeterm/protocol/model/OnScreen.kt | 108 ++++++++++++--- .../dev/nodeterm/protocol/model/SeenLog.kt | 5 +- .../protocol/LiveNotificationsTest.kt | 128 +++++++++++++++++- docs/android.md | 9 +- 8 files changed, 269 insertions(+), 38 deletions(-) diff --git a/android/README.md b/android/README.md index be9058bc70..05f1ae2f71 100644 --- a/android/README.md +++ b/android/README.md @@ -84,8 +84,12 @@ Other paired computers are not polled while you look at one, so their notificati the background check only. Real-time push on Android needs an FCM leg in the backend. The live check leaves out what you are looking at: nothing of a computer is announced while its -Inbox tab is on screen, and nothing of a session open in a terminal. Those events count as seen, so -no later check announces them either. +Inbox tab is on screen, and nothing a session's terminal shows while it is attached. Those events +count as seen, so no later check announces them either. An approval the computer is holding for an +answer (a hook-reply approval) is still announced with its terminal open, because Claude paints that +prompt only once the hold ends. A terminal that shows an error, a relay offer or an approval code +instead of the session hides nothing, and one still connecting leaves the session's events for the +next check. Each Inbox event raises at most one notification: only events younger than 6 hours are announced, and the phone remembers the ones it has announced, you have read or you had on screen for a day diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index 51a6b0375d..edab551beb 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -11,6 +11,7 @@ import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.RelayConnector import dev.nodeterm.protocol.host.TransportKind import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.OnScreen import dev.nodeterm.protocol.model.OnScreenTracker import dev.nodeterm.protocol.model.PairedHost import dev.nodeterm.protocol.model.ProjectsSnapshot @@ -76,8 +77,9 @@ class HostSession(val hostId: String, private val graph: AppGraph) { val isWatched: Boolean get() = synchronized(this) { watchers > 0 } /** - * What of this computer is on screen: its Inbox tab, and the sessions open in a terminal. Each - * listing announces its new Inbox events except those (audit A73, see [refreshNow]). + * What of this computer is on screen: its Inbox tab, and the sessions open in a terminal, as far + * as each terminal shows its pane. Each listing announces its new Inbox events except those + * (audit A73, see [refreshNow]). */ val onScreen = OnScreenTracker() @@ -337,6 +339,18 @@ class HostSession(val hostId: String, private val graph: AppGraph) { runCatching { graph.announce(hostId, listed, onScreen.now()) } } + /** + * A terminal of this computer just attached, so the pane of [nodeId] is in front of the user (the + * A73 review). The listing that arrived while it was connecting left that session's events + * waiting ([OnScreen.opening]), and the next listing is up to 8 s away: record what the pane + * shows of the latest listing as seen now, so a user who looks and leaves before then is not told + * about it afterwards. Records only, announces nothing, and costs no network call. + */ + fun notePaneShown(nodeId: String) { + val events = _snapshot.value.status?.inbox?.events ?: return + runCatching { graph.hosts.claimLive(events, OnScreen(nodes = setOf(nodeId)), notify = false) } + } + /** A refresh the USER asked for (Refresh, Try again). */ fun refresh() { scope.launch { refreshNow(Trigger.USER) } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index ee6305f6d2..f816431256 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -45,6 +45,7 @@ import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.InputBar import dev.nodeterm.protocol.model.Keys +import dev.nodeterm.protocol.model.OnScreen import dev.nodeterm.protocol.model.Osc52 import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable @@ -90,6 +91,19 @@ class TerminalController( */ val attached: Boolean get() = state == TermState.Attached + /** + * Whether the session's pane is in front of the user, for the live notifications (audit A73 + * review): attached shows it; connecting is where every start begins, and ends in the pane or in + * an overlay; an overlay (ended, relay offer, approval code, lost view) covers it. Read by a + * refresh on a background thread: [state] is snapshot state, which any thread may read. + */ + val pane: OnScreen.Pane + get() = when (state) { + TermState.Attached -> OnScreen.Pane.SHOWN + TermState.Connecting -> OnScreen.Pane.OPENING + else -> OnScreen.Pane.HIDDEN + } + /** * Offered after an attach of an agent node: its resume line after a COLD attach (the desktop's * cold restore), or its wake line when the session is Sleeping over direct SSH (audit A76). diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 03a0aebafb..e7b7974519 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.LifecycleStartEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -47,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import dev.nodeterm.android.Navigator import dev.nodeterm.android.NodetermApp +import dev.nodeterm.protocol.model.OnScreen @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -59,11 +61,12 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String // Attached and watching only while the screen is STARTED (audit A18): in the background the // relay stream kept the desktop treating the session as watched (Eco shield, and the phone's // size as a ceiling) and kept the radio busy. Stop detaches; start reattaches. - // While it shows, this session's Inbox events are in front of the user: the live refresh records - // them as seen instead of announcing them (A73). Registered before the watch starts, so its - // first listing already knows. + // While the pane shows, this session's Inbox events are in front of the user: the live refresh + // records them as seen instead of announcing them (A73). Only while it shows (the A73 review): + // the refresh asks controller.pane each time, so an overlay over the pane, or a terminal still + // connecting, records nothing. Registered before the watch starts, so its first listing knows. LifecycleStartEffect(controller) { - val showing = session.onScreen.showNode(nodeId) + val showing = session.onScreen.showNode(nodeId) { controller.pane } session.startWatching() controller.onStart() onStopOrDispose { @@ -72,6 +75,12 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String showing.close() } } + // The pane just came on screen: what it shows of the latest listing is seen now. That listing + // arrived while the terminal was connecting and left this session's events waiting (A73 review). + val pane = controller.pane + LaunchedEffect(controller, pane) { + if (pane == OnScreen.Pane.SHOWN) session.notePaneShown(nodeId) + } DisposableEffect(controller) { onDispose { controller.dispose() } } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt index ec065931af..d0ecb8133a 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt @@ -9,33 +9,80 @@ import java.util.concurrent.atomic.AtomicBoolean * announce path as the 15-minute background check, except for what the user is looking at: * * - every event while that computer's Inbox tab is on screen (the tab is where they are listed); - * - the events of a session open in a terminal (its prompt or its finish is in front of the user). + * - the events of a session whose terminal is attached ([nodes]), because its pane shows them: the + * prompt, the question's picker, the finish. Not all of them, though ([inPane]): a held hook-reply + * approval is not painted until its hold ends. * * Those are recorded as seen instead of announced ([SeenLog.claimLive]), so no later check (the next - * refresh, or the background one) announces what the user already saw. Other computers are not - * polled at all, so nothing here makes their notifications live: theirs still come from the - * background check only. + * refresh, or the background one) announces what the user already saw. Recording is permanent, so + * this must never claim more than the screen shows (the A73 review): a session whose terminal is + * still connecting ([opening]) is neither recorded nor announced, and is decided by a later listing, + * once its pane shows or an error does instead. Other computers are not polled at all, so nothing + * here makes their notifications live: theirs still come from the background check only. */ -data class OnScreen(val inbox: Boolean = false, val nodes: Set = emptySet()) { +data class OnScreen( + val inbox: Boolean = false, + /** Sessions open in a terminal that is attached: their pane is in front of the user. */ + val nodes: Set = emptySet(), + /** Sessions open in a terminal that is still connecting: their pane, or an error, is about to show. */ + val opening: Set = emptySet() +) { /** The user is looking at [ev]. */ - fun shows(ev: InboxEvent): Boolean = inbox || ev.nodeId in nodes + fun shows(ev: InboxEvent): Boolean = inbox || (ev.nodeId in nodes && inPane(ev)) - /** [events] (a computer's feed) split into what is on screen and what is not; each keeps the feed's order. */ + /** [ev] would be in the pane of a terminal that is still connecting: not decided yet. */ + fun waitsFor(ev: InboxEvent): Boolean = !shows(ev) && ev.nodeId in opening && inPane(ev) + + /** [events] (a computer's feed) split by what is on screen; each part keeps the feed's order. */ fun split(events: List): Split { - val (shown, offScreen) = events.partition(::shows) - return Split(shown, offScreen) + val shown = ArrayList() + val offScreen = ArrayList() + val waiting = ArrayList() + for (ev in events) { + when { + shows(ev) -> shown += ev + waitsFor(ev) -> waiting += ev + else -> offScreen += ev + } + } + return Split(shown, offScreen, waiting) } data class Split( /** On screen: recorded as seen, never announced. */ val shown: List, /** Not on screen: announced when new ([SeenLog.claimLive] decides). */ - val offScreen: List + val offScreen: List, + /** In the pane of a terminal still connecting: left alone, for a later listing to decide. */ + val waiting: List = emptyList() ) + /** Where a session open in a terminal stands, for [OnScreenTracker.showNode]. */ + enum class Pane { + /** Attached: the pane is in front of the user. */ + SHOWN, + /** Connecting: the pane, or an error instead, is about to show. */ + OPENING, + /** Something else covers the pane (the session ended, an offer, a code to approve, a lost view). */ + HIDDEN + } + companion object { /** Nothing of this computer on screen: the app is in the background, or shows something else. */ val NOTHING = OnScreen() + + /** + * Whether [ev] is painted in its session's pane. Every event is, except a held hook-reply + * approval (it carries a `pendingId`): Claude Code applies the hook's decision before it ever + * paints the prompt, and paints it only once the hold ends, 45 s by default + * (docs/hook-reply-approvals.md; [dev.nodeterm.protocol.host.QuickActions] answers such an + * approval with its ticket, never with keys, for the same reason). A subagent's concurrent + * approval is one too, published on the parent node while its pane shows the parent's + * question. The ticket stays on the event after the hold ends, when the prompt is painted, so + * such an approval may be announced although it is on screen by then: a notification too many + * beats one lost. + */ + fun inPane(ev: InboxEvent): Boolean = !(ev.kind == InboxKind.APPROVAL && ev.pendingId != null) } } @@ -47,11 +94,30 @@ data class OnScreen(val inbox: Boolean = false, val nodes: Set = emptySe */ class OnScreenTracker { private var inboxes = 0 - private val nodes = HashMap() + private val terminals = ArrayList() - /** What is on screen at this moment. */ + private class Terminal(val nodeId: String, val pane: () -> OnScreen.Pane) + + /** + * What is on screen at this moment. A session is in [OnScreen.nodes] when any of its terminals is + * [OnScreen.Pane.SHOWN], else in [OnScreen.opening] when one is [OnScreen.Pane.OPENING]. + */ @Synchronized - fun now(): OnScreen = if (inboxes == 0 && nodes.isEmpty()) OnScreen.NOTHING else OnScreen(inboxes > 0, nodes.keys.toSet()) + fun now(): OnScreen { + if (inboxes == 0 && terminals.isEmpty()) return OnScreen.NOTHING + val shown = HashSet() + val opening = HashSet() + for (t in terminals) { + when (t.pane()) { + OnScreen.Pane.SHOWN -> shown += t.nodeId + OnScreen.Pane.OPENING -> opening += t.nodeId + OnScreen.Pane.HIDDEN -> Unit + } + } + opening -= shown + return if (inboxes == 0 && shown.isEmpty() && opening.isEmpty()) OnScreen.NOTHING + else OnScreen(inboxes > 0, shown, opening) + } /** The computer's Inbox tab is on screen until the handle is closed. */ fun showInbox(): AutoCloseable { @@ -59,13 +125,15 @@ class OnScreenTracker { return handle { inboxes-- } } - /** The session [nodeId] is open in a terminal until the handle is closed. */ - fun showNode(nodeId: String): AutoCloseable { - synchronized(this) { nodes[nodeId] = (nodes[nodeId] ?: 0) + 1 } - return handle { - val left = (nodes[nodeId] ?: 0) - 1 - if (left > 0) nodes[nodeId] = left else nodes.remove(nodeId) - } + /** + * The session [nodeId] is open in a terminal until the handle is closed; [pane] says, whenever a + * listing asks, whether that terminal shows its pane. It is called from the refresh's thread under + * this tracker's lock, so it must only read state. Without it the terminal counts as attached. + */ + fun showNode(nodeId: String, pane: () -> OnScreen.Pane = { OnScreen.Pane.SHOWN }): AutoCloseable { + val t = Terminal(nodeId, pane) + synchronized(this) { terminals += t } + return handle { terminals.remove(t) } } private fun handle(release: () -> Unit): AutoCloseable { diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt index 2451a6643a..70d9d5541e 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt @@ -66,8 +66,9 @@ class SeenLog( * as announced in the same locked step (audit A73). What [onScreen] shows is recorded as seen and * never returned, so no later check (the next refresh, or the background one) announces what the * user already looked at; that happens even when [notify] is false (notifications off, or not - * allowed by the system), so turning them on later does not announce it either. The rest is - * [claimAnnounceable]'s answer when [notify], and left untouched otherwise. + * allowed by the system), so turning them on later does not announce it either. What waits on a + * terminal still connecting ([OnScreen.Split.waiting]) is left untouched, for a later listing to + * decide. The rest is [claimAnnounceable]'s answer when [notify], and left untouched otherwise. * * Only events that could still be announced are recorded, an entry already recorded is left as it * is, and nothing is written when nothing is new: the live check runs every 8 s. Leaving an diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt index ce81abe86a..78b840aa64 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt @@ -17,8 +17,11 @@ import kotlin.test.assertTrue * 15-minute background check ever posted one; the in-app refresh only updated the listing. Now every * fresh listing of a computer runs the one announce path, so notifications are live for the computer * whose screen is open, minus what the user is looking at: every event while that computer's Inbox - * tab is on screen, and the events of the session open in a terminal. Those are recorded as seen, so - * no later check announces them. Other computers are not polled, and the copy now says so. + * tab is on screen, and what the pane of a session attached in a terminal shows. Those are recorded as + * seen, so no later check announces them. Other computers are not polled, and the copy now says so. + * The review of A73 narrowed "a terminal is open" to what its pane shows: not a held hook-reply + * approval (its prompt is not painted while held), nothing while an overlay covers the pane, and + * nothing decided yet while the terminal is still connecting. * * The decision ([OnScreen], [SeenLog.claimLive]) and the screen bookkeeping ([OnScreenTracker]) are * pure and tested here. The wiring (the listing announces, the screens register while started, the @@ -50,12 +53,16 @@ class LiveNotificationsTest { nodeId: String = "n1", ts: Long = t0, kind: InboxKind = InboxKind.APPROVAL, - resolved: Boolean = false + resolved: Boolean = false, + pendingId: String? = null ) = InboxEvent( id = id, ts = ts, nodeId = nodeId, agentId = "claude", sessionId = null, kind = kind, title = "t", - detail = null, interrupted = false, resolved = resolved, options = emptyList(), multiSelect = false, pendingId = null + detail = null, interrupted = false, resolved = resolved, options = emptyList(), multiSelect = false, pendingId = pendingId ) + /** A held hook-reply approval: it carries its ticket, and its prompt is not painted while held. */ + private fun held(id: String, nodeId: String = "n1", ts: Long = t0) = ev(id, nodeId, ts, pendingId = "$nodeId-$ts-1") + private fun ids(events: List) = events.map { it.id } // --- What is on screen --------------------------------------------------------------------- @@ -87,6 +94,75 @@ class LiveNotificationsTest { assertFalse(OnScreen(nodes = setOf("open")).shows(ev("x", "other"))) } + @Test + fun `a held approval is not in its session's pane, so an open terminal does not hide it`() { + // docs/hook-reply-approvals.md: Claude applies the hook's decision before it paints the prompt, + // so while the hook holds the request the pane shows no prompt (a subagent's concurrent + // approval rides the parent node, whose pane shows the parent's question). + assertFalse(OnScreen.inPane(held("h"))) + assertTrue(OnScreen.inPane(ev("keyed")), "an approval with no ticket is a prompt in the pane") + assertTrue(OnScreen.inPane(ev("q", kind = InboxKind.QUESTION))) + assertTrue(OnScreen.inPane(ev("d", kind = InboxKind.DONE))) + val onScreen = OnScreen(nodes = setOf("open")) + val feed = listOf(held("h", "open"), ev("keyed", "open"), ev("q", "open", kind = InboxKind.QUESTION), ev("d", "open", kind = InboxKind.DONE)) + val split = onScreen.split(feed) + assertEquals(listOf("keyed", "q", "d"), ids(split.shown)) + assertEquals(listOf("h"), ids(split.offScreen)) + // The Inbox tab lists it as a card with its buttons: there it IS on screen. + assertTrue(OnScreen(inbox = true).shows(held("h", "open"))) + } + + @Test + fun `a held approval of the open session is announced, and not recorded as seen until it is`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val onScreen = OnScreen(nodes = setOf("open")) + val feed = listOf(held("h", "open"), ev("keyed", "open")) + // Notifications off: the prompt in the pane is recorded, the held approval is not spent. + assertEquals(emptyList(), log.claimLive(feed, onScreen, notify = false)) + assertTrue(log.isSeen("keyed")) + assertFalse(log.isSeen("h"), "a held approval nobody could see was recorded as seen") + // Notifications on: announced once, whether the terminal is still open or the user left it. + clock.now += 8_000 + assertEquals(listOf("h"), ids(log.claimLive(feed, onScreen, notify = true))) + clock.now += 8_000 + assertEquals(emptyList(), log.claimLive(feed, onScreen, notify = true)) + assertEquals(emptyList(), log.claimAnnounceable(feed)) + } + + @Test + fun `a terminal still connecting leaves its session's events for a later listing`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + val opening = OnScreen(opening = setOf("open")) + val feed = listOf(ev("q", "open", kind = InboxKind.QUESTION), held("h", "open"), ev("other", "n2")) + val split = opening.split(feed) + assertEquals(listOf("q"), ids(split.waiting)) + assertEquals(listOf("h", "other"), ids(split.offScreen), "a held approval is not in the pane about to show") + assertEquals(emptyList(), split.shown) + assertEquals(listOf("h", "other"), ids(log.claimLive(feed, opening, notify = true))) + assertFalse(log.isSeen("q"), "a pane that never showed was recorded as seen") + // The attach failed and an overlay covers the pane: the next listing announces it. + clock.now += 8_000 + assertEquals(listOf("q"), ids(log.claimLive(feed, OnScreen.NOTHING, notify = true))) + } + + @Test + fun `once the terminal attaches, its pane is recorded from the latest listing, and leaving announces nothing`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val feed = listOf(ev("d", "open", kind = InboxKind.DONE), held("h", "open")) + assertEquals(listOf("h"), ids(log.claimLive(feed, OnScreen(opening = setOf("open")), notify = true))) + // HostSession.notePaneShown, on the attach: records what the pane shows, announces nothing. + assertEquals(emptyList(), log.claimLive(feed, OnScreen(nodes = setOf("open")), notify = false)) + assertTrue(log.isSeen("d")) + // The user looked and left before the next refresh: nothing about it afterwards. + clock.now += 3_000 + assertEquals(emptyList(), log.claimLive(feed, OnScreen.NOTHING, notify = true)) + assertEquals(emptyList(), log.claimAnnounceable(feed)) + } + // --- One check of a fresh listing ----------------------------------------------------------- @Test @@ -210,6 +286,26 @@ class LiveNotificationsTest { assertEquals(setOf("n1"), seen.nodes) } + @Test + fun `a terminal counts only while its pane shows, and the tracker asks it at every listing`() { + val tracker = OnScreenTracker() + var pane = OnScreen.Pane.OPENING + val h = tracker.showNode("n1") { pane } + assertEquals(OnScreen(opening = setOf("n1")), tracker.now()) + pane = OnScreen.Pane.SHOWN + assertEquals(OnScreen(nodes = setOf("n1")), tracker.now()) + pane = OnScreen.Pane.HIDDEN // an overlay over the pane: the session ended, an offer, a lost view + assertSame(OnScreen.NOTHING, tracker.now()) + // Two terminals of one session: the one that shows it wins over the one still connecting. + val other = tracker.showNode("n1") { OnScreen.Pane.SHOWN } + pane = OnScreen.Pane.OPENING + assertEquals(OnScreen(nodes = setOf("n1")), tracker.now()) + other.close() + assertEquals(OnScreen(opening = setOf("n1")), tracker.now()) + h.close() + assertSame(OnScreen.NOTHING, tracker.now()) + } + // --- The wiring, pinned in the app's source ----------------------------------------------------- private val connections get() = AppSourcePins.app("conn/ConnectionManager.kt") @@ -268,14 +364,34 @@ class LiveNotificationsTest { "onStopOrDispose { showing.close() }" ) // Registered before the watch starts, so the first listing already knows; closed after it stops. + // The terminal says at every listing whether its pane shows (the A73 review). + val terminal = AppSourcePins.ui("TerminalScreen.kt") AppSourcePins.assertInOrder( - AppSourcePins.blockAfter(AppSourcePins.ui("TerminalScreen.kt"), "LifecycleStartEffect(controller)"), - "val showing = session.onScreen.showNode(nodeId)", + AppSourcePins.blockAfter(terminal, "LifecycleStartEffect(controller)"), + "val showing = session.onScreen.showNode(nodeId) { controller.pane }", "session.startWatching()", "onStopOrDispose {", "session.stopWatching()", "showing.close()" ) + // Attached shows the pane, connecting is about to, every overlay covers it. + val pane = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "val pane: OnScreen.Pane") + AppSourcePins.assertInOrder( + pane, + "TermState.Attached -> OnScreen.Pane.SHOWN", + "TermState.Connecting -> OnScreen.Pane.OPENING", + "else -> OnScreen.Pane.HIDDEN" + ) + // On the attach, what the pane shows of the latest listing is recorded, and nothing announced. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(terminal, "LaunchedEffect(controller, pane)"), + "if (pane == OnScreen.Pane.SHOWN) session.notePaneShown(nodeId)" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(connections, "fun notePaneShown(nodeId: String)"), + "_snapshot.value.status?.inbox?.events", + "graph.hosts.claimLive(events, OnScreen(nodes = setOf(nodeId)), notify = false)" + ) } @Test diff --git a/docs/android.md b/docs/android.md index 524ec83cb9..ba2b98d776 100644 --- a/docs/android.md +++ b/docs/android.md @@ -268,8 +268,13 @@ in-app refresh only updated the listing. Now every listing that arrives (the 8 s computer on screen, a change the computer pushes, the background check) runs the one announce path, so notifications are live for the computer whose screen is open. What the user is looking at is left out and recorded as seen instead, so no later check announces it: every event while that computer's -Inbox tab is on screen, and the events of a session open in a terminal. That is recorded even with -notifications off, so turning them on later does not announce it either. Other computers are not +Inbox tab is on screen, and what a session's terminal shows while it is attached. That is recorded +even with notifications off, so turning them on later does not announce it either. Recording is +permanent, so it must never claim more than the screen shows (the review of A73): a held hook-reply +approval (it carries a `pendingId`) is announced with its terminal open, because Claude paints that +prompt only once the hold ends; a terminal showing an overlay instead of its pane (ended, relay +offer, approval code, lost view) hides nothing; and one still connecting leaves its session's events +for a later listing, which the attach settles at once from the latest listing. Other computers are not polled while one is open, so theirs still come only from the background check, and the Settings text, the README and the notifier's comment now say exactly that. It costs no network call (the listing already arrived), and the check writes the phone's seen-log only when something is new. The From e0a7883ae9550ec04437b167bc6184d22523dc57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:27:17 +0000 Subject: [PATCH 071/284] fix(android): address review of A37 ac0923a said in five places that a missing -dontwarn OR -keep fails CI (docs/android.md, android/README.md, the release block of app/build.gradle.kts, the header of proguard-rules.pro and the app-release comment in android.yml). Only the first half is true. R8 fails on a missing class, so a missing -dontwarn does fail the release build in CI. It never reports a missing keep: it renames or drops the code it cannot see used. tools/check-r8-output.sh then checks only the targets it names (the bridge methods read from TerminalController.kt, the InboxWorker constructor, and the names of BouncyCastleProvider, AES$Mappings, the X25519 KeyAgreementSpi and HostException), and R8RulesTest only asks for a keep per WorkManager worker, the bridge keep and the Throwable keepnames. A new Class.forName target, or a class a new library loads from a string, still builds green without its keep. The body of proguard-rules.pro already said this; the headline sentences contradicted it. What changed (comments and docs only): - The five sites now say: a missing -dontwarn fails CI; the keeps the script names are checked to have matched; a keep that new reflection needs is not detected and needs its own keep plus a line in tools/check-r8-output.sh. - proguard-rules.pro "What is verified": check-r8-output.sh verifies the keeps it names, not "the runtime keeps below" (it checks nothing about the sshj and EdDSA keeps), and R8RulesTest scans the jars the protocol module ships to the app, not every jar the app ships. docs/android.md had the same overstatement about R8RulesTest. - check-r8-output.sh header: says it checks only the targets it names, and what a new name-loaded target needs. How it was verified: read check-r8-output.sh and R8RulesTest against every claim. Full protocol suite (301 tests, including R8RulesTest, which reads proguard-rules.pro, the release block and android.yml, and LiveNotificationsTest, which reads android/README.md) and the offline app type-check pass. sh -n on the script; android.yml still parses as YAML. No regression test: the change is prose, and a regex over prose would pin wording, not behaviour. No mutation check therefore applies. Not done here: docs/android-handover.md still says "Release/minified builds. R8 is off; see A37." under "Not verified". That line is stale since ac0923a (R8 now runs for release in CI; no minified APK has run on a device). The handover is the orchestrator's file, so it is left for that batch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 11 +++++++---- android/README.md | 9 ++++++--- android/app/build.gradle.kts | 7 ++++--- android/app/proguard-rules.pro | 17 +++++++++++------ android/tools/check-r8-output.sh | 4 ++++ docs/android.md | 15 ++++++++++----- 6 files changed, 42 insertions(+), 21 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index cd3728defd..17df99dc6e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -80,10 +80,13 @@ jobs: path: android/app/build/outputs/apk/debug/*.apk # R8 runs only for the release build type, and the debug APK above is the one distributed. Building - # the release here (unsigned; no signing secrets exist or are needed) makes a missing -dontwarn or - # -keep in app/proguard-rules.pro fail CI instead of the first real release (audit A37). A separate - # job so an R8 failure never withholds the debug APK. Green proves the build and that the runtime - # keeps matched (tools/check-r8-output.sh); it does not prove reflection-loaded code works on a phone. + # the release here (unsigned; no signing secrets exist or are needed) makes a missing -dontwarn in + # app/proguard-rules.pro fail CI instead of the first real release (audit A37): R8 reports the missing + # class. It reports no missing -keep, so tools/check-r8-output.sh then checks that the keeps it names + # matched (the WebView bridge, the worker, BouncyCastle's provider tables, one exception name). A NEW + # reflection target without a keep is not detected: it needs its own keep and a line in that script. + # A separate job so an R8 failure never withholds the debug APK. Green does not prove reflection-loaded + # code works on a phone. app-release: name: App release (R8, unsigned) runs-on: ubuntu-latest diff --git a/android/README.md b/android/README.md index 05f1ae2f71..97242aca53 100644 --- a/android/README.md +++ b/android/README.md @@ -46,9 +46,12 @@ Needs JDK 17+ and the Android SDK (Android Studio's, or `ANDROID_HOME`). `minSdk CI builds the debug APK on every change under `android/` (`.github/workflows/android.yml`) and attaches it to the run. The debug APK is not minified. Only the release build type runs R8 (`app/proguard-rules.pro`), and CI builds it too, unsigned and not published -(`./gradlew :app:assembleRelease`, then `tools/check-r8-output.sh`). A missing R8 rule therefore -fails CI rather than a first release. That proves the rules build and that their keeps matched, -not that a minified APK works on a phone; none has been run on one. +(`./gradlew :app:assembleRelease`, then `tools/check-r8-output.sh`). A missing `-dontwarn` therefore +fails CI rather than a first release (R8 reports the missing class), and so does one of the keeps the +script checks when it stops matching (the WebView bridge, the worker, BouncyCastle's provider tables, +one exception name). A keep that NEW reflection needs is not detected, because R8 renames or drops +such code without a word; add the keep and a line in `tools/check-r8-output.sh`. None of this proves +a minified APK works on a phone; none has been run on one. **Debug builds are signed with a public key that is committed on purpose** (`app/debug.keystore`, password `android`). That is what lets you install a newer CI or local diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7b2561de62..cd590ac134 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -39,9 +39,10 @@ android { } release { // R8 (shrink + obfuscate) runs for release only; debug stays unminified and is the APK that - // is distributed. CI builds this unsigned release on every change so a missing rule fails CI - // (audit A37). That proves R8 accepts proguard-rules.pro and that its keeps matched - // (tools/check-r8-output.sh); it does NOT prove the reflection-loaded code (BouncyCastle's + // is distributed. CI builds this unsigned release on every change (audit A37), so a missing + // -dontwarn fails CI (R8 reports the missing class), and tools/check-r8-output.sh checks that + // the keeps it names matched. A keep that NEW reflection needs is not detected: R8 renames or + // drops such code silently. None of it proves the reflection-loaded code (BouncyCastle's // provider tables, the WebView bridge, the WorkManager worker) works on a device, which // nobody has run a release build on. There is no release signing config yet. isMinifyEnabled = true diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 3c6296c18a..54fa1e0b10 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,15 +1,20 @@ # R8 rules for the RELEASE build type (audit A37). Debug is not minified, and the debug APK is the # one distributed. CI builds an unsigned release APK (`:app:assembleRelease`) on every change, so R8 -# runs against these rules and a missing one fails CI instead of the first real release. +# runs against these rules: a missing -dontwarn fails CI instead of the first real release. A missing +# -keep does not fail R8: it renames or drops code it cannot see used and reports nothing. So code +# reached by a NEW name (a new Class.forName, a class a new library loads from a string) needs its own +# keep here AND a line in tools/check-r8-output.sh, or CI stays green without it. # # What is verified, and by what: # - R8 finishing in CI: the build accepts these rules (no missing-class errors). Nothing more. -# - tools/check-r8-output.sh (CI, after that build): the runtime keeps below MATCHED. The bridge -# methods, the worker, the BouncyCastle provider tables and an exception class are in R8's seeds -# and keep their names in its mapping. A keep whose class was renamed matches nothing, silently. +# - tools/check-r8-output.sh (CI, after that build): the keeps it names MATCHED. The bridge methods, +# the worker, the BouncyCastle provider tables and an exception class are in R8's seeds and keep +# their names in its mapping. A keep whose class was renamed matches nothing, silently. It checks +# nothing about the sshj and EdDSA keeps, nor about any target it does not name. # - R8RulesTest (android/protocol, runs without AGP): re-derives the missing classes from the jars -# the app ships and checks every one is covered below or by the library's own consumer rules, and -# pins the runtime keeps against the app's sources. +# the protocol module ships to the app and checks every one is covered below or by the library's +# own consumer rules, and pins the bridge, worker and exception-name keeps against the app's +# sources (a new WorkManager worker without its keep fails it). # - NOT verified: that the reflection-loaded code works on a device. The release APK has never run # on one. R8 passing proves the build, not the runtime. # diff --git a/android/tools/check-r8-output.sh b/android/tools/check-r8-output.sh index 921554f96c..8538dade68 100755 --- a/android/tools/check-r8-output.sh +++ b/android/tools/check-r8-output.sh @@ -8,6 +8,10 @@ # nothing and R8 does not say so, so this checks the keeps MATCHED what they exist for: # seeds.txt every class and member a keep rule pinned, under its original name; # mapping.txt "original -> output:" for every class that survived. +# It checks only the targets named below (bridge methods are read from the source, so a new one is +# covered). A NEW class or member reached by name needs its keep in proguard-rules.pro, which nothing +# in CI asks for (except for a WorkManager worker: R8RulesTest does), and a line here so CI checks +# that the keep matched. # It does not prove that the reflection-loaded code works on a device; nothing here runs the APK. set -eu diff --git a/docs/android.md b/docs/android.md index ba2b98d776..fc6ce50305 100644 --- a/docs/android.md +++ b/docs/android.md @@ -116,11 +116,16 @@ unchanged. The **app** module is built by CI (`.github/workflows/android.yml`) against the runner's Android SDK. The first run, [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730) on `2f58918`, built the debug APK successfully. CI also builds an unsigned release APK, the only build -type R8 minifies (audit `A37`). A missing `-dontwarn` or `-keep` then fails CI instead of the first -release, and `tools/check-r8-output.sh` checks that the keeps for reflection-loaded code (the WebView -bridge, the WorkManager worker, BouncyCastle's provider tables) matched. `R8RulesTest` re-derives the -classes Android lacks from the jars the app ships. The debug APK stays unminified and is the one -distributed. The app has no instrumented tests and has **not been run on a device**, minified or not. An audit of the code found release blockers; the fixed ones are marked in its +type R8 minifies (audit `A37`). A missing `-dontwarn` then fails CI instead of the first release, +because R8 reports the missing class. `tools/check-r8-output.sh` then checks that these existing +keeps for code reached by name matched: the WebView bridge's methods, the WorkManager worker's constructor, +BouncyCastle's provider tables, and one exception class's name (error text can fall back to it). A +missing `-keep` is otherwise not detected: R8 renames or drops code it cannot see used and reports +nothing, so a new reflection or name-dependent target (a new `Class.forName`, a class a library loads +from a string) builds green without its keep. It needs its own keep and a line in +`tools/check-r8-output.sh`. `R8RulesTest` re-derives the classes Android lacks from the jars the +protocol module ships to the app, and requires a keep for every WorkManager worker in the app sources. +The debug APK stays unminified and is the one distributed. The app has no instrumented tests and has **not been run on a device**, minified or not. An audit of the code found release blockers; the fixed ones are marked in its index, and the rest are open: [`android-audit-2026-09.md`](android-audit-2026-09.md). The plan, the device checklist and the decisions still open are in [`android-handover.md`](android-handover.md). From 46b0897e4a2fb2a0584cb1b5e64f1a61b684bb4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:45:42 +0000 Subject: [PATCH 072/284] fix(android): review follow-ups for A44, A45, A51, A52, A73 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A45 (terminal renderer recovery): - The kill-loop window ran on System.currentTimeMillis(); a wall clock set back or forward stretched or cleared it. The screen now passes SystemClock.elapsedRealtime(), and RendererRecovery documents that `now` must be monotonic. - Kills while the screen is stopped counted against the 2-per-60 s budget, although the waived priority makes them expected. onGone takes `visible`; a kill of a not-visible screen is Reattach and not counted (onStart reattaches). A crash is still offered. - Every loss rebuilt the WebView at once (webViewKey++). After an Offer the screen is ViewLost, which is SETTLED, so a renderer that died as its page loaded was rebuilt and lost via Keep with no bound (the same held for an Ended/relay answer). The controller now builds the replacement at once only for an automatic reattach of a visible screen (the bounded case); otherwise `hasWebView` is false and the screen composes no WebView until the next attach is asked for (attachWhenPageReady: Reopen terminal, Reattach, Open through the relay, a pending auto-reattach, or onStart). Nothing is built while stopped. - setRendererPriorityPolicy: RENDERER_PRIORITY_BOUND also bound the VISIBLE renderer below WebView's default IMPORTANT. Now RENDERER_PRIORITY_IMPORTANT with waivedWhenNotVisible = true: the same waiver in the background, the default priority in front of the user. A44 (Settings): - A notification tap (replaceAll) or a pairing link (push) disposed Settings without leave(), losing the edits. leave() and a new DisposableEffect onDispose share save(); the dispose path stores an accepted, changed address (and the name) with no Toast. After leave() it finds nothing to change and shows no second message. - The inline error followed the leave decision, so an unusable address an older build stored (Keep) was never flagged. ApiBaseSetting .fieldError() decides by what the field holds now: "it will not be saved" for a refused edit, "the relay can't be reached with it" for the stored one. Still no Toast when unchanged. A52: the README's lock-screen sentence now names "Completed — " for finished/interrupted turns; kindLabel's KDoc no longer claims the chip's exact words; the "Show details" switch is enabled on notify && canPost, like the Notifications switch reads. A73: gated, not softened. A change the computer pushes is re-listed (and so announced) only while a screen shows that computer, as the reconnect already was. The connection outlives the screen until it drops or the background check closes it, so pushes kept announcing live for a computer the user had left, which the Settings text does not promise. What such a push carried is not recorded as seen, so the background check announces it. README, the notifier KDoc, refreshNow's KDoc and docs/android.md say so. A51: README and docs/android.md qualify the device-id claims: from this build on a new id is minted with a re-created key; a phone whose key an older build already replaced keeps its id until reinstalled. Verified: full protocol suite (gradle test --offline, 309 tests, 8 new) and the offline app type-check (gradle compileKotlin --offline) pass. Mutation-checked 16 mutations, each killed by the intended test: removing the visible gate, counting background kills, reattaching a background crash, fieldError by the leave decision or with one message, the wall clock, not passing visibility, rebuilding at once for an offer, never rebuilding on ask, composing the WebView regardless of hasWebView, RENDERER_PRIORITY_BOUND, no dispose save, a Toast on dispose, the old field-error wiring, the details switch on notify alone, and the ungated push. The controller, screens and connection manager are only type-checked and pinned in the source; nothing has run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 16 ++-- .../android/conn/ConnectionManager.kt | 12 ++- .../nodeterm/android/notify/InboxNotifier.kt | 9 +- .../dev/nodeterm/android/ui/SettingsScreen.kt | 32 +++++-- .../nodeterm/android/ui/TerminalController.kt | 47 ++++++++-- .../dev/nodeterm/android/ui/TerminalScreen.kt | 7 +- .../nodeterm/protocol/host/TerminalPage.kt | 25 ++++- .../protocol/model/InboxNotificationText.kt | 5 +- .../nodeterm/protocol/relay/ApiBaseSetting.kt | 20 +++- .../protocol/InboxNotificationTextTest.kt | 4 + .../protocol/LiveNotificationsTest.kt | 11 +++ .../nodeterm/protocol/SettingsLeaveTest.kt | 51 ++++++++--- .../dev/nodeterm/protocol/TerminalPageTest.kt | 91 +++++++++++++++++-- docs/android.md | 54 ++++++----- 14 files changed, 302 insertions(+), 82 deletions(-) diff --git a/android/README.md b/android/README.md index 97242aca53..15cab15b19 100644 --- a/android/README.md +++ b/android/README.md @@ -84,7 +84,9 @@ The iOS app is woken by APNs pushes the nodeterm backend sends. That backend has leg, so this app polls instead: checked about every 15 minutes in the background (WorkManager's floor), and live for the computer whose screen is open, which the app re-lists every 8 seconds. Other paired computers are not polled while you look at one, so their notifications still come from -the background check only. Real-time push on Android needs an FCM leg in the backend. +the background check only. That includes a computer you just left: the app may still hold its +connection for a while, but no longer re-lists it when it pushes a change. Real-time push on Android +needs an FCM leg in the backend. The live check leaves out what you are looking at: nothing of a computer is announced while its Inbox tab is on screen, and nothing a session's terminal shows while it is attached. Those events @@ -103,8 +105,9 @@ A notification names the session, the computer and the kind of event ("Needs you "Needs approval"), but not the event's own text: the command, file or question, or the agent's last message. Android shows a notification's full content on a secure lock screen unless you hide sensitive content there. **Settings → Show details in notifications** (off by default) adds that -text. A lock screen set to hide sensitive content shows only "Needs you — " and the -computer's name either way. The iOS app does receive the detail, in the push the desktop sends. +text. A lock screen set to hide sensitive content shows only the title ("Needs you — ", or +"Completed — " for a finished or interrupted turn) and the computer's name either way. The +iOS app does receive the detail, in the push the desktop sends. ## Security @@ -114,9 +117,10 @@ computer's name either way. The iOS app does receive the detail, in the push the - Nothing of the app's goes into a backup or a phone-to-phone transfer. `allowBackup="false"` stops cloud backup, and the manifest's data extraction rules stop the Android 12+ device-to-device transfer, which ignores `allowBackup`. A new phone starts unpaired; pair it again. -- The relay device id goes with the relay key: if the key ever has to be created again (it was - lost), a new device id is minted with it, so the phone never presents an old device id with new - keys. +- The relay device id goes with the relay key: from this build on, if the key ever has to be + created again (it was lost), a new device id is minted with it, so the phone does not present an + old device id with a new key. A phone whose key an older build already replaced keeps its device + id until the app is reinstalled. - Relay traffic is end-to-end encrypted (NaCl box under a per-session HKDF key) and checked exactly as the desktop checks it: role byte (no reflections), strictly increasing sequence numbers (no replays), no re-key once ready, and the host key pinned from pairing. diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index edab551beb..1a8d220319 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -269,7 +269,12 @@ class HostSession(val hostId: String, private val graph: AppGraph) { } } } - c.setOnChanged { scope.launch { refreshNow() } } + // A change the computer pushes is re-listed, and so announced, only while a screen shows this + // computer, like the reconnect above (the review of A73). The connection outlives that screen + // (until it drops or the background check closes it), and its pushes used to go on announcing + // live for a computer the user had left, which the Settings text does not promise. What such a + // push carried is not recorded as seen, so the background check announces it. + c.setOnChanged { if (isWatched) scope.launch { refreshNow() } } } private fun pinFor(host: PairedHost) = object : HostKeyPin { @@ -313,9 +318,10 @@ class HostSession(val hostId: String, private val graph: AppGraph) { * [Trigger.BACKGROUND] one never makes a first relay handshake (see [RelayApprovalGate]). * * A listing that arrives announces its new Inbox events, minus what [onScreen] shows (audit A73): - * the 8 s poll of the computer on screen, a change the computer pushed, and the background check + * the 8 s poll of the computer on screen, a change that computer pushed, and the background check * all come through here, so notifications are live for the computer whose screen is open. Other - * computers are not re-listed, so theirs wait for the background check. + * computers are not re-listed, not even on a push over a connection still open from a screen the + * user left ([adopt]), so theirs wait for the background check. */ suspend fun refreshNow(trigger: Trigger = Trigger.AUTO) { val listed = try { diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt index a34d3c33bc..58b53eed12 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -39,10 +39,11 @@ import java.util.concurrent.TimeUnit * instead, honestly: checked about every 15 minutes in the background, and live for the computer * whose screen is open. Both are the same [announce], run on every fresh listing of a computer * (HostSession.refreshNow): the periodic WorkManager check (Android's floor is 15 minutes) lists - * each paired computer, and the app re-lists the computer on screen every 8 s (audit A73). Other - * computers are not polled, so their notifications come from the background check only. What the - * user is looking at ([OnScreen]) is recorded as seen instead of announced. A real push leg is - * backend work. + * each paired computer, and the app re-lists the computer on screen every 8 s, and when it pushes a + * change (audit A73). Other computers are not polled, and a change one pushes over a connection still + * open from a screen the user left is not re-listed, so their notifications come from the background + * check only. What the user is looking at ([OnScreen]) is recorded as seen instead of announced. A + * real push leg is backend work. */ object InboxNotifier { private const val CH_ATTENTION = "attention" diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt index 01e3231db9..afbb1a11e2 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -89,22 +90,35 @@ fun SettingsScreen(nav: Navigator) { // https is not stored, and the user is told so rather than finding the old one still in place. // Leaving without an edit stores nothing. While nothing is stored, the name follows the phone's // model and the address the build's default relay; storing either would pin it. - fun leave() { + fun save(onRejected: () -> Unit) { if (name.trim() != graph.hosts.deviceName) graph.hosts.deviceName = name when (val edit = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase)) { is ApiBaseSetting.OnLeave.Save -> graph.hosts.apiBase = edit.value ApiBaseSetting.OnLeave.UseDefault -> graph.hosts.useDefaultApiBase() ApiBaseSetting.OnLeave.Keep -> {} - ApiBaseSetting.OnLeave.Rejected -> Toast.makeText( + ApiBaseSetting.OnLeave.Rejected -> onRejected() + } + } + fun leave() { + save(onRejected = { + Toast.makeText( context, "Relay API not saved: it must be a full https:// address.", Toast.LENGTH_LONG ).show() - } + }) nav.pop() } // Registered after AppContent's, so it runs instead of that plain pop while Settings shows. BackHandler(enabled = nav.size > 1) { leave() } + // Settings also leaves the screen without a back (the review of A44): a notification tap replaces + // the whole stack, and a pairing link pushes its screen on top. The edits are stored then too, but + // silently: a refused address is not stored, and the user is not on this screen to be told. After + // leave() this finds nothing left to change, and no second message. A recreation of the + // activity (a rotation) stores what is typed by then as well; the new screen reads it back. + DisposableEffect(Unit) { + onDispose { save(onRejected = {}) } + } Scaffold( topBar = { @@ -177,7 +191,8 @@ fun SettingsScreen(nav: Navigator) { color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = notifyDetails, enabled = notify, onCheckedChange = { + // Enabled like the Notifications switch reads: on only while notifications can show. + Switch(checked = notifyDetails, enabled = notify && canPost, onCheckedChange = { notifyDetails = it graph.hosts.notificationDetails = it }) @@ -225,15 +240,14 @@ fun SettingsScreen(nav: Navigator) { HorizontalDivider() Text("Advanced", style = MaterialTheme.typography.titleMedium) - val apiBaseRejected = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase) == ApiBaseSetting.OnLeave.Rejected + // By what the field holds now, so an unusable address an older build stored is flagged too. + val apiBaseError = ApiBaseSetting.fieldError(apiBase, graph.hosts.apiBase) OutlinedTextField( value = apiBase, onValueChange = { apiBase = it.trim() }, label = { Text("Relay API (https)") }, - isError = apiBaseRejected, - supportingText = if (apiBaseRejected) { - { Text("Not a full https:// address: it will not be saved.") } - } else null, + isError = apiBaseError != null, + supportingText = apiBaseError?.let { error -> { Text(error) } }, singleLine = true, modifier = Modifier.fillMaxWidth() ) diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index f816431256..1dec3297d7 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -12,6 +12,7 @@ import android.graphics.Color import android.net.Uri import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.os.TransactionTooLargeException import android.util.Base64 import android.view.Choreographer @@ -65,8 +66,8 @@ sealed interface TermState { /** Opening through the relay for the first time: the computer shows this code to approve. */ data class AwaitingApproval(val sas: String) : TermState /** - * The terminal view's renderer crashed, or kept being killed (audit A45). A new view has been - * built; "Reopen terminal" reattaches to it. Not automatic: see [RendererRecovery]. + * The terminal view's renderer crashed, or kept being killed (audit A45). "Reopen terminal" builds + * a new view and reattaches to it. Not automatic: see [RendererRecovery]. */ data class ViewLost(val message: String) : TermState } @@ -143,6 +144,16 @@ class TerminalController( var webViewKey by mutableStateOf(0) private set + /** + * The screen shows a WebView at all. False after a renderer loss (audit A45) until the next attach + * is asked for ([attachWhenPageReady]: a button, a pending auto-reattach, or [onStart]). Only an + * automatic reattach of a visible screen, which [RendererRecovery] bounds, builds the replacement at + * once: built unasked, a page whose renderer dies as it loads was rebuilt and lost over and over, + * since a kept answer or an offer is not counted (the review of A45). + */ + var hasWebView by mutableStateOf(true) + private set + private val main = Handler(Looper.getMainLooper()) private var webView: WebView? = null /** The page in [webView], by generation, and the JavaScript waiting for it (A45). */ @@ -332,9 +343,13 @@ class TerminalController( settings.setSupportZoom(false) settings.builtInZoomControls = false settings.displayZoomControls = false - // While the screen is not visible (its stream is detached then anyway), Android may reclaim - // this renderer before the app; onRenderProcessGone below brings the terminal back (A45). - setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_BOUND, true) + // While the screen is not visible (its stream is detached then anyway), the renderer's priority + // is waived, so Android may reclaim it before the app; onRenderProcessGone below brings the + // terminal back (A45). While visible it keeps RENDERER_PRIORITY_IMPORTANT, WebView's default. + // RENDERER_PRIORITY_BOUND (the first A45 fix) waived it the same way, but also bound the + // VISIBLE renderer below that default, so memory pressure could take the terminal the user + // was looking at before it took the app (the review of A45). + setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, true) addJavascriptInterface(Bridge(gen), "NodetermBridge") webViewClient = object : WebViewClient() { // The page never navigates; a link the user taps opens in the browser, outside this bridge. @@ -372,7 +387,8 @@ class TerminalController( * The renderer behind [view] is gone (audit A45): Android killed it to reclaim memory, or it * crashed. That WebView can never be used again. The stream is detached (it would paint into * nothing), the WebView leaves the view tree and is destroyed, and [webViewKey] changes so the - * screen builds a new one. Then, per [RendererRecovery] and what the screen was showing: an answer + * screen builds a new one: at once for an automatic reattach, otherwise once the next attach is + * asked for ([hasWebView]). Then, per [RendererRecovery] and what the screen was showing: an answer * with its own button (ended, disconnected, relay offer, view lost) stays, and nothing reattaches * unasked; otherwise the screen reattaches by itself once the new page is ready (a kill), or offers * "Reopen terminal" (a crash, or kills in a loop). @@ -400,7 +416,10 @@ class TerminalController( sizedElsewhere = null destroyWebView(view, rendererAlive = false) webViewKey++ - when (val outcome = recovery.onGone(didCrash, System.currentTimeMillis(), showing)) { + // Monotonic: a wall clock changed meanwhile would stretch or clear the kill-loop window. A kill + // while stopped is expected (the priority is waived then) and not counted (the review of A45). + val outcome = recovery.onGone(didCrash, SystemClock.elapsedRealtime(), showing, visible = !stopped) + when (outcome) { // The screen keeps its answer, and its button reattaches once the new page is ready // (attachWhenPageReady). Moving it to Connecting would attach unasked: over the relay, to // a pane that exited, that creates a new, empty session. @@ -410,6 +429,9 @@ class TerminalController( RendererRecovery.Outcome.Reattach -> state = TermState.Connecting is RendererRecovery.Outcome.Offer -> state = TermState.ViewLost(outcome.message) } + // The replacement is built now only for an automatic reattach of a visible screen, which the + // bound above limits. Otherwise the next attach asked for builds it (attachWhenPageReady). + hasWebView = outcome == RendererRecovery.Outcome.Reattach && !stopped } /** @@ -445,10 +467,17 @@ class TerminalController( /** * Attach now or, while a replacement page is still loading after a renderer loss (A45), once it - * has reported its size ([attachIfWaiting]): attaching before would claim the pty at 80×24. + * has reported its size ([attachIfWaiting]): attaching before would claim the pty at 80×24. A + * replacement not built yet ([hasWebView]) is built now, this being the attach it waited for; + * not while stopped, where nothing attaches and [onStart] builds it. */ private fun attachWhenPageReady() { - if (page.isReplacing) state = TermState.Connecting else attach() + if (page.isReplacing) { + if (!stopped) hasWebView = true + state = TermState.Connecting + } else { + attach() + } } /** "Reopen terminal", after the view was lost with its renderer (A45). */ diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index e7b7974519..954efcc1da 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -100,8 +100,11 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String Column(Modifier.fillMaxSize().aboveKeyboard(padding)) { Box(Modifier.weight(1f).fillMaxWidth().background(NtColors.canvas)) { // A new key = a new WebView: the old one was destroyed with its renderer (audit A45). - key(controller.webViewKey) { - AndroidView(factory = { ctx -> controller.createWebView(ctx) }, modifier = Modifier.fillMaxSize()) + // None at all after a loss until the next attach is asked for (the review of A45). + if (controller.hasWebView) { + key(controller.webViewKey) { + AndroidView(factory = { ctx -> controller.createWebView(ctx) }, modifier = Modifier.fillMaxSize()) + } } when (val st = controller.state) { TermState.Connecting -> Row( diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt index a691829fe2..b56359dde1 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt @@ -82,8 +82,11 @@ class TerminalPage { } /** - * What the terminal screen does once its page's renderer is gone (audit A45). A new page is built - * either way; the question is what the screen shows over it, and who reattaches the terminal. + * What the terminal screen does once its page's renderer is gone (audit A45). The page is replaced + * either way; the question is what the screen shows meanwhile, and who reattaches the terminal. The + * screen builds the replacement at once only for an automatic reattach of a visible screen, which + * the bound below limits; otherwise it waits for whatever asks for the next attach (a button, or the + * screen being started again), so a page that dies as it loads is not rebuilt with nobody asking. * * It depends first on what the screen was [Showing]: * - An ANSWER with its own button ([Showing.SETTLED]: the session ended or the connection dropped, @@ -100,13 +103,17 @@ class TerminalPage { * screen again, so reattaching unasked could crash it over and over. The screen offers "Reopen * terminal" and the user decides; * - a kill also falls back to the offer after [maxAutomatic] automatic reattaches within [windowMs]: - * a device that keeps killing a visible renderer should not have the app fight it in a loop. + * a device that keeps killing a visible renderer should not have the app fight it in a loop. A + * kill while the screen is not visible does not count (the review of A45): the renderer's priority + * is waived then, so that kill is expected, and the screen reattaches only when it comes back. * * The offer says the session is still running only when a stream was attached ([Showing.ATTACHED]): * that is the only case in which the screen knew. While it was still opening the terminal, it knew * nothing about the session yet (it may not exist, or not on this host). * - * Thread-safe; [now] is a millisecond clock supplied by the caller. + * Thread-safe. [onGone]'s `now` is a MONOTONIC millisecond clock supplied by the caller + * (`SystemClock.elapsedRealtime()` on the phone): with a wall clock, one set back would keep old + * kills in the window, and one set forward would clear them. */ class RendererRecovery(private val windowMs: Long = 60_000L, private val maxAutomatic: Int = 2) { /** What the terminal screen was showing when its renderer went away. */ @@ -137,11 +144,19 @@ class RendererRecovery(private val windowMs: Long = 60_000L, private val maxAuto private val automatic = ArrayDeque() + /** + * The renderer went away: [didCrash] as Android reports it, [now] on a monotonic clock, what the + * screen was [showing], and whether the screen was [visible] (started, in front of the user). + */ @Synchronized - fun onGone(didCrash: Boolean, now: Long, showing: Showing): Outcome { + fun onGone(didCrash: Boolean, now: Long, showing: Showing, visible: Boolean): Outcome { // Not counted against the automatic reattaches: none happens. if (showing == Showing.SETTLED) return Outcome.Keep if (!didCrash) { + // Expected while the screen is not visible, and not counted: the bound is for a device + // that keeps killing a renderer the user is looking at. The screen reattaches when it is + // started again, and a kill after that counts. + if (!visible) return Outcome.Reattach while (automatic.isNotEmpty() && now - automatic.first() >= windowMs) automatic.removeFirst() if (automatic.size < maxAutomatic) { automatic.addLast(now) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt index 5f83b31f24..8fbd7d1e76 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt @@ -57,7 +57,10 @@ data class InboxNotificationText( ) } - /** The kind of event in words, as the Inbox card's chip says it. Never the event's own text. */ + /** + * The kind of event in words, close to the Inbox card's chip but not always the same words + * (the chip says QUESTION). Never the event's own text. + */ fun kindLabel(event: InboxEvent): String = when (event.kind) { InboxKind.APPROVAL -> "Needs approval" InboxKind.QUESTION -> "Has a question" diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt index 127b758ac5..a58954b6f5 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt @@ -29,7 +29,8 @@ object ApiBaseSetting { /** * What is already stored: nothing to store. That is an unedited address, or one we would - * not accept that an older build saved, which a user who did not type it is not told about. + * not accept that an older build saved: a user who did not type it gets no message on + * leaving, though the field flags it ([fieldError]). */ data object Keep : OnLeave @@ -70,6 +71,23 @@ object ApiBaseSetting { } } + /** + * What the field says under [typed], or null when it is an address we accept. Decided by what the + * field holds NOW, not by what leaving would do (the review of A44): an address we would not accept + * that an older build stored is kept on leaving without a message, since the user did not type it + * here, but every relay call uses it, so the field still says so. + */ + fun fieldError(typed: String, stored: String): String? { + if (accept(typed) != null) return null + return if (onLeave(typed, stored) == OnLeave.Rejected) NOT_SAVED else STORED_UNUSABLE + } + + /** Under an address the user typed that we refuse. */ + const val NOT_SAVED = "Not a full https:// address: it will not be saved." + + /** Under a stored address we refuse (only an older build could have stored it). */ + const val STORED_UNUSABLE = "Not a full https:// address: the relay can't be reached with it. Correct it or reset to default." + /** The form the app stores an address in (HostStore's setter trims the same way). */ private fun normalize(address: String): String = address.trim().trimEnd('/') } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt index 94375b3023..65679f62f5 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt @@ -180,5 +180,9 @@ class InboxNotificationTextTest { ) // Stored only from the switch. assertEquals(1, Regex("""graph\.hosts\.notificationDetails\s*=(?!=)""").findAll(settings).count()) + // Enabled exactly when the Notifications switch reads On (the review of A52): with notifications + // turned off for the app in the system, the details switch is not offered either. + assertTrue(settings.contains("Switch(checked = notify && canPost, onCheckedChange = {")) + assertTrue(settings.contains("Switch(checked = notifyDetails, enabled = notify && canPost, onCheckedChange = {")) } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt index 78b840aa64..d3af6e4701 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt @@ -334,6 +334,17 @@ class LiveNotificationsTest { ) } + @Test + fun `a change pushed by a computer whose screen is closed is not re-listed live`() { + // The connection outlives the screen (the review of A73): a computer the user left kept + // announcing live through its pushes until the connection dropped or the worker ran, while the + // copy promises live notifications only for the computer on screen. Like the reconnect. + val adopt = AppSourcePins.blockAfter(connections, "private fun adopt(c: HostConnection)") + AppSourcePins.assertInOrder(adopt, "if (watchers > 0) scope.launch {", "refreshNow()") + assertTrue(adopt.contains("c.setOnChanged { if (isWatched) scope.launch { refreshNow() } }"), adopt) + assertEquals(1, Regex("""\.setOnChanged\b""").findAll(connections).count(), "another connection re-lists on its pushes") + } + @Test fun `announce decides with what is on screen, and gates only the claim on the switch and permission`() { val announce = AppSourcePins.blockAfter(notifier, "fun announce(") diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt index 036f61b4c3..fbb16e4ac9 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt @@ -15,9 +15,9 @@ import kotlin.test.assertTrue * dropped by that arrow without a word. * * The address rule and the leave decision are pure and tested here. The wiring (both backs run the - * one `leave()`, which stores before it pops, and nothing stores per keystroke) cannot run on a JVM, - * so it is pinned in the app's source ([AppSourcePins]); whether the gesture reaches it on a device - * has not been checked. + * one `leave()`, which stores before it pops; a screen taken away without a back stores silently; and + * nothing stores per keystroke) cannot run on a JVM, so it is pinned in the app's source + * ([AppSourcePins]); whether the gesture reaches it on a device has not been checked. */ class SettingsLeaveTest { @Test @@ -94,6 +94,21 @@ class SettingsLeaveTest { assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("http://relay.example.com", "https:")) } + @Test + fun `the field flags what it holds now, including an unusable address an older build stored`() { + val default = RelayApi.DEFAULT_API_BASE + assertNull(ApiBaseSetting.fieldError(default, default)) + assertNull(ApiBaseSetting.fieldError("https://relay.example.com", default)) + // Typed here: it will not be saved (leaving says so too). + assertEquals(ApiBaseSetting.NOT_SAVED, ApiBaseSetting.fieldError("http://relay.example.com", default)) + assertEquals(ApiBaseSetting.NOT_SAVED, ApiBaseSetting.fieldError("http://relay.example.com", "https:")) + // Stored by an older build and not edited: leaving keeps it without a message, but the field + // flags it (the review of A44), since every relay call uses it. It used to show no error at all. + assertEquals(ApiBaseSetting.STORED_UNUSABLE, ApiBaseSetting.fieldError("https:", "https:")) + assertEquals(ApiBaseSetting.STORED_UNUSABLE, ApiBaseSetting.fieldError(" https:// ", "https:")) + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave("https:", "https:")) + } + private val settings get() = AppSourcePins.ui("SettingsScreen.kt") @Test @@ -109,19 +124,28 @@ class SettingsLeaveTest { @Test fun `leave stores the name and the address, tells about a refused one, then pops`() { - val leave = AppSourcePins.blockAfter(settings, "fun leave()") + val save = AppSourcePins.blockAfter(settings, "fun save(onRejected: () -> Unit)") AppSourcePins.assertInOrder( - leave, + save, // An unedited name is not stored: a phone that stores no name is named after its model. "if (name.trim() != graph.hosts.deviceName) graph.hosts.deviceName = name", "ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase)", "OnLeave.Save -> graph.hosts.apiBase = edit.value", "OnLeave.UseDefault -> graph.hosts.useDefaultApiBase()", "OnLeave.Keep -> {}", - "OnLeave.Rejected -> Toast.makeText(", - ".show()", - "nav.pop()" + "OnLeave.Rejected -> onRejected()" ) + val leave = AppSourcePins.blockAfter(settings, "fun leave()") + AppSourcePins.assertInOrder(leave, "save(onRejected = {", "Toast.makeText(", ".show()", "nav.pop()") + } + + @Test + fun `leaving the screen without a back stores the edits too, without a message`() { + // A notification tap (replaceAll) or a pairing link (push) takes Settings off the screen + // without running leave(), and the edits used to be lost silently (the review of A44). + val effect = AppSourcePins.blockAfter(settings, "DisposableEffect(Unit)") + assertEquals("{ save(onRejected = {}) }", AppSourcePins.blockAfter(effect, "onDispose")) + assertFalse(effect.contains("Toast"), "the dispose save shows a message nobody is there to read:\n$effect") } @Test @@ -138,13 +162,16 @@ class SettingsLeaveTest { @Test fun `nothing else pops or stores the edits`() { val src = settings - // One pop (in leave), and the two stores only there: never per keystroke, never skipped. + // One pop (in leave), and the two stores only in save: never per keystroke, never skipped. assertEquals(1, Regex("""nav\.pop\(\)""").findAll(src).count()) assertEquals(1, Regex("""graph\.hosts\.apiBase\s*=(?!=)""").findAll(src).count()) assertEquals(1, Regex("""graph\.hosts\.deviceName\s*=(?!=)""").findAll(src).count()) assertEquals(1, Regex("""graph\.hosts\.useDefaultApiBase\(\)""").findAll(src).count()) - // The field warns with the same decision leaving acts on. - assertTrue(src.contains("val apiBaseRejected = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase) == ApiBaseSetting.OnLeave.Rejected")) - assertTrue(src.contains("isError = apiBaseRejected")) + // save() runs from leave() and when the screen is disposed, nowhere else. + assertEquals(2, Regex("""\bsave\(onRejected = """).findAll(src).count()) + // The field warns by what it holds now (ApiBaseSetting.fieldError), not only about an edit. + assertTrue(src.contains("val apiBaseError = ApiBaseSetting.fieldError(apiBase, graph.hosts.apiBase)")) + assertTrue(src.contains("isError = apiBaseError != null")) + assertTrue(src.contains("supportingText = apiBaseError?.let { error -> { Text(error) } }")) } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt index a3712a12c1..ac1cc4a947 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt @@ -15,7 +15,8 @@ import kotlin.test.assertTrue * Audit A45: the terminal WebView's renderer can go away (killed by the system, or crashed), and the * screen then builds a new WebView. The screen (TerminalController) is only type-checked; these pin * the rules it delegates to: which page's callbacks count, where queued JavaScript goes, who - * reattaches, and what the screen shows meanwhile. + * reattaches, and what the screen shows meanwhile. What it passes in, and when it builds the new + * WebView, is pinned in its source at the end. */ class TerminalPageTest { // ---- the page generation ----------------------------------------------------------------- @@ -98,20 +99,20 @@ class TerminalPageTest { // ---- who reattaches -------------------------------------------------------------------- - private fun RendererRecovery.gone(didCrash: Boolean, now: Long) = onGone(didCrash, now, Showing.ATTACHED) + private fun RendererRecovery.gone(didCrash: Boolean, now: Long) = onGone(didCrash, now, Showing.ATTACHED, visible = true) @Test fun `a crashed renderer is offered to the user, never reattached by itself`() { val recovery = RendererRecovery() assertIs(recovery.gone(didCrash = true, now = 0)) assertIs(recovery.gone(didCrash = true, now = 3_600_000)) - assertIs(recovery.onGone(didCrash = true, now = 3_600_001, showing = Showing.OPENING)) + assertIs(recovery.onGone(didCrash = true, now = 3_600_001, showing = Showing.OPENING, visible = true)) } @Test fun `a renderer the system killed is reattached by itself`() { assertEquals(Outcome.Reattach, RendererRecovery().gone(didCrash = false, now = 0)) - assertEquals(Outcome.Reattach, RendererRecovery().onGone(didCrash = false, now = 0, showing = Showing.OPENING)) + assertEquals(Outcome.Reattach, RendererRecovery().onGone(didCrash = false, now = 0, showing = Showing.OPENING, visible = true)) } @Test @@ -126,6 +127,29 @@ class TerminalPageTest { assertIs(recovery.gone(didCrash = false, now = 60_001)) } + @Test + fun `kills while the screen is not visible are reattached and do not use up the automatic reattaches`() { + // The renderer's priority is waived while the screen is not visible, so Android reclaiming it + // then is expected (the review of A45); the screen reattaches when it is started again. + val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 2) + repeat(5) { + assertEquals(Outcome.Reattach, recovery.onGone(didCrash = false, now = it.toLong(), showing = Showing.OPENING, visible = false)) + } + // Back in front of the user: the whole bound is still there. + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 10)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 11)) + assertIs(recovery.gone(didCrash = false, now = 12)) + // A background kill is not held against the screen once the bound is used up either. + assertEquals(Outcome.Reattach, recovery.onGone(didCrash = false, now = 13, showing = Showing.OPENING, visible = false)) + } + + @Test + fun `a crash while the screen is not visible is still not reattached by itself`() { + // Only a kill is expected in the background; a crash may come from what the pane printed. + val crashed = assertIs(RendererRecovery().onGone(didCrash = true, now = 0, showing = Showing.OPENING, visible = false)) + assertEquals("The terminal view crashed.", crashed.message) + } + @Test fun `an offer is not counted as an automatic reattach`() { val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 1) @@ -142,14 +166,14 @@ class TerminalPageTest { // attach of its own (over the relay that creates a new, empty session), and a kill after a // crash offer must not reattach what the user was asked about. val recovery = RendererRecovery() - assertEquals(Outcome.Keep, recovery.onGone(didCrash = false, now = 0, showing = Showing.SETTLED)) - assertEquals(Outcome.Keep, recovery.onGone(didCrash = true, now = 1, showing = Showing.SETTLED)) + assertEquals(Outcome.Keep, recovery.onGone(didCrash = false, now = 0, showing = Showing.SETTLED, visible = true)) + assertEquals(Outcome.Keep, recovery.onGone(didCrash = true, now = 1, showing = Showing.SETTLED, visible = true)) } @Test fun `a kept answer does not use up the automatic reattaches`() { val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 2) - repeat(5) { recovery.onGone(didCrash = false, now = it.toLong(), showing = Showing.SETTLED) } + repeat(5) { recovery.onGone(didCrash = false, now = it.toLong(), showing = Showing.SETTLED, visible = true) } assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 10)) assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 11)) assertIs(recovery.gone(didCrash = false, now = 12)) @@ -160,14 +184,63 @@ class TerminalPageTest { val running = "The session is still running on the computer." val crashed = assertIs(RendererRecovery().gone(didCrash = true, now = 0)) assertEquals("The terminal view crashed. $running", crashed.message) - val opening = assertIs(RendererRecovery().onGone(didCrash = true, now = 0, showing = Showing.OPENING)) + val opening = assertIs(RendererRecovery().onGone(didCrash = true, now = 0, showing = Showing.OPENING, visible = true)) assertEquals("The terminal view crashed.", opening.message) assertFalse(running in opening.message, "nothing was known about the session while the terminal was opening") val loop = RendererRecovery(maxAutomatic = 0) val killedAttached = assertIs(loop.gone(didCrash = false, now = 0)) assertEquals("Android keeps closing the terminal view to free memory. $running", killedAttached.message) - val killedOpening = assertIs(loop.onGone(didCrash = false, now = 1, showing = Showing.OPENING)) + val killedOpening = assertIs(loop.onGone(didCrash = false, now = 1, showing = Showing.OPENING, visible = true)) assertEquals("Android keeps closing the terminal view to free memory.", killedOpening.message) } + + // ---- the screen's side, pinned in the app's source (the review of A45) ---------------------- + + private val controller get() = AppSourcePins.ui("TerminalController.kt") + + @Test + fun `the screen asks on a monotonic clock and says whether it is visible`() { + val gone = AppSourcePins.blockAfter(controller, "private fun rendererGone(") + assertTrue( + gone.contains("recovery.onGone(didCrash, SystemClock.elapsedRealtime(), showing, visible = !stopped)"), + "rendererGone does not ask RendererRecovery with the monotonic clock and the screen's visibility:\n$gone" + ) + assertFalse(gone.contains("currentTimeMillis"), "the kill-loop window runs on the wall clock:\n$gone") + } + + @Test + fun `a lost page is rebuilt at once only for an automatic reattach of a visible screen`() { + val src = controller + val gone = AppSourcePins.blockAfter(src, "private fun rendererGone(") + AppSourcePins.assertInOrder(gone, "webViewKey++", "recovery.onGone(", "hasWebView = outcome == RendererRecovery.Outcome.Reattach && !stopped") + // Otherwise the next attach asked for builds it, and only that: an offer (or a kept answer) + // rebuilt at once let a page that dies as it loads be rebuilt and lost with no bound. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(src, "private fun attachWhenPageReady()"), + "if (page.isReplacing) {", + "if (!stopped) hasWebView = true", + "state = TermState.Connecting" + ) + assertEquals(2, Regex("""hasWebView\s*=(?!=)""").findAll(src).count(), "hasWebView is set somewhere else") + // "Reopen terminal" and coming back to the screen are among the attaches that build it. + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(src, "fun reopenTerminal()"), "attachWhenPageReady()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(src, "fun onStart()"), "stopped = false", "attachWhenPageReady()") + // No WebView at all meanwhile: the dead one left with its key. + AppSourcePins.assertInOrder( + AppSourcePins.ui("TerminalScreen.kt"), + "if (controller.hasWebView) {", + "key(controller.webViewKey) {", + "AndroidView(factory = { ctx -> controller.createWebView(ctx) }" + ) + } + + @Test + fun `the visible terminal's renderer keeps WebView's default priority`() { + // RENDERER_PRIORITY_BOUND waived it in the background too, but also bound the renderer of the + // terminal the user was looking at below the default. + val create = AppSourcePins.blockAfter(controller, "fun createWebView(") + assertTrue(create.contains("setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, true)"), create) + assertFalse(controller.contains("setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_BOUND")) + } } diff --git a/docs/android.md b/docs/android.md index fc6ce50305..87362ecf6d 100644 --- a/docs/android.md +++ b/docs/android.md @@ -190,11 +190,15 @@ be tapped again once the reattach has settled it (see `ResumeOfferTest`). detaches the stream, destroys that WebView and builds a new one. Each page has a generation, so a callback the dead page posted just before the loss cannot mark the new page ready, and JavaScript queued for the dead page is dropped rather than replayed into the new one. A paint offered before -the new page exists is kept for it. A renderer the system killed (the terminal's renderer priority -is waived while the screen is not visible) is reattached automatically once the new page has -reported its size. A crash is not, because the reattach would repaint the same screen: the screen -offers "Reopen terminal" instead. More than two kills within a minute fall back to that offer too. -A screen that was showing an answer with its own button (the session ended, the connection +the new page exists is kept for it. A renderer the system killed is reattached automatically once +the new page has reported its size. The renderer keeps WebView's default priority while the terminal +is visible and has it waived while it is not, so a kill in the background is expected: it is not +counted, and the terminal is reattached when the screen is started again. A crash is not reattached, +because the reattach would repaint the same screen: the screen offers "Reopen terminal" instead. More +than two kills of a visible terminal within a minute (on a monotonic clock) fall back to that offer +too. Only that automatic reattach builds the new WebView at once; otherwise it is built for the next +attach something asks for, so a page whose renderer dies as it loads is not rebuilt and lost in a +loop. A screen that was showing an answer with its own button (the session ended, the connection dropped, "Open through the relay", or an earlier "Reopen terminal") keeps it, and nothing reattaches unasked: over the relay, an attach to a pane that has exited creates a new, empty session. The offer says the session is still running only when a stream was attached. The WebView @@ -244,14 +248,16 @@ Whether the keyboard comes up, and stays up, is a device check. `SettingsLeaveTest` covers leaving Settings (`A44`). The system back (gesture or button) used to pop the screen without storing the edited phone name or relay API address; only the top-bar arrow stored -them. Both now run one `leave()`, which stores, then pops. Nothing is stored per keystroke. The relay -address must be a full `https://` URL (`ApiBaseSetting`: a host, no query or fragment); one that is -not is left unstored, the field says so while it is being typed, and leaving shows a message. -Leaving without an edit stores nothing, and the built-in relay address is never stored: "Reset to -default" forgets the stored address instead. A phone that stores none follows the default of the build -it runs, so storing the default would pin it to this build's for good. The address rule and the leave -decision are unit-tested; the wiring is pinned in the source, and whether the back gesture reaches it -is a device check. +them. Both now run one `leave()`, which stores, then pops. A screen taken away without a back (a +notification tap replaces the stack, a pairing link pushes its screen on top) stores the edits as it +goes, silently. Nothing is stored per keystroke. The relay address must be a full `https://` URL +(`ApiBaseSetting`: a host, no query or fragment); one that is not is left unstored, the field says +so while it is being typed, and leaving shows a message. An unusable address an older build stored +is kept without a message, but the field flags it too. Leaving without an edit stores nothing, and +the built-in relay address is never stored: "Reset to default" forgets the stored address instead. +A phone that stores none follows the default of the build it runs, so storing the default would pin +it to this build's for good. The address rule and the leave decision are unit-tested; the wiring is +pinned in the source, and whether the back gesture reaches it is a device check. `InboxNotificationTextTest` pins what an Inbox notification says (`A52`). An approval's notification used to carry the desktop's tool summary (the command's first line, a file path, a fetched URL), and @@ -270,7 +276,7 @@ what a lock screen shows is a device check. `LiveNotificationsTest` covers when notifications are posted (`A73`). The app said they were live every 8 s while a computer was open, but only the 15-minute background check ever posted one; the in-app refresh only updated the listing. Now every listing that arrives (the 8 s refresh of the -computer on screen, a change the computer pushes, the background check) runs the one announce path, +computer on screen, a change that computer pushes, the background check) runs the one announce path, so notifications are live for the computer whose screen is open. What the user is looking at is left out and recorded as seen instead, so no later check announces it: every event while that computer's Inbox tab is on screen, and what a session's terminal shows while it is attached. That is recorded @@ -281,10 +287,14 @@ prompt only once the hold ends; a terminal showing an overlay instead of its pan offer, approval code, lost view) hides nothing; and one still connecting leaves its session's events for a later listing, which the attach settles at once from the latest listing. Other computers are not polled while one is open, so theirs still come only from the background check, and the Settings -text, the README and the notifier's comment now say exactly that. It costs no network call (the -listing already arrived), and the check writes the phone's seen-log only when something is new. The -decision and the screen bookkeeping are unit-tested; the wiring into the refresh, the worker and the -two screens is pinned in the source, and whether a notification appears on a phone is a device check. +text, the README and the notifier's comment now say exactly that. A computer the user just left is +one of them: its connection can stay open until it drops or the background check closes it, and a +change it pushes meanwhile is no longer re-listed (it used to be, and so announced live; the review +of A73). What that push carried is not recorded as seen, so the background check announces it. The +announce costs no network call (the listing already arrived), and the check writes the phone's +seen-log only when something is new. The decision and the screen bookkeeping are unit-tested; the +wiring into the refresh, the worker and the two screens is pinned in the source, and whether a +notification appears on a phone is a device check. `PhoneIdentityTest` and `BackupRulesTest` cover what leaves the phone (`A51`). `allowBackup="false"` stops cloud backup, but an app that targets Android 12 or later is still copied by a @@ -299,9 +309,11 @@ the stored deviceId is removed first, durably, and a deviceId is minted only onc phone whose key was lost therefore registers with the relay backend under a new id instead of re-registering the old one without its previous device token, which the free tier can refuse (per the desktop's note on `priorDeviceToken` in `pairing-service.ts`; the backend is not in this repo). -It also no longer shares a device row with the phone it was copied from, so removing one pairing on -an entitled desktop cannot revoke the other phone. The old row is left unused on the backend, as -after an uninstall. An existing install keeps its id while its key still opens. +From this build on, it also no longer shares a device row with the phone it was copied from, so +removing one pairing on an entitled desktop cannot revoke the other phone. The old row is left unused +on the backend, as after an uninstall. An existing install keeps its id while its key still opens, +so a phone whose key an older build already replaced keeps the id it had (and any row it shares) +until the app is reinstalled. `KeyboardInsetsTest` covers the soft keyboard on Android 15 (`A77`). The app targets API 35, so on Android 15 its window is edge-to-edge whether it asks or not: the keyboard no longer resizes the From e3cbc131b18caea23f9c58198e466e322d925b70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:46:46 +0000 Subject: [PATCH 073/284] =?UTF-8?q?docs(android):=20batch=20B=20progress?= =?UTF-8?q?=20(A37,=20A40=E2=80=93A47,=20A51,=20A52,=20A57,=20A73,=20A76,?= =?UTF-8?q?=20A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handover: a progress-log table for the app-only batch; the stale "R8 is off" and `key(route)` lines corrected. Audit index: the batch's findings marked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 30 +++++++++++++++--------------- docs/android-handover.md | 28 ++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index 10baf6de2f..5b4e99cfd0 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -51,27 +51,27 @@ the audit's proposal, the handover's progress log says how and why. | [A34](#a34) | medium | | small | critic/bug | ✅ fixed in `2a273a7` · The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter | | [A35](#a35) | medium | | small | critic/bug | ✅ fixed in `16706f4` · approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.' | | [A36](#a36) | medium | | small | critic/gap | ✅ fixed in `2a273a7` · After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself | -| [A37](#a37) | low | | small | build/risk | proguard-rules.pro would not survive turning on minification (R8 missing-class errors) | +| [A37](#a37) | low | | small | build/risk | ✅ fixed in `ac0923a` · proguard-rules.pro would not survive turning on minification (R8 missing-class errors) | | [A38](#a38) | low | | small | protocol/bug | ✅ fixed in `5ff8f6c` · Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question | | [A39](#a39) | low | | small | protocol/bug | ✅ fixed in `437e359` · The account chip reads `account.label`, which the mirror never writes, so it falls back to the raw account UUID | -| [A40](#a40) | low | | small | runtime/bug | The pending-launch path and the attach hand-off never re-check `disposed`: a stream can stay attached forever, or a phone-started node gets registered with no agent launched | -| [A41](#a41) | low | | small | runtime/bug | The composed prompt is cleared even when no stream is attached, so the text is silently lost | -| [A42](#a42) | low | | small | runtime/bug | NewSessionDialog crashes if the selected project disappears while the dialog is open | -| [A43](#a43) | low | | small | runtime/bug | The HostScreen tab and scroll position reset after returning from a terminal | -| [A44](#a44) | low | | small | runtime/bug | System back discards Settings edits (device name, relay API base) | -| [A45](#a45) | low | | small | runtime/risk | No onRenderProcessGone handler on the terminal WebView | -| [A46](#a46) | low | | small | runtime/bug | The ⌨ key-row chip only focuses the DOM textarea, which cannot raise the soft keyboard | -| [A47](#a47) | low | | small | runtime/bug | The Keystore decrypt runs on the main thread in the host list's composition, once per row per recomposition | +| [A40](#a40) | low | | small | runtime/bug | ✅ fixed in `a38d39d` · The pending-launch path and the attach hand-off never re-check `disposed`: a stream can stay attached forever, or a phone-started node gets registered with no agent launched | +| [A41](#a41) | low | | small | runtime/bug | ✅ fixed in `94a558a` · The composed prompt is cleared even when no stream is attached, so the text is silently lost | +| [A42](#a42) | low | | small | runtime/bug | ✅ fixed in `7debe67` · NewSessionDialog crashes if the selected project disappears while the dialog is open | +| [A43](#a43) | low | | small | runtime/bug | ✅ fixed in `2be5e87` · The HostScreen tab and scroll position reset after returning from a terminal | +| [A44](#a44) | low | | small | runtime/bug | ✅ fixed in `7988087` · System back discards Settings edits (device name, relay API base) | +| [A45](#a45) | low | | small | runtime/risk | ✅ fixed in `1901939` · No onRenderProcessGone handler on the terminal WebView | +| [A46](#a46) | low | | small | runtime/bug | ✅ fixed in `17ee526` · The ⌨ key-row chip only focuses the DOM textarea, which cannot raise the soft keyboard | +| [A47](#a47) | low | | small | runtime/bug | ✅ fixed in `52df0a3` · The Keystore decrypt runs on the main thread in the host list's composition, once per row per recomposition | | [A48](#a48) | low | | small | runtime/bug | ✅ fixed in `d383e76` · The seen-events set is trimmed in hash order and updated without synchronization, which can produce duplicate notifications | | [A49](#a49) | low | | small | security/risk | ✅ fixed in `a40d11b` · SSH host-key TOFU pin is saved during key exchange (before auth) and is not tied to the pairing | | [A50](#a50) | low | | medium | security/risk | The only distributable build is a debuggable APK, so Keystore-protected secrets can be pulled over adb/JDWP | -| [A51](#a51) | low | | small | security/gap | allowBackup=false does not stop device-to-device migration at targetSdk 35: hosts, pins and deviceId are cloned | -| [A52](#a52) | low | | small | security/gap | Approval and finish notifications put command text and the agent's last message on the lock screen | +| [A51](#a51) | low | | small | security/gap | ✅ fixed in `9b4af70` · allowBackup=false does not stop device-to-device migration at targetSdk 35: hosts, pins and deviceId are cloned | +| [A52](#a52) | low | | small | security/gap | ✅ fixed in `3780f5a` · Approval and finish notifications put command text and the agent's last message on the lock screen | | [A53](#a53) | low | | small | security/bug | ✅ fixed in `af587ac` · OSC 52 handler has no size cap (the desktop caps at 1,000,000) and setPrimaryClip is unguarded | | [A54](#a54) | low | | small | security/bug | ✅ fixed in `32330df` · PairingClient trusts an unbounded Content-Length / EOF body from the pairing endpoint | | [A55](#a55) | low | | medium | parity/gap | Inbox and Usage are per computer; iOS merges them across all paired computers | | [A56](#a56) | low | | small | parity/gap | 📝 not built, by decision (`38fa6c4`): a blind `2` can answer No or switch to auto mode on current Claude Code · Approval cards lack iOS's "Always allow" answer | -| [A57](#a57) | low | | small | parity/gap | Multi-select AskUserQuestion cards fall back to "Open session" | +| [A57](#a57) | low | | small | parity/gap | ✅ fixed in `8dbeb48` · Multi-select AskUserQuestion cards fall back to "Open session" | | [A58](#a58) | low | | small | parity/gap | ✅ fixed in `a44f16c` · Usage and feed cards omit iOS's pace line and the context indicator on event cards | | [A59](#a59) | low | | small | parity/gap | No built-in dictation (iOS has on-device Whisper plus a Cloud engine) | | [A60](#a60) | low | | small | ci-docs/bug | Interop fixture needs the Electron binary, which is downloaded at test time inside the 20 s ready window, despite the workflow saying 'not Electron' | @@ -87,11 +87,11 @@ the audit's proposal, the handover's progress log says how and why. | [A70](#a70) | low | | small | ci-docs/bug | On Windows the interop tests fail with CreateProcess instead of skipping | | [A71](#a71) | low | | small | ci-docs/gap | android/README says 'JDK 17+', but the pinned Gradle 8.14.3 cannot run on JDK 25 | | [A72](#a72) | low | | medium | critic/gap | ✅ fixed in `71290de` · Phone-started sessions are created without the agent-specific env, so for their whole life they get no hook-reply approvals, no canvas control and no pane ownership | -| [A73](#a73) | low | | small | critic/bug | Notifications are documented as 'live every 8 seconds while a computer is open', but the in-app poll never posts a notification | +| [A73](#a73) | low | | small | critic/bug | ✅ fixed in `e055f37` · Notifications are documented as 'live every 8 seconds while a computer is open', but the in-app poll never posts a notification | | [A74](#a74) | low | | small | critic/bug | ✅ fixed in `a40d11b` · The LAN leg dials a DHCP IPv4 frozen at pairing time; when another SSH host answers at that address, the host-key 'hard stop' also blocks the relay fallback | | [A75](#a75) | low | | small | critic/gap | ✅ fixed in `437e359` · The New session account picker lists managed Claude accounts by raw UUID | -| [A76](#a76) | low | | small | critic/gap | Over direct SSH, opening a Sleeping (Eco-hibernated) session lands on a bare shell with no wake or resume offer | -| [A77](#a77) | low | | small | critic/bug | IME insets are not handled for Android 15's enforced edge-to-edge (targetSdk 35): the terminal gets double bottom padding when the keyboard opens, and other screens have no IME padding at all | +| [A76](#a76) | low | | small | critic/gap | ✅ fixed in `6966f25` · Over direct SSH, opening a Sleeping (Eco-hibernated) session lands on a bare shell with no wake or resume offer | +| [A77](#a77) | low | | small | critic/bug | ✅ fixed in `0a2a1aa` · IME insets are not handled for Android 15's enforced edge-to-edge (targetSdk 35): the terminal gets double bottom padding when the keyboard opens, and other screens have no IME padding at all | ## A01 diff --git a/docs/android-handover.md b/docs/android-handover.md index 3beeb0a6c0..242c092296 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -25,6 +25,30 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr Newest first. Each entry says what landed, how it was checked, and where the fix differs from the audit's proposal. +### WP6 batch B (app-only low-severity items): done on the branch, not device-verified + +Most of the logic moved into pure, JVM-tested classes in `android/protocol` (the Compose wiring is +type-checked and source-pinned only). Every item had an adversarial review; the follow-up commits +are listed with it. + +| Finding | Commits | What changed | Checked by | +|---|---|---|---| +| A40 | `a38d39d`, `5fb3aea` | `TerminalHandoff` (StreamLease, ViewerSlot, PhoneLaunch): a stream is installed only for the current attach ticket; a phone launch holds its own lease, so Back or backgrounding within the settle delay still writes the line and registers the node. Review follow-up: once a request is on the wire it is not cancelled, and both transports now detach a stream whose caller was cancelled (relay `pty.kill`, SSH client close). | `TerminalHandoffTest`; relay interop and SSH tests for the cancelled attach. | +| A41 | `94a558a`, `0507555` | `InputBar.plan`: Send keeps the draft while detached; byte-sending chips are disabled. The resume offer survives a reattach of the same screen and is re-checked against a fresh listing before it can be tapped. | `InputBarTest`, `ResumeOfferTest`. | +| A45 | `1901939`, `a371483`, `46b0897` | `onRenderProcessGone` handled: a kill reattaches automatically once a fresh WebView reports its size (bounded, visible screen only, monotonic clock); a crash offers "Reopen terminal", and the WebView is rebuilt only when something asks to attach. Renderer priority IMPORTANT, waived when not visible. | `TerminalPageTest`. | +| A46 | `17ee526` | The ⌨ chip clears Compose focus, focuses the WebView, blur/refocuses xterm, and asks the IME after a frame. | Source pins; device check owed. | +| A76 | `6966f25`, `4ef21f0` | "Wake " over direct SSH for a Sleeping node, only while a shell owns the pane (read before offering and again at the tap), with Ctrl-U first. **Desktop:** the mirror (and the renderer's self-heal report) now drops `hibernated` on a live state or session start, so a CLI resumed outside the desktop's own wake no longer stays Sleeping. | `ResumeOfferTest`; SSH transport test with a real tmux; vitest mirror + agentStatus tests. | +| A43 | `2be5e87` | `BackStack`: per-entry keys for `SaveableStateHolder`, retired keys removed (persisted across process death); Board project and Inbox archive toggle saveable. | `BackStackTest`. | +| A44 | `7988087`, `582bdae`, `46b0897` | `ApiBaseSetting`: system back saves like the arrow; an unedited address is not stored (the built-in default stays a default); leaving by a notification tap or a pairing link saves too; a refused or unusable stored address is flagged in the field. | `SettingsLeaveTest`. | +| A42 | `7debe67` | `NewSessionChoice`: the selection is derived from the current listing; Start is disabled when nothing is offered. | `NewSessionChoiceTest`. | +| A57 | `8dbeb48` | Multi-select questions list their options read-only with "answer in the session". | `QuestionChoicesTest`. | +| A47 | `52df0a3` | The host list asks `SecretStoreCore.contains` (no decrypt, no lock) keyed on a revision flow. | `SecretStoreCoreTest`. | +| A52 | `3780f5a`, `46b0897` | `InboxNotificationText`: no event text in notifications by default ("Show details in notifications" opts in); a public version for lock screens that hide sensitive content. | `InboxNotificationTextTest`. | +| A73 | `e055f37`, `27ee194`, `46b0897` | Live notifications for the watched computer: every successful listing announces, skipping what is on screen (the Inbox tab; the open terminal's node, except a held hook-reply approval, which is not painted). A computer the user left is not announced from pushes. | `LiveNotificationsTest`. | +| A51 | `9b4af70`, `46b0897` | `data_extraction_rules.xml` excludes everything from cloud backup and device transfer; a box key created anew drops the device id so a new one is minted with it. | `DeviceIdentityTest`; device check owed. | +| A77 | `0a2a1aa` | `Modifier.aboveKeyboard`: Scaffold padding, consume, then IME padding, on the terminal, Pair and Settings screens; no `enableEdgeToEdge()`. | Source pins; device check owed. | +| A37 | `ac0923a`, `e0a7883` | Real R8 rules (`-dontwarn` for what jdeps finds missing, keeps for the WebView bridge, name-loaded crypto classes, the worker); release is minified; CI builds `assembleRelease` and checks the named keeps matched. A missing `-dontwarn` fails CI, a missing keep for new reflection does not. | CI job "App release (R8, unsigned)" green on its first run; `R8RulesTest`. | + ### WP4 remainder, WP5/WP6 batch A (desktop root causes, protocol-testable items): done on the branch, not device-verified | Finding | Commit | What changed | Checked by | @@ -46,7 +70,7 @@ audit's proposal. |---|---|---|---| | A24 | `8e304db` | `SecretStoreCore` (protocol) replaces a stored secret only on positive evidence it is gone (nothing stored, malformed blob, `AEADBadTagException`); anything else is `SecretUnavailableException` and nothing is written. The identity's first write is durable. `SecureStore` generates the Keystore key only when the alias is absent. | `SecretStoreCoreTest` with real AES-GCM. | | A20 | `d199f03` | `connectLocked` rethrows cancellation on both legs (state back to Idle, no "offline"), and closes an SSH connection that finished dialing after its caller was cancelled. | Type-check + CI build only. | -| A11 / A19 | `de1eded` | `onNewIntent` records the host id (and `setIntent`); a LaunchedEffect opens Hosts → that computer's Inbox. The Host destination is `key(route)`-ed so an already-showing Host screen does not keep its tab. | Type-check + CI build only. | +| A11 / A19 | `de1eded` | `onNewIntent` records the host id (and `setIntent`); a LaunchedEffect opens Hosts → that computer's Inbox. A re-navigated Host screen starts on the Inbox tab (the per-entry state holders of A43 now do what a `key(route)` did here). | Type-check + CI build only. | | A34 / A36 | `2a273a7` | A34: an armed Ctrl applies to the input bar (`Keys.ctrl`, one control byte, no Enter). A36: a null exit (connection gone) reattaches automatically once the host connection is back — bounded (3 per flapping stretch), only while the screen is showing. | `KeysTest`; the controller is type-checked only. | | A14 / A15 / A16 | `c58ad65` | A16: the project's `defaultPermissionMode` wins over the global one (both re-validated); its `defaultAccountId` is preselected only while the host still has it. A15: the resume offer is built like the desktop's cold restore (`cd`, `CLAUDE_CONFIG_DIR`, mode; portable `./` cwds resolved). A14: cwd-less projects are no longer offered, and a refused registration shows a notice. | `ModelTest` (4 new cases). | | A12 | `ab1335c` | New additive desktop verb `node.sendKeys {nodeId, keys}` → `{sent}` via `PtyManager.backgroundWrite` (no throwaway client; short answers only; SSH-project nodes answer `sent:false`). The phone uses it; `sent:false` opens the session; an older desktop gets attach → wait for paint → write → 400 ms linger. | vitest `host-node-actions.test.ts`; relay interop incl. the older-desktop fallback. | @@ -160,7 +184,7 @@ Verified: - The relay join request shape. The client sends `{deviceToken, hostId}` to `POST /v1/relay/join` and accepts `pairingToken | token | joinToken` in the reply, but it has not been checked against the live backend (the backend repo is not here). -- Release/minified builds. R8 is off; see `A37`. +- Release/minified builds on a device. R8 runs for release in CI (`A37`: `assembleRelease` plus `tools/check-r8-output.sh`), but no minified APK has been installed or run, and no release signing key exists. - **Caveat on the tests:** their fixtures hand-copy some desktop shapes: the mirror, the `projects.list` blob and the `~/.nodeterm` files (`A64`). That is how `A02` passed its test while being wrong on every real desktop. From 81a2e53b255dc0871b34ce69e92e352ff41197d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:46:58 +0000 Subject: [PATCH 074/284] docs(android): name the A51 test class correctly in the handover Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-handover.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/android-handover.md b/docs/android-handover.md index 242c092296..cd3d840021 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -45,7 +45,7 @@ are listed with it. | A47 | `52df0a3` | The host list asks `SecretStoreCore.contains` (no decrypt, no lock) keyed on a revision flow. | `SecretStoreCoreTest`. | | A52 | `3780f5a`, `46b0897` | `InboxNotificationText`: no event text in notifications by default ("Show details in notifications" opts in); a public version for lock screens that hide sensitive content. | `InboxNotificationTextTest`. | | A73 | `e055f37`, `27ee194`, `46b0897` | Live notifications for the watched computer: every successful listing announces, skipping what is on screen (the Inbox tab; the open terminal's node, except a held hook-reply approval, which is not painted). A computer the user left is not announced from pushes. | `LiveNotificationsTest`. | -| A51 | `9b4af70`, `46b0897` | `data_extraction_rules.xml` excludes everything from cloud backup and device transfer; a box key created anew drops the device id so a new one is minted with it. | `DeviceIdentityTest`; device check owed. | +| A51 | `9b4af70`, `46b0897` | `data_extraction_rules.xml` excludes everything from cloud backup and device transfer; a box key created anew drops the device id so a new one is minted with it. | `PhoneIdentityTest`; device check owed. | | A77 | `0a2a1aa` | `Modifier.aboveKeyboard`: Scaffold padding, consume, then IME padding, on the terminal, Pair and Settings screens; no `enableEdgeToEdge()`. | Source pins; device check owed. | | A37 | `ac0923a`, `e0a7883` | Real R8 rules (`-dontwarn` for what jdeps finds missing, keeps for the WebView bridge, name-loaded crypto classes, the worker); release is minified; CI builds `assembleRelease` and checks the named keeps matched. A missing `-dontwarn` fails CI, a missing keep for new reflection does not. | CI job "App release (R8, unsigned)" green on its first run; `R8RulesTest`. | From 67e6297f9857a05e6dffa9e9dff558d8366785c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:53:13 +0000 Subject: [PATCH 075/284] fix(android): keep the interop fixture off the real electron package (A60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interop fixture bundles host-service.ts, host-identity.ts and host-canvas-hub.ts, which import `electron` at the top. With `--external:electron` the bundle kept three top-level `require("electron")` calls, and outside Electron that runs the npm package's index.js, which synchronously downloads the ~110 MB Electron binary whenever it is missing. The package has no install script, so every fresh `npm ci` (a CI runner) lacks it, and the download ran inside the harness's 20 s wait for the fixture's ready line: a slow or failed download failed every relay and pairing interop test with a timeout. The workflow comment claimed the tests need "not Electron". - InteropHarness bundles with `--alias:electron=./android/protocol/src/test/interop/electron-stub.ts` instead of `--external:electron`. The stub exports every member the src/main/remote files import (app, ipcMain, safeStorage, dialog); a member missing from it fails the esbuild step with "No matching export". - InteropHarness.start kills the node process when the ready wait fails (timeout or `fatal`) before rethrowing. The caller never received a handle, so the process used to be orphaned. close() also destroys the process's descendants, captured before node exits. - start takes `readyTimeoutMs` and an `onSpawn` test seam; the fixture gets a `never-ready` mode. - The workflow comment and the fixture header now say why Electron is not needed. Departure from the audit: the stub members are not no-ops. Each is a Proxy that throws an error naming the member on use (symbol keys and `then` answer undefined). Under the real package every member was `undefined`, so any use already threw; a no-op would change that silently (`app.isPackaged` falsy, `ipcMain.on` registering nothing) and hide a fixture that started to depend on Electron. The ELECTRON_OVERRIDE_DIST_PATH alternative was not used because the alias removes the package from the fixture entirely. Verified: new InteropHarnessTest asserts the bundle has no `require("electron")` and carries the stub, and that a start whose ready wait fails leaves no live process. Mutation check: with `--external:electron` restored and the kill removed, both tests fail ("the bundle requires the real electron package…", "a failed start left the node process running"). Running the built bundle under a node preload that throws on require('electron') prints the ready line in both modes; the old bundle throws. Full protocol suite (gradle test --offline): 311 tests, 0 failures, 0 skipped. Offline app type-check (gradle compileKotlin --offline) passes; the stub type-checks under tsc --strict. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 6 ++- .../src/test/interop/electron-stub.ts | 46 ++++++++++++++++++ .../protocol/src/test/interop/host-fixture.ts | 19 +++++--- .../dev/nodeterm/protocol/InteropHarness.kt | 39 ++++++++++++--- .../nodeterm/protocol/InteropHarnessTest.kt | 48 +++++++++++++++++++ 5 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 android/protocol/src/test/interop/electron-stub.ts create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 17df99dc6e..a042a61176 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -37,7 +37,11 @@ jobs: cache: 'npm' # The interop tests run the desktop's relay host / pairing code through esbuild + node. They - # need tweetnacl, ws and esbuild — not Electron or a rebuilt node-pty, hence no scripts. + # need tweetnacl, ws and esbuild, not a rebuilt node-pty, hence no scripts. They do not need + # Electron either: InteropHarness aliases `electron` to a stub + # (android/protocol/src/test/interop/electron-stub.ts), because the real package's first + # `require` downloads the ~110 MB Electron binary when it is not there yet, as on a fresh + # runner, inside the harness's 20 s ready wait (audit A60). - name: Install desktop dependencies (no scripts) run: npm ci --ignore-scripts diff --git a/android/protocol/src/test/interop/electron-stub.ts b/android/protocol/src/test/interop/electron-stub.ts new file mode 100644 index 0000000000..643d9edcad --- /dev/null +++ b/android/protocol/src/test/interop/electron-stub.ts @@ -0,0 +1,46 @@ +// Stand-in for the `electron` package in the Android interop fixture. InteropHarness.kt bundles +// host-fixture.ts with `--alias:electron=`, so the bundle never `require`s the real one. +// +// Why (audit A60): host-service.ts, host-identity.ts and host-canvas-hub.ts import `electron` at the +// top. Outside Electron, `require('electron')` runs the npm package's index.js, which returns the PATH +// of the Electron binary and, when that binary is missing, first downloads it synchronously: about +// 110 MB from GitHub releases, inside the harness's 20 s wait for the fixture's ready line. The package +// has no install script, so every fresh `npm ci` (a CI runner) lacks the binary; a slow or failed +// download then failed every interop test with a timeout. +// +// The fixture injects everything those modules would otherwise take from Electron (the pty, the +// canvas feed, the host keys, the relay gate), so no member below is reached at run time. Under the +// real package every member was `undefined` here, so any use already failed; each member keeps that, +// but throws an error naming the member instead of a bare TypeError on `undefined`. A no-op would +// instead let a code path that started depending on Electron pass silently (`app.isPackaged` would +// read as a plain falsy value, `ipcMain.on` would register nothing). +// +// Export every member that a src/main module the fixture reaches imports from 'electron'; between +// them the src/main/remote files import these four (plus types, which esbuild erases). A missing one +// fails the esbuild step with "No matching export", which is the intended signal: add it here, do not +// reach for the real package. + +function unavailable(member: string): never { + throw new Error( + `electron.${member} is not available in the Android interop fixture ` + + '(android/protocol/src/test/interop/electron-stub.ts): inject the dependency into the desktop code instead' + ) +} + +function stub(name: string): Record { + return new Proxy(Object.create(null) as Record, { + get(_target, prop) { + // Symbol keys (util.inspect, Symbol.toPrimitive) and `then` (a stray `await`) are probes, not uses. + if (typeof prop === 'symbol' || prop === 'then') return undefined + return unavailable(`${name}.${prop}`) + }, + set(_target, prop) { + return unavailable(`${name}.${String(prop)}`) + } + }) +} + +export const app = stub('app') +export const ipcMain = stub('ipcMain') +export const safeStorage = stub('safeStorage') +export const dialog = stub('dialog') diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index dbd9590fe2..83af6fb26b 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -7,10 +7,13 @@ // serving a FAKE pty/kanban/inbox bridge that records what the phone asked for. // mode "pair": the desktop's real `createPairingService` (src/main/pairing-service.ts) with HOME // pointed at a temp dir by the caller, and a fake `/v1/relay/device` API. +// mode "never-ready": prints nothing and stays alive, so InteropHarnessTest can check that a +// harness whose ready wait fails still kills the process. // // Protocol with the Kotlin test: line 1 on stdout is a JSON "ready" object; every later line is a -// JSON event. Bundled by esbuild at test time (see InteropHarness.kt); `electron` and `ws` stay -// external and resolve from the repo's node_modules. +// JSON event. Bundled by esbuild at test time (see InteropHarness.kt); `ws` stays external and +// resolves from the repo's node_modules, and `electron` is aliased to ./electron-stub.ts, so the +// real package (whose first `require` downloads the Electron binary) is never loaded (audit A60). import http from 'http' import { WebSocketServer, type WebSocket } from 'ws' import { initPlatform } from '../../../../../src/core/platform' @@ -297,7 +300,11 @@ async function runPair(): Promise { } const mode = process.argv[2] -;(mode === 'pair' ? runPair() : runRelay()).catch((err) => { - emit({ event: 'fatal', message: String((err as Error)?.stack ?? err) }) - process.exit(1) -}) +if (mode === 'never-ready') { + setInterval(() => {}, 60_000) +} else { + ;(mode === 'pair' ? runPair() : runRelay()).catch((err) => { + emit({ event: 'fatal', message: String((err as Error)?.stack ?? err) }) + process.exit(1) + }) +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt index b394f6b73b..ce6c5a77d8 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt @@ -12,6 +12,11 @@ import java.util.concurrent.TimeUnit * Runs `src/test/interop/host-fixture.ts` — the DESKTOP's own relay host / pairing code — in a node * child process. Skips (never fails) when this checkout has no node or no `npm ci` yet, so a * JVM-only contributor still gets a green protocol build; CI installs both and runs everything. + * + * The bundle aliases `electron` to `src/test/interop/electron-stub.ts` (audit A60): the real package's + * `require` downloads the Electron binary when it is missing, as it is after any fresh `npm ci` (the + * package has no install script), and that download ran inside the 20 s ready wait. + * [InteropHarnessTest] pins the alias and the kill of a process whose ready wait failed. */ class InteropHarness private constructor(private val process: Process) : AutoCloseable { private val lines = LinkedBlockingQueue() @@ -54,14 +59,20 @@ class InteropHarness private constructor(private val process: Process) : AutoClo await(timeoutMs) { it["event"]?.toString() == "\"$name\"" } override fun close() { + // Taken before node exits: after that its children are re-parented and unreachable from here. + val descendants = process.descendants().toList() process.destroy() if (!process.waitFor(3, TimeUnit.SECONDS)) process.destroyForcibly() + descendants.forEach { it.destroy() } } companion object { val repoRoot: File = File(System.getProperty("nodeterm.repoRoot") ?: "../..").canonicalFile - private val bundle: File by lazy { + /** Where the fixture's `electron` import resolves to instead of the npm package. */ + const val ELECTRON_STUB = "android/protocol/src/test/interop/electron-stub.ts" + + internal val bundle: File by lazy { val out = File(repoRoot, "android/protocol/build/interop/host-fixture.cjs") val esbuild = File(repoRoot, "node_modules/.bin/esbuild") val proc = ProcessBuilder( @@ -69,7 +80,7 @@ class InteropHarness private constructor(private val process: Process) : AutoClo "android/protocol/src/test/interop/host-fixture.ts", "--bundle", "--platform=node", "--format=cjs", "--outfile=${out.path}", - "--external:electron", "--external:ws", + "--alias:electron=./$ELECTRON_STUB", "--external:ws", "--alias:@shared=./src/shared", "--alias:@renderer=./src/renderer", "--log-level=warning" ).directory(repoRoot).redirectErrorStream(true).start() @@ -78,19 +89,35 @@ class InteropHarness private constructor(private val process: Process) : AutoClo out } - private fun available(): Boolean { + internal fun available(): Boolean { val node = runCatching { ProcessBuilder("node", "--version").start().waitFor() == 0 }.getOrDefault(false) return node && File(repoRoot, "node_modules/.bin/esbuild").exists() && File(repoRoot, "node_modules/ws").exists() && File(repoRoot, "node_modules/tweetnacl").exists() } - fun start(mode: String, env: Map = emptyMap()): InteropHarness { + /** + * Starts the fixture in [mode] and waits for its ready line. When that wait fails (a timeout, or + * a `fatal` event), the process is killed before the error propagates: the caller never gets a + * handle, so nothing else would ever close it. [onSpawn] is a test seam for exactly that check. + */ + fun start( + mode: String, + env: Map = emptyMap(), + readyTimeoutMs: Long = 20_000, + onSpawn: (Process) -> Unit = {} + ): InteropHarness { assumeTrue(available(), "node + repo node_modules (npm ci) are needed for interop tests") val pb = ProcessBuilder("node", bundle.path, mode).directory(repoRoot) pb.environment().putAll(env) val h = InteropHarness(pb.start()) - h.startReader() - h.ready = h.await(20_000) { it["ready"] != null } + try { + onSpawn(h.process) + h.startReader() + h.ready = h.await(readyTimeoutMs) { it["ready"] != null } + } catch (t: Throwable) { + h.close() + throw t + } return h } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt new file mode 100644 index 0000000000..bf2f624dbe --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt @@ -0,0 +1,48 @@ +package dev.nodeterm.protocol + +import org.junit.jupiter.api.Assumptions.assumeTrue +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The interop harness itself (audit A60). The fixture must not load the real `electron` package, + * whose first `require` downloads the Electron binary when it is missing (after any fresh `npm ci`), + * inside the harness's ready wait. And a start whose ready wait fails must not leave the node process + * behind: the caller never receives a handle to close. + */ +class InteropHarnessTest { + private val needs = "node + repo node_modules (npm ci) are needed for interop tests" + + @Test + fun `the fixture bundle resolves electron to the stub and never requires the package`() { + assumeTrue(InteropHarness.available(), needs) + val source = InteropHarness.bundle.readText() + assertFalse( + Regex("""require\(\s*["']electron["']\s*\)""").containsMatchIn(source), + "the bundle requires the real electron package; alias it to ${InteropHarness.ELECTRON_STUB}" + ) + assertTrue( + source.contains("is not available in the Android interop fixture"), + "the bundle does not carry ${InteropHarness.ELECTRON_STUB}" + ) + } + + @Test + fun `a start whose ready wait fails kills the fixture process`() { + assumeTrue(InteropHarness.available(), needs) + var spawned: Process? = null + try { + val err = assertFailsWith { + InteropHarness.start("never-ready", readyTimeoutMs = 500, onSpawn = { spawned = it }) + } + assertTrue(err.message.orEmpty().contains("not seen within 500ms"), err.message) + val process = assertNotNull(spawned, "the fixture was never spawned") + assertFalse(process.isAlive, "a failed start left the node process running") + } finally { + spawned?.destroyForcibly() + } + } +} From 77c760da738837e6873cd1a77f1feecc7f299627 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:05:54 +0000 Subject: [PATCH 076/284] fix(android): run the SSH transport tests on macOS, not only Linux (A62) SshTransportTest was Linux-only in two ways, and on a Mac with Homebrew tmux the gate passed and every test failed instead of running or skipping: - Its root came from Files.createTempDirectory under the JVM temp dir, which on a Mac is /var/folders/.../T/. tmux resolves that to /private/var/folders/... before binding, and the socket path passed macOS's 103-character sun_path, so tmux new-session failed with "File name too long" in @BeforeAll and took all tests of the class down. - Every pty exec ran util-linux's `script -qfec /dev/null`. BSD script has no -c. What changed (new test helper SshTestHost.kt, used by the test): - ShortTmuxRoot.create makes the root under /tmp first, then the JVM temp dir, the way src/core/tmux-test-socket.ts makeTmuxTmpdir does: each base is resolved before it is measured, the socket path is measured on the directory actually created with the uid that owns it, and a base without room is skipped. None with room is a failure that names the lengths. On Linux the root stays under /tmp as before. - PtyScript picks the script(1) form. util-linux names itself in --version and keeps exactly the old arguments. BSD is recognised by a probe that must see a tty and get exit status 7 back, and runs `script -q /dev/null /bin/sh -c `. With neither form, or without tmux, the class skips with a reason. The old /usr/bin/script check is gone. Verified: the full protocol suite (318 tests, none skipped) and the offline app type-check pass. New SshTestHostTest covers a symlinked base that looks short but resolves long (macOS's shape), no base with room, the argv of both forms, this machine's script under a pty, a BSD-shaped stand-in (bsd-script-standin.sh, BSD grammar over util-linux's pty) and scripts that answer neither form or give no pty. Mutation checks: on HEAD, SshTransportTest with -Djava.io.tmpdir set to a 136-character dir fails in @BeforeAll with "File name too long", and passes with the fix. Dropping the path resolution, the BSD probe, the probe's tty check, or the BSD argv each turns a SshTestHostTest case red. Nothing has run on a Mac: the BSD form follows its documented grammar and FreeBSD's source. Departure from the audit: it offered gating on util-linux as an alternative. This supports BSD instead and skips only when neither form answers. The BSD form runs the command through /bin/sh (the shell the harness uses without a pty); util-linux keeps running it through $SHELL as before. docs/android.md and android/README.md now say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 3 +- .../dev/nodeterm/protocol/SshTestHost.kt | 133 +++++++++++++++ .../dev/nodeterm/protocol/SshTestHostTest.kt | 151 ++++++++++++++++++ .../dev/nodeterm/protocol/SshTransportTest.kt | 33 ++-- .../src/test/resources/bsd-script-standin.sh | 19 +++ docs/android.md | 8 +- 6 files changed, 332 insertions(+), 15 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHost.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt create mode 100644 android/protocol/src/test/resources/bsd-script-standin.sh diff --git a/android/README.md b/android/README.md index 15cab15b19..bcf25dedd3 100644 --- a/android/README.md +++ b/android/README.md @@ -68,7 +68,8 @@ or your own build.** Release builds will need their own private key; none exists Its tests run the **desktop's own code** on the other end of the wire — `connectHostSession` and `createPairingService` through a local relay broker (`src/test/interop/host-fixture.ts`, bundled with the repo's esbuild), and the SSH transport against a real SSH server and a sandboxed tmux. - Run `npm ci --ignore-scripts` at the repo root first; without node the interop tests skip. + Run `npm ci --ignore-scripts` at the repo root first; without node the interop tests skip. The SSH + tests need tmux and `script(1)` (util-linux on Linux, BSD on macOS) and skip without them. - **`app/`** — the Compose UI on top: pairing, the computers list, a computer's Sessions / Board / Inbox / Usage tabs, the terminal screen, settings, background notifications. diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHost.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHost.kt new file mode 100644 index 0000000000..116a47343c --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHost.kt @@ -0,0 +1,133 @@ +package dev.nodeterm.protocol + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +// What the machine running SshTransportTest has to provide, and the two places where the platforms +// contributors use differ (audit A62): where a tmux socket can be bound, and which `script(1)` stands +// in for sshd's pty. Both were Linux-only, so on a Mac with Homebrew tmux the audit found every test +// of that class failing instead of running or skipping. Only the Linux leg has run here. + +/** + * The longest unix socket path that can be bound. macOS `sys/un.h` declares `sun_path[104]`, which is + * 103 characters plus the NUL; Linux allows 107. The smaller number holds everywhere, as in + * `src/core/tmux-test-socket.ts`: a limit that only bites on a Mac looks like a broken test. + */ +internal const val SUN_PATH_MAX = 103 + +/** + * A private temp root for the SSH transport tests, short enough that `/` works as + * `TMUX_TMPDIR` for `-L `. + * + * The JVM's temp dir on a Mac is `/var/folders/…/T/`, which tmux resolves to `/private/var/folders/…` + * before it binds. Under it the socket path was about 110 characters, and `tmux new-session` failed + * with "File name too long" in `@BeforeAll`. So `/tmp` is tried first (`/private/tmp` on a Mac, + * `/tmp` itself on Linux, where the JVM's temp dir usually already is `/tmp`), and every base is + * resolved before anything is measured. + */ +internal object ShortTmuxRoot { + /** Where tmux binds `-L ` under `TMUX_TMPDIR=` for [uid]. */ + fun socketPath(tmuxDir: File, uid: Int, socket: String): String = "${tmuxDir.path}/tmux-$uid/$socket" + + fun defaultBases(): List = listOf(File("/tmp"), File(System.getProperty("java.io.tmpdir"))) + + /** + * Create the root under the first of [bases] that leaves room for the socket, and return it + * resolved. Each base is resolved first, because tmux resolves its directory before binding and + * measuring the unresolved spelling under-counts. The length is then measured on the directory + * actually created, with the uid that owns it (the uid tmux names its `tmux-` directory + * after). The directory made under a base without room is removed and the next base tried; when + * none has room this throws, naming the lengths, instead of handing back a path that fails at bind + * time. + */ + fun create(prefix: String, tmuxSubdir: String, socket: String, bases: List = defaultBases()): File { + val tried = ArrayList() + for (base in bases.mapNotNull { resolved(it) }.distinct()) { + val root = try { + Files.createTempDirectory(base.toPath(), prefix).toRealPath().toFile() + } catch (e: IOException) { + tried += "$base (${e.message})" + continue + } + val uid = Files.getAttribute(root.toPath(), "unix:uid") as Int + val bound = socketPath(File(root, tmuxSubdir), uid, socket) + if (bound.length <= SUN_PATH_MAX) return root + tried += "$bound (${bound.length} chars)" + root.deleteRecursively() + } + throw IllegalStateException( + "no temp dir leaves room for a tmux socket within $SUN_PATH_MAX chars; tried: ${tried.joinToString("; ")}" + ) + } + + private fun resolved(dir: File): File? = + runCatching { dir.toPath().toRealPath().toFile() }.getOrNull()?.takeIf { it.isDirectory } +} + +/** + * The `script(1)` that gives a pty-requesting exec its pty, standing in for sshd (MINA's process + * bridge has none). The two in the field take different arguments: + * + * - **util-linux** (Linux): `script -qfec /dev/null`. `-c` runs the command through + * `$SHELL -c`, `-e` returns its exit status, `-q` drops the start/done banners. + * - **BSD** (macOS, FreeBSD): `script [-aeFkqr] [-t time] [file [command ...]]`. There is no `-c`: + * the command is the argv after the typescript file, exec'd as is. Its exit status should be the + * command's; [detect] checks that rather than trusting it. The command runs as + * `/bin/sh -c `, the shell the harness uses for commands without a pty. + * + * util-linux writes the terminal's EOF character into the pty when its stdin ends (measured), and + * FreeBSD's `script.c` does the same in canonical mode (macOS unmeasured), which is why the harness + * never closes a pty command's stdin. + */ +internal class PtyScript private constructor(val flavor: Flavor, private val executable: String) { + enum class Flavor { UTIL_LINUX, BSD } + + /** The argv that runs [command] (one shell line) under a fresh pty, with `/dev/null` as the typescript. */ + fun argv(command: String): List = when (flavor) { + Flavor.UTIL_LINUX -> listOf(executable, "-qfec", command, "/dev/null") + Flavor.BSD -> listOf(executable, "-q", "/dev/null", "/bin/sh", "-c", command) + } + + companion object { + const val SKIP_REASON = "the SSH transport tests need tmux and a script(1) that gives a command a pty: " + + "util-linux (Linux) or BSD (macOS)" + + private const val PROBE_SECONDS = 10L + + /** A command whose exit status 7 proves both that it had a tty and that the status came back. */ + internal const val PROBE_COMMAND = "[ -t 0 ] && [ -t 1 ] || exit 3; exit 7" + + fun of(flavor: Flavor, executable: String = "script") = PtyScript(flavor, executable) + + /** + * Which `script` [executable] is, or null for neither (the tests then skip with [SKIP_REASON]). + * util-linux names itself in `--version`, so Linux is recognised without running anything under + * a pty, exactly as before. BSD `script` has no `--version` (an unknown option prints the usage + * and exits 1), so it is recognised by running [PROBE_COMMAND] in its own argument form. + */ + fun detect(executable: String = "script"): PtyScript? { + val version = run(listOf(executable, "--version")) + if (version != null && version.first == 0 && "util-linux" in version.second) return of(Flavor.UTIL_LINUX, executable) + val bsd = of(Flavor.BSD, executable) + return bsd.takeIf { run(it.argv(PROBE_COMMAND))?.first == 7 } + } + + private fun run(argv: List): Pair? = runCatching { + // Run where a typescript some other script(1) might write cannot land in the source tree. + val p = ProcessBuilder(argv).directory(File(System.getProperty("java.io.tmpdir"))) + .redirectErrorStream(true).start() + p.outputStream.close() + val out = StringBuffer() + val reader = Thread { runCatching { out.append(p.inputStream.bufferedReader().readText()) } } + .apply { isDaemon = true; start() } + if (!p.waitFor(PROBE_SECONDS, TimeUnit.SECONDS)) { + p.destroyForcibly() + return@runCatching null + } + reader.join(2_000) + p.exitValue() to out.toString() + }.getOrNull() + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt new file mode 100644 index 0000000000..d7ea3850e7 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt @@ -0,0 +1,151 @@ +package dev.nodeterm.protocol + +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The machine-facing half of [SshTransportTest] (audit A62): the temp root its tmux socket lives in, + * and the `script(1)` that gives its pty commands a pty. Both were Linux-only, so on a Mac the audit + * found every test of that class failing instead of running. Nothing here has run on a Mac: the macOS + * temp dir is stood in for by a symlink to a long directory, and BSD `script` by + * `bsd-script-standin.sh`, which follows BSD's documented argument grammar. + */ +class SshTestHostTest { + private val socket = "node-terminal" + + /** A real directory whose socket path cannot fit, whatever temp root it sits under. */ + private fun longDir(parent: File): File = + File(parent, "x".repeat(maxOf(1, 100 - parent.path.length))).apply { mkdirs() }.toPath().toRealPath().toFile() + + @Test + fun `a base that resolves to a long path is passed over, however short its own spelling`() { + // macOS's shape: the JVM's temp dir is spelled /var/folders/…, but tmux binds under + // /private/var/folders/…, 8 characters longer. Measuring the spelling said it fit when it did not. + val scratch = Files.createTempDirectory("nt-a62").toFile() + val links = Files.createTempDirectory(File("/tmp").toPath(), "nt-l").toFile() + try { + val long = longDir(scratch) + val link = File(links, "t") + Files.createSymbolicLink(link.toPath(), long.toPath()) + assertTrue( + ShortTmuxRoot.socketPath(File(link, "nt-t12345678901234567890/tmux"), 501, socket).length <= SUN_PATH_MAX, + "the stand-in must look short when measured unresolved" + ) + val root = ShortTmuxRoot.create("nt-t", "tmux", socket, bases = listOf(link, File("/tmp"))) + try { + assertEquals(File("/tmp").toPath().toRealPath(), root.toPath().parent, "picked $root") + assertEquals(root.toPath().toRealPath().toFile(), root, "TMUX_TMPDIR is handed over resolved") + val uid = Files.getAttribute(root.toPath(), "unix:uid") as Int + assertTrue(ShortTmuxRoot.socketPath(File(root, "tmux"), uid, socket).length <= SUN_PATH_MAX) + assertEquals(emptyList(), long.list()!!.toList(), "the directory made under the long base was removed") + } finally { + root.deleteRecursively() + } + } finally { + links.deleteRecursively() + scratch.deleteRecursively() + } + } + + @Test + fun `no base with room is a failure naming the lengths, never a path that fails at bind time`() { + val scratch = Files.createTempDirectory("nt-a62").toFile() + try { + val long = longDir(scratch) + val e = assertFailsWith { ShortTmuxRoot.create("nt-t", "tmux", socket, bases = listOf(long)) } + assertTrue(e.message!!.contains("$SUN_PATH_MAX chars") && e.message!!.contains(long.path), e.message) + assertEquals(emptyList(), long.list()!!.toList()) + } finally { + scratch.deleteRecursively() + } + } + + @Test + fun `util-linux keeps the arguments the harness always used`() { + assertEquals( + listOf("script", "-qfec", "stty size", "/dev/null"), + PtyScript.of(PtyScript.Flavor.UTIL_LINUX).argv("stty size") + ) + } + + @Test + fun `BSD gets the command as argv after the typescript file`() { + assertEquals( + listOf("script", "-q", "/dev/null", "/bin/sh", "-c", "stty size"), + PtyScript.of(PtyScript.Flavor.BSD).argv("stty size") + ) + } + + private fun runScript(argv: List): Pair { + val p = ProcessBuilder(argv).redirectErrorStream(true).start() + val out = StringBuffer() + val reader = Thread { runCatching { out.append(p.inputStream.bufferedReader().readText()) } } + .apply { isDaemon = true; start() } + // Like the harness's pty commands, stdin stays open while it runs (its EOF types a ^D). + try { + if (!p.waitFor(10, TimeUnit.SECONDS)) { + p.destroyForcibly() + throw AssertionError("$argv did not exit: $out") + } + } finally { + p.outputStream.close() + } + reader.join(2_000) + return p.exitValue() to out.toString() + } + + /** The command shape [SshTransportTest] runs under a pty: size the pty, then the product's line. */ + private val sizedCommand = "stty cols 100 rows 30 2>/dev/null; stty size; exit 5" + + @Test + fun `this machine's script gives the harness's command a pty and returns its status`() { + val script = PtyScript.detect() + assumeTrue(script != null, PtyScript.SKIP_REASON) + val (code, out) = runScript(script!!.argv(sizedCommand)) + assertEquals(5, code, out) + assertTrue(out.contains("30 100"), out) + } + + private fun executable(dir: File, name: String, text: String) = + File(dir, name).apply { writeText(text); setExecutable(true) } + + @Test + fun `a BSD-shaped script is recognised by its argument form, and runs the harness's command`() { + // The stand-in borrows its pty from util-linux. + assumeTrue(PtyScript.detect()?.flavor == PtyScript.Flavor.UTIL_LINUX, "the BSD stand-in needs util-linux script") + val dir = Files.createTempDirectory("nt-a62").toFile() + try { + val text = assertNotNull(javaClass.getResource("/bsd-script-standin.sh")).readText() + val bsd = executable(dir, "bsd-script", text) + val script = assertNotNull(PtyScript.detect(bsd.path)) + assertEquals(PtyScript.Flavor.BSD, script.flavor) + val (code, out) = runScript(script.argv(sizedCommand)) + assertEquals(5, code, out) + assertTrue(out.contains("30 100"), out) + } finally { + dir.deleteRecursively() + } + } + + @Test + fun `a script that answers neither form, or gives no pty, is not used`() { + val dir = Files.createTempDirectory("nt-a62").toFile() + try { + assertNull(PtyScript.detect(executable(dir, "refuses", "#!/bin/sh\nexit 1\n").path)) + // BSD's argument grammar without a pty: the probe asks for a tty, not just an exit status. + val noPty = executable(dir, "no-pty", "#!/bin/sh\nshift 2\nexec \"\$@\"\n") + assertNull(PtyScript.detect(noPty.path)) + assertNull(PtyScript.detect(File(dir, "missing").path)) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index 9c9c212b5d..3d78ae22ce 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -33,7 +33,6 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.InputStream import java.io.OutputStream -import java.nio.file.Files import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test @@ -46,12 +45,15 @@ import kotlin.test.assertTrue /** * The direct-SSH transport end to end: a real SSH server (Apache MINA) running every command - * through `/bin/sh` as the phone would get from sshd, against a fake HOME laid out like a desktop's + * through a shell as the phone would get from sshd, against a fake HOME laid out like a desktop's * (a v3 workspace index + project files + agent-status.json) and a REAL tmux — on a private * `TMUX_TMPDIR`, so no test ever touches a tmux server a developer is using (issue #629's rule). * - * A pty-requesting exec runs under `script`, standing in for sshd's pty allocation (MINA's process - * bridge has none of its own); that wrapper is harness, the scripts it runs are the product's. + * An exec without a pty runs as `/bin/sh -c`. A pty-requesting exec runs under `script`, standing in + * for sshd's pty allocation (MINA's process bridge has none of its own); that wrapper is harness, the + * scripts it runs are the product's. Linux's util-linux `script` (which runs the command through + * `$SHELL`) and macOS's BSD one take different arguments, and the macOS temp dir is too long for a + * tmux socket: [PtyScript] and [ShortTmuxRoot] handle both (audit A62). */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SshTransportTest { @@ -59,11 +61,11 @@ class SshTransportTest { private lateinit var home: File private lateinit var tmuxDir: File private lateinit var server: SshServer + private lateinit var ptyScript: PtyScript private val identity = SshIdentity.generate() private var port = 0 - private fun tmuxAvailable() = runCatching { ProcessBuilder("tmux", "-V").start().waitFor() == 0 }.getOrDefault(false) && - File("/usr/bin/script").exists() + private fun tmuxAvailable() = runCatching { ProcessBuilder("tmux", "-V").start().waitFor() == 0 }.getOrDefault(false) // No locale at all, like an sshd exec channel on a stock macOS host (audit A03): passing the JVM's // own LANG through is what hid that bug. @@ -102,7 +104,7 @@ class SshTransportTest { val cols = env.env["COLUMNS"] ?: "80" val lines = env.env["LINES"] ?: "24" val pty = env.env.containsKey("TERM") - val argv = if (pty) listOf("script", "-qfec", "stty cols $cols rows $lines 2>/dev/null; $command", "/dev/null") + val argv = if (pty) ptyScript.argv("stty cols $cols rows $lines 2>/dev/null; $command") else listOf("/bin/sh", "-c", command) val pb = ProcessBuilder(argv).directory(home) pb.environment().clear() @@ -123,9 +125,10 @@ class SshTransportTest { }.apply { isDaemon = true; start() } val outPump = pump(p.inputStream, output, false) val errPump = pump(p.errorStream, error, false) - // For a pty session, channel EOF must NOT become stdin EOF: util-linux `script` turns that - // into a ^D typed into the pty (measured), which ends the pane's shell — a harness artifact - // real sshd does not have (it closes the pty and the tmux client just detaches). + // For a pty session, channel EOF must NOT become stdin EOF: `script` turns that into a ^D + // typed into the pty (util-linux measured; FreeBSD's source does the same), which ends the + // pane's shell — a harness artifact real sshd does not have (it closes the pty and the tmux + // client just detaches). pump(input, p.outputStream, !pty) Thread { val code = p.waitFor() @@ -143,8 +146,12 @@ class SshTransportTest { @BeforeAll fun setUp() { - assumeTrue(tmuxAvailable(), "tmux + script are needed for the SSH transport tests") - root = Files.createTempDirectory("nt-ssh").toFile() + assumeTrue(tmuxAvailable(), "${PtyScript.SKIP_REASON}; no tmux on PATH") + val script = PtyScript.detect() + assumeTrue(script != null, "${PtyScript.SKIP_REASON}; `script` answered neither form") + ptyScript = script!! + // Short and resolved, so the tmux socket fits in sun_path on a Mac too (audit A62). + root = ShortTmuxRoot.create("nt-ssh", "tmux", "node-terminal") home = File(root, "home").apply { mkdirs() } tmuxDir = File(root, "tmux").apply { mkdirs() } server = SshServer.setUpDefaultServer() @@ -582,7 +589,7 @@ class SshTransportTest { fun `the attach script itself refuses to create a missing session`() { // Belt and braces for the race where the session ends between the check and the attach. val (code, _) = run { - val pb = ProcessBuilder("script", "-qfec", SshScripts.attach("term-y-8"), "/dev/null").directory(home) + val pb = ProcessBuilder(ptyScript.argv(SshScripts.attach("term-y-8"))).directory(home) pb.environment().clear() pb.environment().putAll(childEnv()) val p = pb.start() diff --git a/android/protocol/src/test/resources/bsd-script-standin.sh b/android/protocol/src/test/resources/bsd-script-standin.sh new file mode 100644 index 0000000000..4542196655 --- /dev/null +++ b/android/protocol/src/test/resources/bsd-script-standin.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# A stand-in for BSD script(1), for SshTestHostTest on a machine that only has util-linux's. +# It follows BSD's argument grammar, `script [-aeFkqr] [-t time] [file [command ...]]`: no -c and no +# --version (an unknown option prints the usage and exits 1), and the command is the argv after the +# typescript file. The pty itself is borrowed from util-linux, so that argv is quoted back into one line. +while getopts aeFkqrt: o; do + if [ "$o" = '?' ]; then + echo 'usage: script [-aeFkqr] [-t time] [file [command ...]]' >&2 + exit 1 + fi +done +shift $((OPTIND - 1)) +file=$1 +shift +line='' +for a in "$@"; do + line="$line '$(printf '%s' "$a" | sed "s/'/'\\\\''/g")'" +done +exec script -qec "$line" "$file" diff --git a/docs/android.md b/docs/android.md index 87362ecf6d..e10b13c451 100644 --- a/docs/android.md +++ b/docs/android.md @@ -107,9 +107,15 @@ unchanged. above 64 KiB or below zero before anything is allocated, a body with no length stops at 64 KiB, and the whole exchange ends at a 45 s deadline (or when the caller is cancelled) by closing the socket, so a server that trickles bytes cannot hold the pairing screen. -- **SSH** — against Apache MINA sshd running every command through `/bin/sh`, with real tmux on a +- **SSH** — against Apache MINA sshd running every command through a shell, with real tmux on a private `TMUX_TMPDIR`: v3 index resolution, attach with keystrokes both ways, cold-start detection, literal `send-keys` (a leading `-` is text), answer files, read-acks, host-key pinning. + A command without a pty runs as `/bin/sh -c `. One that asks for a pty runs under `script(1)` + in place of sshd's pty: util-linux's `script -qfec /dev/null` on Linux (which runs `` + through `$SHELL`), BSD's `script -q /dev/null /bin/sh -c ` on macOS. They are told apart by + `script --version` and a probe (`SshTestHost.kt`); with neither, or without tmux, the class skips. + The temp root is short and resolved (`/tmp` first): under macOS's `/private/var/folders/…` the + socket path passed the 103-character `sun_path` limit (audit A62). Only the Linux leg has run. One tmux fact measured along the way: an exact-match **pane** target is `=name:`, not `=name` (tmux 3.4 answers "can't find pane"). From 68d5da6d4fa0a8216ada2d9c421a0cae0ce930b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:14:04 +0000 Subject: [PATCH 077/284] fix(android): let the interop tests run on Windows, off the real profile (A70) The interop harness bundled host-fixture.ts by spawning node_modules/.bin/esbuild. On Windows that is an extensionless sh shim beside esbuild.cmd, which CreateProcess cannot run, and available() only checked that the file existed, so every interop test errored with an IOException instead of running or skipping. What changed: - New android/protocol/src/test/interop/bundle-fixture.cjs bundles the fixture through esbuild's JS API with the same options the CLI call passed (electron -> stub, ws external, @shared/@renderer aliases). The harness now runs `node bundle-fixture.cjs `; every process it starts is node. available() gates on node_modules/esbuild (the package the JS API needs), not on the .bin shim. The output is byte-identical to the old CLI bundle (compared with cmp). - Pair mode passes `platform: 'linux'` to createPairingService when the fixture runs on win32 (process.platform otherwise, so Linux and macOS run exactly what they did): on win32 the service pairs relay-only (ssh:false, no key), which pairing-service.windows.test.ts covers, and the tests here expect the direct-SSH path. - PairingInteropTest now points HOME and USERPROFILE at its temp home (InteropHarness.scratchHomeEnv): os.homedir() reads USERPROFILE on Windows, so with HOME alone a Windows run would have written a paired test device with a live bearer token into the contributor's real %USERPROFILE%\.nodeterm\agent.json and ~/.ssh/authorized_keys. - Pair mode refuses to start unless os.homedir() equals FIXTURE_HOME, so a caller that sets the wrong home variable, on any OS, fails before anything is written. The other spawns in the test tree were checked: TerminalJsDriver and the fixture start already run `node` (node.exe on PATH); the SSH tests are gated on tmux, script(1) and /bin/sh. Tests (all fail without the matching part of the fix; each was mutation-checked by reverting that part and rerunning): - InteropHarnessTest: the bundle step's argv starts with node and names no node_modules file (fails with the old .bin/esbuild argv); the scratch home sets HOME, USERPROFILE and FIXTURE_HOME (fails without USERPROFILE). - PairingInteropTest: pair mode refuses a home that is not FIXTURE_HOME and writes nothing (fails with the guard disabled); with FIXTURE_PROCESS_PLATFORM=win32 (a fixture seam standing in for what the fixture and the service read from process.platform) the QR still offers SSH and the key lands in the temp home's authorized_keys (fails without the platform pin). Verified: gradle test --offline in android/protocol (322 tests, 0 skipped, 0 failed); gradle compileKotlin --offline in android/tools/typecheck; host-fixture.ts type-checked with tsc through a temporary config extending tsconfig.node.json (it is outside npm run typecheck's includes). Not run on Windows: no Windows machine here, and the Android CI job runs on ubuntu only. Departures from the audit: its proposal was `node node_modules/esbuild/bin/esbuild ...`; that breaks Linux and macOS installs where esbuild's install script ran (it replaces bin/esbuild with the native executable, which node cannot run; CI dodges it only because of --ignore-scripts), so the JS API is used instead, which the verifier also allowed. Of the verifier's two options for the pairing tests, the fixture pins the platform and the test sets USERPROFILE (the tests keep running on Windows) rather than gating them off, and the home guard is added on top. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../src/test/interop/bundle-fixture.cjs | 51 +++++++++++++++++++ .../protocol/src/test/interop/host-fixture.ts | 26 ++++++++-- .../dev/nodeterm/protocol/InteropHarness.kt | 37 ++++++++++---- .../nodeterm/protocol/InteropHarnessTest.kt | 28 ++++++++-- .../nodeterm/protocol/PairingInteropTest.kt | 51 +++++++++++++++++-- 5 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 android/protocol/src/test/interop/bundle-fixture.cjs diff --git a/android/protocol/src/test/interop/bundle-fixture.cjs b/android/protocol/src/test/interop/bundle-fixture.cjs new file mode 100644 index 0000000000..88d43bf386 --- /dev/null +++ b/android/protocol/src/test/interop/bundle-fixture.cjs @@ -0,0 +1,51 @@ +// Bundles host-fixture.ts for InteropHarness.kt through esbuild's JS API, so the harness only ever +// spawns `node` (audit A70). +// +// Usage (cwd = the repo root): node bundle-fixture.cjs +// +// Why not the `esbuild` command: `node_modules/.bin/esbuild` is an npm shim, and on Windows the +// extensionless one is a sh script beside esbuild.cmd. CreateProcess (what Java's ProcessBuilder +// uses) runs neither, so bundling threw an IOException and every interop test errored on Windows +// instead of running. Why not `node node_modules/esbuild/bin/esbuild` either: esbuild's install +// script replaces that JS file with the native executable on Linux and macOS (skipped only under +// `--ignore-scripts` or yarn), and node cannot run a native executable. The JS API resolves the +// platform's esbuild binary itself on every OS. +// +// The options are the ones the harness used to pass on the command line, one for one: `electron` +// aliased to the stub (audit A60), `ws` external (it resolves from the repo's node_modules at run +// time), and the repo's two path aliases. Relative alias targets resolve against the cwd, as they did +// for the CLI. +'use strict' +const path = require('path') + +const [outfile, electronStub] = process.argv.slice(2) +if (!outfile || !electronStub) { + console.error('usage: node bundle-fixture.cjs ') + process.exit(2) +} + +const root = process.cwd() +// Resolved from the repo root, the tree whose node_modules the harness checked for esbuild. +const esbuild = require(require.resolve('esbuild', { paths: [root] })) + +esbuild + .build({ + absWorkingDir: root, + entryPoints: ['android/protocol/src/test/interop/host-fixture.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: path.resolve(root, outfile), + alias: { + electron: './' + electronStub, + '@shared': './src/shared', + '@renderer': './src/renderer' + }, + external: ['ws'], + logLevel: 'warning' + }) + .catch(() => { + // esbuild has already printed the errors (logLevel 'warning' includes them) to stderr, which the + // harness folds into its failure message. + process.exitCode = 1 + }) diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index 83af6fb26b..f52d10a733 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -5,8 +5,10 @@ // frames preserving text/binary — all the real relay does) plus the desktop's // `connectHostSession` (src/main/remote/host-service.ts → relay-socket.ts host role) // serving a FAKE pty/kanban/inbox bridge that records what the phone asked for. -// mode "pair": the desktop's real `createPairingService` (src/main/pairing-service.ts) with HOME -// pointed at a temp dir by the caller, and a fake `/v1/relay/device` API. +// mode "pair": the desktop's real `createPairingService` (src/main/pairing-service.ts) with the +// home dir pointed at a temp dir by the caller (HOME, and USERPROFILE for Windows, see +// InteropHarness.scratchHomeEnv; refused unless `os.homedir()` is FIXTURE_HOME), and a +// fake `/v1/relay/device` API. It exercises the direct-SSH pairing path on every OS. // mode "never-ready": prints nothing and stays alive, so InteropHarnessTest can check that a // harness whose ready wait fails still kills the process. // @@ -15,6 +17,8 @@ // resolves from the repo's node_modules, and `electron` is aliased to ./electron-stub.ts, so the // real package (whose first `require` downloads the Electron binary) is never loaded (audit A60). import http from 'http' +import os from 'os' +import path from 'path' import { WebSocketServer, type WebSocket } from 'ws' import { initPlatform } from '../../../../../src/core/platform' import { genKeyPair, publicKeyToB64 } from '../../../../../src/main/remote/e2ee' @@ -265,6 +269,19 @@ async function runRelay(): Promise { // ---- mode "pair" ----------------------------------------------------------------------------------- async function runPair(): Promise { + // Test seam: the OS as the fixture and the pairing service see it. Both read `process.platform` at + // call time; node's own modules captured the real one at startup and are unaffected. Lets a Linux + // run check what a Windows run of this mode does (PairingInteropTest, audit A70). + const asPlatform = process.env.FIXTURE_PROCESS_PLATFORM + if (asPlatform) Object.defineProperty(process, 'platform', { value: asPlatform }) + // The service writes `.nodeterm/agent.json` (a device entry carrying a live bearer token) and + // `.ssh/authorized_keys` under `os.homedir()`, which reads HOME on POSIX and USERPROFILE on Windows. + // Refuse to start unless the caller named that dir as its scratch home, so a caller that set the + // wrong variable fails here instead of pairing a test device into a real profile (audit A70). + const scratch = process.env.FIXTURE_HOME + if (!scratch || path.resolve(os.homedir()) !== path.resolve(scratch)) { + throw new Error(`pair mode needs os.homedir() to be FIXTURE_HOME; it is ${os.homedir()}, FIXTURE_HOME is ${scratch ?? 'unset'}`) + } const keys = genKeyPair() const api = http.createServer((req, res) => { let body = '' @@ -293,7 +310,10 @@ async function runPair(): Promise { pinRelayKey: async (pub) => emit({ event: 'pin', pub }), unpinRelayKey: async (pub) => emit({ event: 'unpin', pub }) }, - { timeoutMs: 60_000 } + // The direct-SSH path on every OS (audit A70): on win32 the service pairs relay-only (no key, the + // QR says `ssh:false`), which pairing-service.windows.test.ts covers. `platform` only separates + // win32 from the rest, so Linux and macOS run exactly what they always did. + { timeoutMs: 60_000, platform: process.platform === 'win32' ? 'linux' : process.platform } ) const started = await service.start((done) => emit({ event: 'done', ...done })) emit({ ready: true, payload: started.payload, hostPublicKeyB64: publicKeyToB64(keys.publicKey), relayPlan: started.relayPlan }) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt index ce6c5a77d8..fd5a40611f 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt @@ -17,6 +17,9 @@ import java.util.concurrent.TimeUnit * `require` downloads the Electron binary when it is missing, as it is after any fresh `npm ci` (the * package has no install script), and that download ran inside the 20 s ready wait. * [InteropHarnessTest] pins the alias and the kill of a process whose ready wait failed. + * + * Every process it starts is `node` itself, bundling included (audit A70): npm's `.bin` shims are sh + * scripts on Windows, which Java's ProcessBuilder (CreateProcess) cannot run. */ class InteropHarness private constructor(private val process: Process) : AutoCloseable { private val lines = LinkedBlockingQueue() @@ -72,26 +75,38 @@ class InteropHarness private constructor(private val process: Process) : AutoClo /** Where the fixture's `electron` import resolves to instead of the npm package. */ const val ELECTRON_STUB = "android/protocol/src/test/interop/electron-stub.ts" + /** Bundles host-fixture.ts through esbuild's JS API; see its header for why not the CLI (audit A70). */ + const val BUNDLER = "android/protocol/src/test/interop/bundle-fixture.cjs" + + /** + * The bundle step's argv. Its program is `node`, never a file under node_modules: npm's + * `.bin/esbuild` is a sh shim on Windows, which CreateProcess cannot run, so every interop test + * errored there instead of running (audit A70). `node` resolves to node.exe on PATH. + */ + internal fun bundleCommand(out: File): List = listOf("node", BUNDLER, out.path, ELECTRON_STUB) + internal val bundle: File by lazy { val out = File(repoRoot, "android/protocol/build/interop/host-fixture.cjs") - val esbuild = File(repoRoot, "node_modules/.bin/esbuild") - val proc = ProcessBuilder( - esbuild.path, - "android/protocol/src/test/interop/host-fixture.ts", - "--bundle", "--platform=node", "--format=cjs", - "--outfile=${out.path}", - "--alias:electron=./$ELECTRON_STUB", "--external:ws", - "--alias:@shared=./src/shared", "--alias:@renderer=./src/renderer", - "--log-level=warning" - ).directory(repoRoot).redirectErrorStream(true).start() + val proc = ProcessBuilder(bundleCommand(out)).directory(repoRoot).redirectErrorStream(true).start() val log = proc.inputStream.bufferedReader().readText() check(proc.waitFor() == 0) { "esbuild failed: $log" } out } + /** + * Home-directory variables pointing a fixture at [home]. `os.homedir()` reads HOME on POSIX and + * USERPROFILE on Windows, and pair mode writes `.nodeterm/agent.json` (a device with a live bearer + * token) and `.ssh/authorized_keys` under it: with HOME alone, a Windows run would write into the + * contributor's real profile (audit A70). FIXTURE_HOME is what the fixture checks `os.homedir()` + * against before pair mode writes anything. + */ + fun scratchHomeEnv(home: File): Map = + mapOf("HOME" to home.path, "USERPROFILE" to home.path, "FIXTURE_HOME" to home.path) + internal fun available(): Boolean { val node = runCatching { ProcessBuilder("node", "--version").start().waitFor() == 0 }.getOrDefault(false) - return node && File(repoRoot, "node_modules/.bin/esbuild").exists() && + // The esbuild PACKAGE (its JS API), not the .bin shim the harness no longer runs. + return node && File(repoRoot, "node_modules/esbuild/package.json").exists() && File(repoRoot, "node_modules/ws").exists() && File(repoRoot, "node_modules/tweetnacl").exists() } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt index bf2f624dbe..ee48036d98 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt @@ -1,21 +1,43 @@ package dev.nodeterm.protocol import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * The interop harness itself (audit A60). The fixture must not load the real `electron` package, + * The interop harness itself (audits A60, A70). The fixture must not load the real `electron` package, * whose first `require` downloads the Electron binary when it is missing (after any fresh `npm ci`), - * inside the harness's ready wait. And a start whose ready wait fails must not leave the node process - * behind: the caller never receives a handle to close. + * inside the harness's ready wait. A start whose ready wait fails must not leave the node process + * behind: the caller never receives a handle to close. And the harness must run on Windows too: it + * spawns only `node`, and a scratch home covers the home variable Windows reads. */ class InteropHarnessTest { private val needs = "node + repo node_modules (npm ci) are needed for interop tests" + @Test + fun `the bundle step runs node, never an npm shim`() { + // A70: node_modules/.bin/esbuild is a sh script on Windows, which CreateProcess cannot run, so + // the bundle step threw and every interop test errored there instead of skipping or running. + val argv = InteropHarness.bundleCommand(File("out.cjs")) + assertEquals("node", argv.first(), "the bundle step must spawn node: $argv") + assertTrue(argv.none { it.contains("node_modules") }, "the bundle step names a node_modules file: $argv") + assertTrue(File(InteropHarness.repoRoot, argv[1]).isFile, "no bundler script at ${argv[1]}") + } + + @Test + fun `a scratch home points the home variable of every OS at the dir`() { + // A70: os.homedir() reads HOME on POSIX and USERPROFILE on Windows; pair mode writes a live + // bearer token under it, so a run must never fall through to the contributor's real profile. + val home = File("scratch-home") + val env = InteropHarness.scratchHomeEnv(home) + for (name in listOf("HOME", "USERPROFILE", "FIXTURE_HOME")) assertEquals(home.path, env[name], name) + } + @Test fun `the fixture bundle resolves electron to the stub and never requires the package`() { assumeTrue(InteropHarness.available(), needs) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt index 8d5e2d066b..fa714d5fa1 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt @@ -9,6 +9,7 @@ import dev.nodeterm.protocol.pairing.SshIdentity import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assumptions.assumeTrue import java.io.File import java.io.IOException import java.io.InputStream @@ -31,9 +32,11 @@ import kotlin.test.assertTrue /** * The phone's `/pair` exchange against the DESKTOP's real `createPairingService` - * (src/main/pairing-service.ts), with HOME pointed at a temp dir so the authorized_keys line it - * writes can be inspected. Covers the E2EE-sealed exchange (the QR carries `hostKey`) with and - * without a relay leg. + * (src/main/pairing-service.ts), with the home dir pointed at a temp dir so the authorized_keys line + * it writes can be inspected, and so no run pairs a test device into a real profile + * ([InteropHarness.scratchHomeEnv]: HOME and, for Windows, USERPROFILE). Covers the E2EE-sealed + * exchange (the QR carries `hostKey`) with and without a relay leg, on the direct-SSH path whatever OS + * runs it (the fixture pins a non-Windows platform; audit A70). */ class PairingInteropTest { private val cleanup = ArrayList() @@ -41,17 +44,37 @@ class PairingInteropTest { @AfterTest fun tearDown() = cleanup.forEach { it.close() } - private fun start(withRelay: Boolean): Pair { + private fun start(withRelay: Boolean, env: Map = emptyMap()): Pair { val home = Files.createTempDirectory("nt-pair-home").toFile() cleanup += AutoCloseable { home.deleteRecursively() } val h = InteropHarness.start( "pair", - mapOf("HOME" to home.path, "FIXTURE_USERDATA" to home.path, "FIXTURE_RELAY" to if (withRelay) "1" else "0") + InteropHarness.scratchHomeEnv(home) + + mapOf("FIXTURE_USERDATA" to home.path, "FIXTURE_RELAY" to if (withRelay) "1" else "0") + env ) cleanup += h return h to home } + @Test + fun `pair mode refuses to start unless the home dir is the scratch dir it was given`() { + // A70: os.homedir() reads HOME on POSIX and USERPROFILE on Windows. A caller that points the + // wrong variable at its temp dir must fail here, before the service writes agent.json (a live + // bearer token) and authorized_keys into whatever the real home is. + assumeTrue(InteropHarness.available(), "node + repo node_modules (npm ci) are needed for interop tests") + val home = Files.createTempDirectory("nt-pair-home").toFile() + val other = Files.createTempDirectory("nt-pair-other").toFile() + cleanup += AutoCloseable { home.deleteRecursively(); other.deleteRecursively() } + val err = assertFailsWith { + InteropHarness.start( + "pair", + InteropHarness.scratchHomeEnv(home) + mapOf("FIXTURE_HOME" to other.path, "FIXTURE_USERDATA" to home.path) + ) + } + assertTrue(err.message.orEmpty().contains("needs os.homedir() to be FIXTURE_HOME"), err.message) + assertTrue(home.listFiles().isNullOrEmpty() && other.listFiles().isNullOrEmpty(), "a refused start wrote files") + } + private fun payloadOf(h: InteropHarness): PairingPayload = assertNotNull(PairingPayload.parse(h.ready["payload"]!!.jsonPrimitive.content), "the desktop's QR must parse") @@ -79,6 +102,24 @@ class PairingInteropTest { assertTrue(agent.contains("\"relayDeviceId\": \"android-device-1\""), agent) } + @Test + fun `on a Windows host the fixture still pairs over the direct-SSH path`() = runBlocking { + // A70: on win32 the desktop pairs relay-only (no key, the QR says ssh:false; covered by + // src/main/pairing-service.windows.test.ts), so without the fixture pinning a non-Windows + // platform every test here that expects an SSH key would fail on Windows. The fixture and the + // service read process.platform at call time, which FIXTURE_PROCESS_PLATFORM stands in for. + val (h, home) = start(withRelay = false, env = mapOf("FIXTURE_PROCESS_PLATFORM" to "win32")) + val payload = payloadOf(h) + assertTrue(payload.sshAvailable, "the QR must offer SSH") + val identity = SshIdentity.generate() + val result = PairingClient().pair(payload, identity.authorizedKeysLine(), "Pixel", "android-device-6") + assertEquals("true", h.awaitEvent("done")["ok"]!!.jsonPrimitive.content) + assertTrue( + File(home, ".ssh/authorized_keys").readText().contains("nodeterm-ios-${result.deviceId}"), + "the key must land in the scratch home's authorized_keys" + ) + } + @Test fun `with remote access on, the sealed answer carries the relay leg`() = runBlocking { val (h, _) = start(withRelay = true) From 696fd102c90c8d2fffa3ba25c62b36de8b900402 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:21:38 +0000 Subject: [PATCH 078/284] fix(android): type-check the interop fixture against the desktop interfaces (A67) The Android interop fixture (android/protocol/src/test/interop/host-fixture.ts) implements the interfaces host-service.ts takes from its callers (HostPtyManager, the kanban/inbox/nodeActions bridge) and drives connectHostSession and createPairingService. No tsconfig included it, and InteropHarness bundles it with esbuild, which strips types without checking them, so a desktop interface change could break the fixture's fit with no check noticing. The Android workflow's path filter also skips most of src/, so not even the runtime interop tests ran on such a change. - tsconfig.node.json includes android/protocol/src/test/interop/**/*.ts, so `npm run typecheck` (ci.yml, every PR, Linux and Windows) checks the fixture and the A60 electron stub. Both already compiled clean; no type error had to be fixed. - HostSessionOptions.pty is typed HostPtyManager instead of PtyManager. connectHostSession only hands it to createHostHandlers, which already took HostPtyManager, so both hosts still pass the real PtyManager and tsc still checks PtyManager's fit to the interface at those call sites. The fixture drops its `pty as unknown as PtyManager` cast: with the cast, connectHostSession could use any PtyManager member and the fixture would still compile, then crash at run time. - The pairing mode's `getSettings: () => ({ phoneAccessEnabled }) as never` becomes `{ ...DEFAULT_SETTINGS, phoneAccessEnabled }`, a real Settings, so a renamed field fails tsc. - New src/main/remote/android-interop-fixture.guard.test.ts: asks tsc (`--listFilesOnly`) that every .ts file under the interop dir is in the node project, and fails on an `as unknown as`, `as never` or `as any` in those files. - android/README.md, CONTRIBUTING.md and CLAUDE.md say the fixture is part of `npm run typecheck`. Verified: npm run typecheck passes; vitest src/main/remote, src/main/pairing-service*, the line-endings, no-electron and tmux-isolation guards pass (58 files); gradle test --offline in android/protocol passes in full (322 tests, 0 skipped, interop tests included); the offline app type-check (android/tools/typecheck compileKotlin) passes. Mutation checks: (1) with a fixture made to return a number from attachDetached and a string from setCardColumn, typecheck fails with the new include and passes silently with HEAD's tsconfig; (2) a connectHostSession that calls opts.pty.killAll() fails typecheck with the new option type and compiles on HEAD (option PtyManager plus the fixture's cast); (3) removing the include line fails the guard's first test; restoring both casts fails its second test. Departures from the audit: none from the include itself. The cast is tightened by narrowing the option type rather than by a `satisfies` in the fixture, because the option demanded more than the session uses; the `as never` settings cast was tightened too, and a guard test was added. The android.yml path filter (verifier note) is left to A63. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- CLAUDE.md | 16 ++-- CONTRIBUTING.md | 4 +- android/README.md | 3 +- .../protocol/src/test/interop/host-fixture.ts | 10 ++- .../android-interop-fixture.guard.test.ts | 83 +++++++++++++++++++ src/main/remote/host-service.ts | 6 +- tsconfig.node.json | 7 +- 7 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 src/main/remote/android-interop-fixture.guard.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ef03467ebe..612fabf0c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5398,12 +5398,16 @@ sessions is a hazard whatever kills it; this removes the hazard, not a proven ca mirror file, or the SSH-visible file contracts (`~/.nodeterm/pending`, `~/.nodeterm/acks`, `~/.nodeterm/relay.json`) owes the Android client (and its interop fixture, `android/protocol/src/test/interop/host-fixture.ts`) in the same PR**; the Android workflow - (`.github/workflows/android.yml`) runs on those paths. Two relay verbs exist for a relay-only - phone and must not be dropped: `approvals.answer` (answers a held hook-reply approval through - the SAME `answerPermission` the canvas button uses — typing `1` is wrong there, the prompt is - not on screen while the hook holds it) and `inbox.ack` (the read-ack the `~/.nodeterm/acks` - sweep runs for an SSH phone). An exact-match tmux PANE target is `=name:`; `=name` alone is - refused ("can't find pane", measured on 3.4) — session commands take `=name`. + (`.github/workflows/android.yml`) runs on those paths. The fixture implements host-service's + interfaces (`HostPtyManager`, the kanban/inbox/nodeActions bridge) and esbuild only strips its + types, so it sits in `tsconfig.node.json` and `npm run typecheck` checks it on every CI run; + it passes nothing through a cast (`android-interop-fixture.guard.test.ts`, audit A67). Two + relay verbs exist for a relay-only phone and must not be dropped: `approvals.answer` (answers + a held hook-reply approval through the SAME `answerPermission` the canvas button uses — typing + `1` is wrong there, the prompt is not on screen while the hook holds it) and `inbox.ack` (the + read-ack the `~/.nodeterm/acks` sweep runs for an SSH phone). An exact-match tmux PANE target + is `=name:`; `=name` alone is refused ("can't find pane", measured on 3.4) — session commands + take `=name`. **The canvas and the kanban board are TWO VIEWS of the same nodes — treat the board as a first-class surface, not an afterthought.** Every session/node feature you add to a canvas node diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b26ce1276..0ae76b47e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,9 @@ applicable here": a phone sees — a `host-service.ts` relay verb, the `projects.list` blob, the pairing payload, the agent-status mirror, or the `~/.nodeterm/{pending,acks,relay.json}` files — update the Android client in the same PR; `./gradlew -p protocol test` (from `android/`, after - `npm ci --ignore-scripts`) runs it against this repo's own host code. + `npm ci --ignore-scripts`) runs it against this repo's own host code. Its interop fixture + (`android/protocol/src/test/interop/`) implements host-service's interfaces and is part of + `npm run typecheck`, so changing one of them fails the typecheck there until the fixture follows. Anything reachable from `window.nodeTerminal` needs a **real** implementation in `src/renderer/bridge/`, or a deliberate, documented degrade. The `satisfies NodeTerminalApi` gate diff --git a/android/README.md b/android/README.md index bcf25dedd3..a57c4664cd 100644 --- a/android/README.md +++ b/android/README.md @@ -67,7 +67,8 @@ or your own build.** Release builds will need their own private key; none exists RPC vocabulary, pairing, the direct-SSH transport, and the parsers for what the computer serves. Its tests run the **desktop's own code** on the other end of the wire — `connectHostSession` and `createPairingService` through a local relay broker (`src/test/interop/host-fixture.ts`, bundled - with the repo's esbuild), and the SSH transport against a real SSH server and a sandboxed tmux. + with the repo's esbuild and type-checked by the repo's `npm run typecheck` against the desktop + interfaces it implements), and the SSH transport against a real SSH server and a sandboxed tmux. Run `npm ci --ignore-scripts` at the repo root first; without node the interop tests skip. The SSH tests need tmux and `script(1)` (util-linux on Linux, BSD on macOS) and skip without them. - **`app/`** — the Compose UI on top: pairing, the computers list, a computer's Sessions / Board / diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index f52d10a733..bf26582fdb 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -16,6 +16,10 @@ // JSON event. Bundled by esbuild at test time (see InteropHarness.kt); `ws` stays external and // resolves from the repo's node_modules, and `electron` is aliased to ./electron-stub.ts, so the // real package (whose first `require` downloads the Electron binary) is never loaded (audit A60). +// esbuild only strips types, so this file is type-checked by `npm run typecheck` as part of +// tsconfig.node.json (audit A67): a desktop interface it implements cannot drift from it unseen. +// Do not cast what it hands to the desktop code (`as unknown as`, `as never`); a cast turns that +// check off for the value, and the drift then shows up only at run time. import http from 'http' import os from 'os' import path from 'path' @@ -31,7 +35,7 @@ import { createHostNewSessions } from '../../../../../src/main/remote/host-new-s import { mirrorClaudeAccount } from '../../../../../src/core/agent-status-mirror' import { createPairingService } from '../../../../../src/main/pairing-service' import type { DetachedSinks } from '../../../../../src/core/pty-manager' -import type { PtyManager } from '../../../../../src/core/pty-manager' +import { DEFAULT_SETTINGS } from '../../../../../src/shared/types' const emit = (obj: unknown): void => { process.stdout.write(JSON.stringify(obj) + '\n') @@ -204,7 +208,7 @@ async function runRelay(): Promise { url: `ws://127.0.0.1:${port}`, token: 'host-room', ourKeys: keys, - pty: pty as unknown as PtyManager, + pty, getLatestCanvas: () => null, subscribeCanvas: () => () => {}, applyMutation: () => {}, @@ -301,7 +305,7 @@ async function runPair(): Promise { const withRelay = process.env.FIXTURE_RELAY === '1' const service = createPairingService( { - getSettings: () => ({ phoneAccessEnabled: withRelay }) as never, + getSettings: () => ({ ...DEFAULT_SETTINGS, phoneAccessEnabled: withRelay }), getEntitlement: () => null, loadHostKeyPair: async () => keys, relayEndpoint: 'wss://relay.example.test', diff --git a/src/main/remote/android-interop-fixture.guard.test.ts b/src/main/remote/android-interop-fixture.guard.test.ts new file mode 100644 index 0000000000..62edb846d8 --- /dev/null +++ b/src/main/remote/android-interop-fixture.guard.test.ts @@ -0,0 +1,83 @@ +// The Android companion's interop fixture (android/protocol/src/test/interop/host-fixture.ts) runs +// this directory's real host code against the Kotlin client: `connectHostSession` over a local relay +// broker and `createPairingService` for the pairing tests. To do that it implements the interfaces +// host-service.ts takes from its callers — HostPtyManager and the kanban / inbox / nodeActions bridge. +// +// Nothing checked it against them (audit A67). InteropHarness.kt bundles the fixture with esbuild, +// which strips types without checking them; no tsconfig included the file; and the Android workflow's +// path filter skips most of src/, so a desktop change could break the fixture's fit to an interface +// without any check noticing until an interop test failed at run time, or never, where the broken +// member is one no test exercises. It passed tsc when the audit tried it, but nothing kept it so. +// +// tsconfig.node.json now includes the fixture, so `npm run typecheck` (every CI run, every platform) +// checks it. These two guards keep that true: the file stays in the project, and it hands the desktop +// code nothing through a cast, which would switch the check off for exactly the value it exists for. +// The fixture used to pass its fake pty `as unknown as PtyManager` (HostSessionOptions demanded the +// whole class) and its settings `as never`; the option now names the slice the session uses. + +import { describe, expect, it } from 'vitest' +import { execFileSync } from 'child_process' +import { readdirSync, readFileSync, statSync } from 'fs' +import { join, relative } from 'path' + +const REPO_ROOT = join(__dirname, '..', '..', '..') +const INTEROP_DIR = join(REPO_ROOT, 'android', 'protocol', 'src', 'test', 'interop') + +/** Repo-relative, `/`-separated, whatever the host running Vitest. */ +function repoPath(file: string): string { + return relative(REPO_ROOT, file).replace(/\\/g, '/') +} + +function interopSources(dir: string = INTEROP_DIR, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry) + if (statSync(p).isDirectory()) interopSources(p, out) + else if (/\.ts$/.test(entry)) out.push(p) + } + return out +} + +describe('the Android interop fixture is type-checked against the desktop interfaces (audit A67)', () => { + it('every .ts file under the interop dir is in the project `npm run typecheck` checks', () => { + const sources = interopSources().map(repoPath) + // Not vacuous: a moved or renamed fixture must fail here, not pass over an empty directory. + expect(sources).toEqual( + expect.arrayContaining([ + 'android/protocol/src/test/interop/host-fixture.ts', + 'android/protocol/src/test/interop/electron-stub.ts' + ]) + ) + + // Ask the compiler, not the include globs: an `exclude`, a narrower glob or a moved file all + // show up here. `--listFilesOnly` resolves the project without type-checking it (that is + // `npm run typecheck`'s job), and runs the package's own bin script, so no npm shim is involved. + const tsc = join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc') + const listed = execFileSync(process.execPath, [tsc, '-p', 'tsconfig.node.json', '--listFilesOnly'], { + cwd: REPO_ROOT, + encoding: 'utf8' + }) + .split('\n') + .map((line) => line.trim().replace(/\\/g, '/')) + .filter(Boolean) + // Matched by suffix: tsc may print a realpath where the checkout sits behind a symlink. + const missing = sources.filter((rel) => !listed.some((abs) => abs.endsWith('/' + rel))) + expect(missing, 'add these to tsconfig.node.json\'s include (see the comment there)').toEqual([]) + }, 60_000) + + it('hands the desktop code nothing through a cast that would switch the type check off', () => { + const CAST = /\bas\s+(?:unknown\s+as|never|any)\b/ + const offenders: string[] = [] + for (const file of interopSources()) { + readFileSync(file, 'utf8') + .split('\n') + .forEach((line, i) => { + if (/^\s*(\/\/|\*|\/\*)/.test(line)) return // comments may name the casts they forbid + if (CAST.test(line)) offenders.push(`${repoPath(file)}:${i + 1}: ${line.trim()}`) + }) + } + expect( + offenders, + 'type the value as the interface the desktop code takes (widen that interface if it demands more than it uses)' + ).toEqual([]) + }) +}) diff --git a/src/main/remote/host-service.ts b/src/main/remote/host-service.ts index 91ec75435c..dbec175109 100644 --- a/src/main/remote/host-service.ts +++ b/src/main/remote/host-service.ts @@ -1257,8 +1257,10 @@ export interface HostSessionOptions { token: string /** The long-lived host NaCl keypair. */ ourKeys: KeyPair - /** The real pty-manager whose tmux sessions the peer attaches to. */ - pty: PtyManager + /** The pty-manager whose tmux sessions the peer attaches to: the real PtyManager in both hosts. + * Typed as the slice the session uses (it only hands it to createHostHandlers), so the Android + * interop fixture's fake is checked against exactly that, with no cast (audit A67). */ + pty: HostPtyManager /** The host renderer's latest active-project canvas snapshot (source of the mirror). */ getLatestCanvas(): CanvasState | null /** Subscribe to canvas updates so the mirror re-broadcasts. Returns unsubscribe. */ diff --git a/tsconfig.node.json b/tsconfig.node.json index 507fbda932..0b88a7a5e8 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -22,6 +22,11 @@ "src/shared/**/*", "src/core/**/*", "src/server/**/*", - "src/session-host/**/*" + "src/session-host/**/*", + // The Android interop fixture runs the desktop's real host code and implements its interfaces + // (HostPtyManager, the kanban/inbox/nodeActions bridge). esbuild only strips its types, and the + // Android workflow's path filter skips most desktop files, so `npm run typecheck` (every CI run) + // is what catches a desktop change that breaks it (audit A67). + "android/protocol/src/test/interop/**/*.ts" ] } From b3d4c6ea0c0e0834ea618c79882c015f227bc257 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:31:56 +0000 Subject: [PATCH 079/284] fix(android): run the Android workflow on every file the interop fixture bundles (A63) The Android workflow's path filters listed android/**, src/main/remote/** and src/main/pairing-*.ts only. The interop tests run the desktop's own code, bundled by esbuild from host-fixture.ts, and esbuild's metafile for that bundle names 49 desktop sources: 35 of them (all 13 from src/core, all 21 from src/shared, and src/main/windows-ssh-keys.ts) were outside the filter, so a change to host-service's validators, the pending-approval ids or the kanban writers ran no Android test at all. CLAUDE.md's "the Android workflow runs on those paths" was false. - android.yml: push and pull_request list the same widened paths: android/**, src/core/**, src/shared/**, src/main/*.ts, src/main/remote/**, tsconfig.json, package.json, package-lock.json and the workflow itself. A comment above them says how the list was derived (the fixture's esbuild metafile), what each entry is for, and how to re-derive it. The broad globs also hold the phone-facing producers the bundle leaves out: listProjectsOutput in src/main/index.ts, src/core/ack-sweep.ts, src/shared/pair-qr.ts. The trigger structure (branches, merge_group) is unchanged; that is A68. - bundle-fixture.cjs writes esbuild's metafile beside the bundle (.meta.json), and InteropHarness exposes it as bundleMeta (deleting a previous build's copy first). - New WorkflowPathFilterTest (android/protocol): every metafile input of the bundle the interop tests actually run (node_modules inputs map to package-lock.json), plus tsconfig.json, package.json, package-lock.json, the workflow file and every File(InteropHarness.repoRoot, "...") literal the protocol tests read (ResumeOfferTest reads src/shared/agents/pane.ts), must be matched by BOTH filters. GitHub's glob rules are implemented for * and **; any other filter syntax, and paths-ignore, are refused rather than guessed. It holds by induction: a change that adds a file to the bundle edits a file already in it, the bundler or tsconfig.json, so it runs the workflow and this test. - src/main/index.ts: the projects.list marker comment names ProjectsParser.kt beside NodetermProjects.swift, and says the interop fixture serves a hand-written blob. - CLAUDE.md's Android rule now says what the filter covers and what keeps it true, and that running is not testing (the fixture's blob and mirror are hand-written, A64). CONTRIBUTING.md, android/README.md, the tsconfig.node.json comment and the A67 guard's header no longer describe the old filter. Verified: gradle test --offline in android/protocol passes in full (326 tests, 0 skipped, interop tests included); the offline app type-check passes; npm run typecheck passes; vitest android-interop-fixture.guard and line-endings.guard pass. The workflow YAML parses and both path lists are equal. Mutation checks: with HEAD's android.yml the new test fails, naming the 35 desktop sources and package-lock.json, then package.json, tsconfig.json and src/shared/agents/pane.ts; dropping tsconfig.json from pull_request only fails it for that trigger; a bundler that stops writing the metafile fails it (no stale file is read). Departures from the audit: tsconfig.node.json is not in the filter. esbuild reads the root tsconfig.json and does not follow its references (measured: verbatimModuleSyntax in tsconfig.json took the bundle from 52 to 125 inputs, in tsconfig.node.json it changed nothing), and the fixture's type check runs in ci.yml, which has no path filter. package.json is added beside package-lock.json because npm ci and esbuild both read it. The path filter is widened as the task asks rather than dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 39 +++- CLAUDE.md | 7 +- CONTRIBUTING.md | 3 + android/README.md | 7 +- .../src/test/interop/bundle-fixture.cjs | 17 +- .../dev/nodeterm/protocol/InteropHarness.kt | 7 + .../protocol/WorkflowPathFilterTest.kt | 205 ++++++++++++++++++ src/main/index.ts | 7 +- .../android-interop-fixture.guard.test.ts | 7 +- tsconfig.node.json | 4 +- 10 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a042a61176..cd3114c655 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,18 +2,53 @@ name: Android # The Android companion (android/): the pure-Kotlin protocol module, tested against this repo's own # desktop host code, and the Compose app built against the Android SDK the runner ships. +# +# The path filters below (the same list for push and pull_request) are the inputs of what this workflow +# runs, derived from the interop fixture's esbuild metafile rather than guessed (audit A63). The first +# filter listed only android/**, src/main/remote/** and src/main/pairing-*.ts, which missed 35 of the 49 +# desktop sources bundled into the fixture on 2026-09-26 (all 13 from src/core, all 21 from src/shared, +# and src/main/windows-ssh-keys.ts), so a change to one ran no Android test. Each entry and why: +# android/** the Kotlin modules, the fixture and its bundler, the app sources tests read +# src/core/** bundled (pending-approvals, project-kanban-write, agent-status-mirror, ...); +# also the other producers of what a phone reads: the workspace store behind +# the projects.list blob, ack-sweep behind ~/.nodeterm/acks +# src/shared/** bundled (types, agents/*, kanban-labels, ssh, ...); also pair-qr.ts (the +# pairing QR envelope) and agents/pane.ts (read by ResumeOfferTest) +# src/main/*.ts bundled: pairing-core, pairing-service, windows-ssh-keys; also index.ts, which +# builds the projects.list blob and wires the relay verbs +# src/main/remote/** bundled: host-service, connectHostSession and the relay/E2EE code under it +# tsconfig.json esbuild applies its compilerOptions to every bundled file (the root one; it +# does not follow the references to tsconfig.node.json, so that file is absent: +# the fixture's type check runs in ci.yml, which has no path filter) +# package.json, package-lock.json `npm ci` installs esbuild, ws and tweetnacl from them +# .github/workflows/android.yml this file +# To re-derive: `node android/protocol/src/test/interop/bundle-fixture.cjs +# android/protocol/src/test/interop/electron-stub.ts` from the repo root writes .meta.json, +# whose `inputs` are the bundled files. WorkflowPathFilterTest (android/protocol) fails when one is not +# matched by both filters. A change that adds an input edits a file that is already one (or the bundler +# or tsconfig.json), so it runs this workflow and that test. on: push: paths: - 'android/**' + - 'src/core/**' + - 'src/shared/**' + - 'src/main/*.ts' - 'src/main/remote/**' - - 'src/main/pairing-*.ts' + - 'tsconfig.json' + - 'package.json' + - 'package-lock.json' - '.github/workflows/android.yml' pull_request: paths: - 'android/**' + - 'src/core/**' + - 'src/shared/**' + - 'src/main/*.ts' - 'src/main/remote/**' - - 'src/main/pairing-*.ts' + - 'tsconfig.json' + - 'package.json' + - 'package-lock.json' - '.github/workflows/android.yml' workflow_dispatch: diff --git a/CLAUDE.md b/CLAUDE.md index 612fabf0c0..5c5ec0fc08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5398,7 +5398,12 @@ sessions is a hazard whatever kills it; this removes the hazard, not a proven ca mirror file, or the SSH-visible file contracts (`~/.nodeterm/pending`, `~/.nodeterm/acks`, `~/.nodeterm/relay.json`) owes the Android client (and its interop fixture, `android/protocol/src/test/interop/host-fixture.ts`) in the same PR**; the Android workflow - (`.github/workflows/android.yml`) runs on those paths. The fixture implements host-service's + (`.github/workflows/android.yml`) runs on those paths: its filter covers every input of the + fixture's esbuild bundle (`src/core`, `src/shared`, `src/main/*.ts`, `src/main/remote`), which + also holds the producers the bundle leaves out (`listProjectsOutput` in `src/main/index.ts`, + `src/core/ack-sweep.ts`, `src/shared/pair-qr.ts`), and `WorkflowPathFilterTest` fails on a + bundle input the filter misses (audit A63). Running is not testing: the fixture's blob and + mirror are hand-written (audit A64). The fixture implements host-service's interfaces (`HostPtyManager`, the kanban/inbox/nodeActions bridge) and esbuild only strips its types, so it sits in `tsconfig.node.json` and `npm run typecheck` checks it on every CI run; it passes nothing through a cast (`android-interop-fixture.guard.test.ts`, audit A67). Two diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ae76b47e8..f7918f1be5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,6 +111,9 @@ applicable here": `npm ci --ignore-scripts`) runs it against this repo's own host code. Its interop fixture (`android/protocol/src/test/interop/`) implements host-service's interfaces and is part of `npm run typecheck`, so changing one of them fails the typecheck there until the fixture follows. + The Android workflow runs on every file that fixture bundles (`src/core`, `src/shared`, + `src/main/*.ts`, `src/main/remote`); an import that pulls a file from elsewhere into it fails + `WorkflowPathFilterTest` until the workflow's `paths` list it. Anything reachable from `window.nodeTerminal` needs a **real** implementation in `src/renderer/bridge/`, or a deliberate, documented degrade. The `satisfies NodeTerminalApi` gate diff --git a/android/README.md b/android/README.md index a57c4664cd..bd7312cdd9 100644 --- a/android/README.md +++ b/android/README.md @@ -43,9 +43,10 @@ cd android ``` Needs JDK 17+ and the Android SDK (Android Studio's, or `ANDROID_HOME`). `minSdk` 26, `targetSdk` 35. -CI builds the debug APK on every change under `android/` (`.github/workflows/android.yml`) and -attaches it to the run. The debug APK is not minified. Only the release build type runs R8 -(`app/proguard-rules.pro`), and CI builds it too, unsigned and not published +CI builds the debug APK on every change under `android/` or to the desktop code the protocol tests +run (`.github/workflows/android.yml`; its path filter says which) and attaches it to the run. The +debug APK is not minified. Only the release build type runs R8 (`app/proguard-rules.pro`), and CI +builds it too, unsigned and not published (`./gradlew :app:assembleRelease`, then `tools/check-r8-output.sh`). A missing `-dontwarn` therefore fails CI rather than a first release (R8 reports the missing class), and so does one of the keeps the script checks when it stops matching (the WebView bridge, the worker, BouncyCastle's provider tables, diff --git a/android/protocol/src/test/interop/bundle-fixture.cjs b/android/protocol/src/test/interop/bundle-fixture.cjs index 88d43bf386..3901af0538 100644 --- a/android/protocol/src/test/interop/bundle-fixture.cjs +++ b/android/protocol/src/test/interop/bundle-fixture.cjs @@ -15,7 +15,12 @@ // aliased to the stub (audit A60), `ws` external (it resolves from the repo's node_modules at run // time), and the repo's two path aliases. Relative alias targets resolve against the cwd, as they did // for the CLI. +// +// It also writes esbuild's metafile beside the bundle, as `.meta.json`: the repo files the +// bundle was built from. WorkflowPathFilterTest checks that the Android workflow's path filters +// cover every one of them (audit A63). 'use strict' +const fs = require('fs') const path = require('path') const [outfile, electronStub] = process.argv.slice(2) @@ -42,10 +47,16 @@ esbuild '@renderer': './src/renderer' }, external: ['ws'], + metafile: true, logLevel: 'warning' }) - .catch(() => { - // esbuild has already printed the errors (logLevel 'warning' includes them) to stderr, which the - // harness folds into its failure message. + .then((result) => { + fs.writeFileSync(path.resolve(root, outfile) + '.meta.json', JSON.stringify(result.metafile)) + }) + .catch((err) => { + // A build failure: esbuild has already printed the errors (logLevel 'warning' includes them) to + // stderr, which the harness folds into its failure message. Anything else (the metafile write) + // has printed nothing yet. + if (!err || !Array.isArray(err.errors)) console.error(err) process.exitCode = 1 }) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt index fd5a40611f..b6b4cbced9 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt @@ -87,12 +87,19 @@ class InteropHarness private constructor(private val process: Process) : AutoClo internal val bundle: File by lazy { val out = File(repoRoot, "android/protocol/build/interop/host-fixture.cjs") + File(out.path + ".meta.json").delete() // never read a previous build's metafile val proc = ProcessBuilder(bundleCommand(out)).directory(repoRoot).redirectErrorStream(true).start() val log = proc.inputStream.bufferedReader().readText() check(proc.waitFor() == 0) { "esbuild failed: $log" } out } + /** + * esbuild's metafile for [bundle], which the bundler writes beside it: its `inputs` are the files + * the bundle was built from, repo-relative and `/`-separated (audit A63, [WorkflowPathFilterTest]). + */ + internal val bundleMeta: File by lazy { File(bundle.path + ".meta.json") } + /** * Home-directory variables pointing a fixture at [home]. `os.homedir()` reads HOME on POSIX and * USERPROFILE on Windows, and pair mode writes `.nodeterm/agent.json` (a device with a live bearer diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt new file mode 100644 index 0000000000..1a0f6f204a --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt @@ -0,0 +1,205 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A63: `.github/workflows/android.yml` runs only when a changed file matches the `paths` filter + * of its `push` or `pull_request` trigger. The interop tests run the desktop's own code, bundled from + * `host-fixture.ts`, so a change to any file in that bundle changes what they test. The first filter + * covered `android/`, `src/main/remote/` and the `src/main/pairing-*.ts` files only: 35 of the 49 desktop + * sources in the bundle (all of src/core and src/shared, and `src/main/windows-ssh-keys.ts`) could + * change with no Android run at all. + * + * This reads the inputs of the very bundle the interop tests run (the bundler writes esbuild's + * metafile beside it) and checks that both filters match each one, plus the files the workflow uses + * without bundling them. It holds by induction: a change that adds a file to the bundle has to edit a + * file already in it (an import), the bundler's aliases (under android/) or tsconfig.json, so it runs + * the workflow, and this test then fails until the new path is listed. + * + * The filter semantics are GitHub's (`*` stays within one path segment, `**` crosses them); syntax this + * reader does not implement is refused rather than guessed. + */ +class WorkflowPathFilterTest { + private val root = InteropHarness.repoRoot + private val workflowPath = ".github/workflows/android.yml" + private val workflow by lazy { File(root, workflowPath).readText().replace("\r\n", "\n") } + private val triggers = listOf("push", "pull_request") + + /** + * Files the workflow depends on that are not in the bundle's metafile, with why: + * - tsconfig.json: esbuild applies the root tsconfig's compilerOptions to every file it bundles + * (it does not follow the references, so tsconfig.node.json is not read); + * - package.json, package-lock.json: `npm ci` installs esbuild, ws and tweetnacl from them, and + * they stand in for every node_modules input of the bundle; + * - the workflow file itself. + */ + private val unbundledInputs = listOf("tsconfig.json", "package.json", "package-lock.json", workflowPath) + + @Test + fun `every file the fixture bundle is built from runs the workflow`() { + assumeTrue(InteropHarness.available(), "node + repo node_modules (npm ci) are needed for interop tests") + // Reading it builds the bundle first; the bundler writes the metafile beside it. + val meta = Json.parseToJsonElement(InteropHarness.bundleMeta.readText()).jsonObject + val inputs = meta.getValue("inputs").jsonObject.keys + assertTrue(inputs.any { it.startsWith("src/main/remote/") }, "the metafile names no desktop source: $inputs") + val required = inputs.map { input -> + if (input.startsWith("node_modules/")) { + "package-lock.json" + } else { + assertTrue(File(root, input).isFile, "metafile input $input is not a repo file") + input + } + }.toSortedSet() + assertCovered(required, "bundled into host-fixture.ts") + } + + @Test + fun `the files the workflow uses without bundling them run it`() { + // Repo files the protocol tests read by a literal path (ResumeOfferTest reads a src/shared file). + val testSources = File(root, "android/protocol/src/test/kotlin") + val literalReads = testSources.walkTopDown().filter { it.extension == "kt" }.flatMap { file -> + Regex("""File\(\s*InteropHarness\.repoRoot\s*,\s*"([^"$]+)"\s*\)""").findAll(file.readText()).map { it.groupValues[1] } + }.toSortedSet() + assertTrue(literalReads.isNotEmpty(), "found no File(InteropHarness.repoRoot, \"…\") read in $testSources") + val required = (unbundledInputs + literalReads).toSortedSet() + for (path in required) assertTrue(File(root, path).exists(), "$path does not exist; update this test") + assertCovered(required, "read by the workflow or its tests") + } + + @Test + fun `the glob matcher follows GitHub's path filter syntax`() { + assertTrue(globRegex("android/**").matches("android/protocol/src/test/interop/host-fixture.ts")) + assertTrue(globRegex("src/main/*.ts").matches("src/main/windows-ssh-keys.ts")) + assertFalse(globRegex("src/main/*.ts").matches("src/main/remote/host-service.ts"), "* must not cross /") + assertFalse(globRegex("src/main/pairing-*.ts").matches("src/main/windows-ssh-keys.ts")) + assertFalse(globRegex("tsconfig.json").matches("tsconfigXjson"), ". is literal") + assertFalse(globRegex("tsconfig.json").matches("sub/tsconfig.json"), "a pattern is anchored") + for (bad in listOf("src/**/*.ts?", "src/[ab].ts", "!src/**", "src/+.ts", "src/{a,b}.ts")) { + assertFailsWith(bad) { globRegex(bad) } + } + } + + @Test + fun `the filter reader finds each trigger's paths and nothing else`() { + val yaml = """ + name: x + # a comment + on: + push: + branches: [main] + paths: + # a comment inside the list + - 'a/**' + + - "b/*.ts" + - c.json + pull_request: + workflow_dispatch: + jobs: + paths: + - 'not/a/trigger' + """.trimIndent() + assertEquals(listOf("a/**", "b/*.ts", "c.json"), pathFilter(yaml, "push")) + assertNull(pathFilter(yaml, "pull_request"), "a trigger without paths runs on every change") + assertFailsWith { pathFilter(yaml, "merge_group") } + val ignoring = "on:\n push:\n paths-ignore:\n - 'docs/**'\n" + assertFailsWith { pathFilter(ignoring, "push") } + } + + private fun assertCovered(required: Set, what: String) { + for (trigger in triggers) { + val patterns = pathFilter(workflow, trigger) ?: continue // no filter: every change runs it + val regexes = patterns.map { globRegex(it) } + val missed = required.filter { path -> regexes.none { it.matches(path) } } + assertTrue( + missed.isEmpty(), + "$workflowPath: the $trigger paths filter does not match these files $what, so a change to " + + "one runs no Android test (audit A63). Add them (or a glob) to the push and pull_request " + + "paths and to the comment above them:\n ${missed.joinToString("\n ")}" + ) + } + } + + companion object { + /** + * The `paths:` list of [trigger] under the workflow's top-level `on:`, or null when the trigger + * has none (it then runs on every change). Only the block style this repo's workflows use is + * understood; `paths-ignore` is refused. + */ + internal fun pathFilter(yaml: String, trigger: String): List? { + fun indent(line: String) = line.length - line.trimStart().length + fun skip(line: String) = line.isBlank() || line.trimStart().startsWith("#") + val lines = yaml.lines() + val on = lines.indexOfFirst { it.trimEnd() == "on:" } + if (on < 0) throw AssertionError("no top-level `on:` block") + var i = on + 1 + var triggerIndent = -1 + while (i < lines.size) { + val line = lines[i] + if (!skip(line)) { + if (indent(line) == 0) break + if (line.trim() == "$trigger:") { + triggerIndent = indent(line) + break + } + } + i++ + } + if (triggerIndent < 0) throw AssertionError("no `$trigger:` trigger under `on:`") + var paths: MutableList? = null + var pathsIndent = -1 + i++ + while (i < lines.size) { + val line = lines[i] + i++ + if (skip(line)) continue + val ind = indent(line) + if (ind <= triggerIndent) break + val text = line.trim() + if (paths != null && ind > pathsIndent) { + if (!text.startsWith("- ")) throw AssertionError("unexpected line in the $trigger paths list: $line") + paths.add(text.removePrefix("- ").trim().removeSurrounding("'").removeSurrounding("\"")) + continue + } + if (text.startsWith("paths-ignore:")) throw AssertionError("paths-ignore is not understood by this reader") + if (text == "paths:") { + paths = ArrayList() + pathsIndent = ind + } else if (text.startsWith("paths:")) { + throw AssertionError("only a block `paths:` list is understood: $line") + } + } + return paths + } + + /** A GitHub Actions path pattern as a regex over a repo-relative, `/`-separated path. */ + internal fun globRegex(pattern: String): Regex { + val unsupported = pattern.firstOrNull { it in "?+[]!{}" } + require(unsupported == null) { "path filter syntax '$unsupported' in $pattern is not implemented here" } + val out = StringBuilder() + var i = 0 + while (i < pattern.length) { + if (pattern.startsWith("**", i)) { + out.append(".*") + i += 2 + } else if (pattern[i] == '*') { + out.append("[^/]*") + i++ + } else { + out.append(Regex.escape(pattern[i].toString())) + i++ + } + } + return Regex(out.toString()) + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 1ba2a091e5..a1f77e24bf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -622,9 +622,12 @@ ptyManager.setProjectSpawnOverrides( }) ) -// Markers delimiting the `projects.list` relay blob. The iOS client splits on these exact +// Markers delimiting the `projects.list` relay blob. Both phone clients split on these exact // strings to recover [workspace.json | newline-joined tmux session names | agent-status.json], -// matching the SSH browse pipeline it already uses — keep them in sync with NodetermProjects.swift. +// matching the SSH browse pipeline they already use — keep them in sync with NodetermProjects.swift +// (iOS, nodeterm-ios) and android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt. +// The Android interop fixture (android/protocol/src/test/interop/host-fixture.ts) serves a +// hand-written blob, not this function's output, so a change to the shape owes it too. const NT_PROJECTS_MARK = '--NT-PROJECTS-SPLIT--' const NT_STATUS_MARK = '--NT-STATUS-SPLIT--' diff --git a/src/main/remote/android-interop-fixture.guard.test.ts b/src/main/remote/android-interop-fixture.guard.test.ts index 62edb846d8..13ae3aa29b 100644 --- a/src/main/remote/android-interop-fixture.guard.test.ts +++ b/src/main/remote/android-interop-fixture.guard.test.ts @@ -5,9 +5,10 @@ // // Nothing checked it against them (audit A67). InteropHarness.kt bundles the fixture with esbuild, // which strips types without checking them; no tsconfig included the file; and the Android workflow's -// path filter skips most of src/, so a desktop change could break the fixture's fit to an interface -// without any check noticing until an interop test failed at run time, or never, where the broken -// member is one no test exercises. It passed tsc when the audit tried it, but nothing kept it so. +// path filter then skipped most of src/ (audit A63), so a desktop change could break the fixture's fit +// to an interface without any check noticing until an interop test failed at run time, or never, where +// the broken member is one no test exercises. It passed tsc when the audit tried it, but nothing kept +// it so. // // tsconfig.node.json now includes the fixture, so `npm run typecheck` (every CI run, every platform) // checks it. These two guards keep that true: the file stays in the project, and it hands the desktop diff --git a/tsconfig.node.json b/tsconfig.node.json index 0b88a7a5e8..636462e452 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -25,8 +25,8 @@ "src/session-host/**/*", // The Android interop fixture runs the desktop's real host code and implements its interfaces // (HostPtyManager, the kanban/inbox/nodeActions bridge). esbuild only strips its types, and the - // Android workflow's path filter skips most desktop files, so `npm run typecheck` (every CI run) - // is what catches a desktop change that breaks it (audit A67). + // Android workflow runs no tsc, so `npm run typecheck` (every CI run) is what catches a desktop + // change that breaks it (audit A67). "android/protocol/src/test/interop/**/*.ts" ] } From f9782da100e65bd66106084a3c28f1c380f32e4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:52:18 +0000 Subject: [PATCH 080/284] fix(android): test the relay projects.list blob against the desktop's own assembly (A64) The docs said the protocol tests check the projects.list blob, the agent-status mirror and the ~/.nodeterm files against desktop code, but the interop fixture served a blob it wrote by hand, markers and mirror included, so a desktop change to either could not fail an Android test. What changed: - src/core/projects-list-blob.ts: the one assembly of the blob (the two split markers, section order, the read-only `load({sideline:false})`, the mirror file read). `listProjectsOutput` in src/main/index.ts is now a thin async wrapper naming its sources; behaviour is unchanged (same reads, same order, same best-effort degrade). - agent-status-mirror.ts exports AGENT_STATUS_FILE, used by the mirror's default path and by the blob, so writer and reader share the name. - observedClaudeAccount (the hook server's account label) moved unchanged from hook-server.ts to claude-config-dir.ts and is exported; the hook server still attaches it in the same place. - host-fixture.ts (relay mode): the blob comes from buildProjectsListBlob over a real WorkspaceStore (it saves the v3 index and project.json, then loads them read-only) and an agent-status.json written by the real mirror from Claude hook payloads (recordRawToolEvent, normalizeFor, the hook server's account label, recordAgentEvent, setNodeSessionName, flush). Relay mode now requires FIXTURE_USERDATA; RelayInteropTest gives each harness a scratch dir and deletes it. The blob test also asserts the mirror's own approval summary ("Run command" / "npm test"). - android/protocol/build.gradle.kts: src/core, src/shared, src/main and tsconfig.json are test inputs, so a desktop change re-runs the interop tests locally instead of reporting them up to date. - Guard (android-interop-fixture.guard.test.ts): neither the fixture nor index.ts may spell a split marker, both must call buildProjectsListBlob, and the fixture may not write a mirror document by hand. - Docs (CONTRIBUTING.md, CLAUDE.md Android rule, docs/android.md, the android.yml comment, ProjectsParser/Models comments) now say exactly what is real (relay handshake + verb routing with fake bridges, pairing, the projects.list blob and mirror) and name what stays hand-copied: the SSH leg's v3 index, project files, agent-status.json and pending/acks files (SshTransportTest), and relay.json (no test). Verified: - New vitest src/core/projects-list-blob.test.ts (layout, sideline:false, degrade, real store + real mirror end to end); mutation-checked (sideline true, wrong file name each fail). - Mutations of the desktop side (status marker renamed; mirror buildFile dropping `inbox`) fail 3 of 18 RelayInteropTest cases with the new fixture; the same mutations pass all 18 with the HEAD fixture. - The new guard fails against the HEAD fixture and the HEAD index.ts. - gradle test --offline (326 tests, 0 skipped, 0 failed); the offline app type-check; npm run typecheck; the affected vitest suites. Six vitest files fail in this environment because node-pty's native module is not built/patched here; they fail identically on HEAD. Departures from the audit: the SSH-side hand copies (v3 index, pending/acks, agent-status.json in SshTransportTest, relay.json) stay as they are and are documented instead of driven by ack-sweep/pending-approvals (that would make the SSH tests depend on node). The mirror's settings block uses the shells' provider body copied into the fixture (entries still come from the real mirrorClaudeAccount builder). The fixture's newSessions resolver keeps its separate fake index so RelayInteropTest can assert a fixed folder. Nothing has run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 8 +- CLAUDE.md | 18 +- CONTRIBUTING.md | 13 +- android/protocol/build.gradle.kts | 7 + .../dev/nodeterm/protocol/model/Models.kt | 5 +- .../nodeterm/protocol/model/ProjectsParser.kt | 3 +- .../protocol/src/test/interop/host-fixture.ts | 164 ++++++++++++------ .../dev/nodeterm/protocol/RelayInteropTest.kt | 35 +++- docs/android.md | 22 ++- src/core/agent-status-mirror.ts | 7 +- src/core/agents/hook-server.ts | 38 +--- src/core/claude-config-dir.ts | 46 ++++- src/core/projects-list-blob.test.ts | 122 +++++++++++++ src/core/projects-list-blob.ts | 51 ++++++ src/main/index.ts | 40 ++--- .../android-interop-fixture.guard.test.ts | 24 +++ 16 files changed, 458 insertions(+), 145 deletions(-) create mode 100644 src/core/projects-list-blob.test.ts create mode 100644 src/core/projects-list-blob.ts diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index cd3114c655..a9d90bfc71 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -9,13 +9,13 @@ name: Android # desktop sources bundled into the fixture on 2026-09-26 (all 13 from src/core, all 21 from src/shared, # and src/main/windows-ssh-keys.ts), so a change to one ran no Android test. Each entry and why: # android/** the Kotlin modules, the fixture and its bundler, the app sources tests read -# src/core/** bundled (pending-approvals, project-kanban-write, agent-status-mirror, ...); -# also the other producers of what a phone reads: the workspace store behind -# the projects.list blob, ack-sweep behind ~/.nodeterm/acks +# src/core/** bundled (pending-approvals, project-kanban-write, agent-status-mirror, and +# since A64 projects-list-blob + workspace-store, which build the projects.list +# blob, ...); also ack-sweep behind ~/.nodeterm/acks, which is not bundled # src/shared/** bundled (types, agents/*, kanban-labels, ssh, ...); also pair-qr.ts (the # pairing QR envelope) and agents/pane.ts (read by ResumeOfferTest) # src/main/*.ts bundled: pairing-core, pairing-service, windows-ssh-keys; also index.ts, which -# builds the projects.list blob and wires the relay verbs +# names the projects.list blob's sources and wires the relay verbs # src/main/remote/** bundled: host-service, connectHostSession and the relay/E2EE code under it # tsconfig.json esbuild applies its compilerOptions to every bundled file (the root one; it # does not follow the references to tsconfig.node.json, so that file is absent: diff --git a/CLAUDE.md b/CLAUDE.md index 5c5ec0fc08..bcb7a9ca07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5400,10 +5400,20 @@ sessions is a hazard whatever kills it; this removes the hazard, not a proven ca `android/protocol/src/test/interop/host-fixture.ts`) in the same PR**; the Android workflow (`.github/workflows/android.yml`) runs on those paths: its filter covers every input of the fixture's esbuild bundle (`src/core`, `src/shared`, `src/main/*.ts`, `src/main/remote`), which - also holds the producers the bundle leaves out (`listProjectsOutput` in `src/main/index.ts`, - `src/core/ack-sweep.ts`, `src/shared/pair-qr.ts`), and `WorkflowPathFilterTest` fails on a - bundle input the filter misses (audit A63). Running is not testing: the fixture's blob and - mirror are hand-written (audit A64). The fixture implements host-service's + also holds the producers the bundle leaves out (`src/core/ack-sweep.ts`, `src/shared/pair-qr.ts`, + and `src/main/index.ts`, which names the blob's sources), and `WorkflowPathFilterTest` fails on + a bundle input the filter misses (audit A63). Running is not testing, so know what is real + (audit A64): the relay tests run `connectHostSession`'s handshake and verb routing (the pty, + board, inbox and node-action bridges behind the verbs are fakes), `createPairingService`, and + the `projects.list` blob as the desktop builds it — `buildProjectsListBlob` + (`src/core/projects-list-blob.ts`, the one assembly `listProjectsOutput` also calls) over a real + `WorkspaceStore` and an `agent-status.json` the real mirror wrote from hook payloads + (`android-interop-fixture.guard.test.ts` refuses a hand-written blob or mirror there). The SSH + leg is HAND-COPIED: `SshTransportTest` writes the v3 index, the project files, + `agent-status.json` and the held `~/.nodeterm/pending` request itself and checks the phone's + `.answer` / `acks/.seen` against names copied from `pending-approvals.ts` / + `ack-sweep.ts`, and nothing tests `relay.json` — a change to those owes a hand edit of the + Android tests. The fixture implements host-service's interfaces (`HostPtyManager`, the kanban/inbox/nodeActions bridge) and esbuild only strips its types, so it sits in `tsconfig.node.json` and `npm run typecheck` checks it on every CI run; it passes nothing through a cast (`android-interop-fixture.guard.test.ts`, audit A67). Two diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7918f1be5..ec5e3be0f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,8 +107,17 @@ applicable here": The **Android** companion is in this repo (`android/`, see docs/android.md). If you change what a phone sees — a `host-service.ts` relay verb, the `projects.list` blob, the pairing payload, the agent-status mirror, or the `~/.nodeterm/{pending,acks,relay.json}` files — update the - Android client in the same PR; `./gradlew -p protocol test` (from `android/`, after - `npm ci --ignore-scripts`) runs it against this repo's own host code. Its interop fixture + Android client in the same PR. `./gradlew -p protocol test` (from `android/`, after + `npm ci --ignore-scripts`) runs the relay leg against this repo's own host code: the handshake + and host-service's verb routing (`connectHostSession`; the pty, board, inbox and node-action + bridges behind the verbs are fakes), pairing (`createPairingService`), and the `projects.list` + blob as the desktop builds it (`buildProjectsListBlob` in `src/core/projects-list-blob.ts` over + a real `WorkspaceStore` and an `agent-status.json` written by the real mirror). The SSH leg is + **hand-copied**: `SshTransportTest` writes the v3 `workspace.json` index, the project files, + `agent-status.json` and the held request in `~/.nodeterm/pending` itself, and checks the + `.answer` and `acks/.seen` files the phone writes against names copied from + `pending-approvals.ts` and `ack-sweep.ts`; nothing tests `relay.json`. A change to one of those + needs the matching hand edit in the Android tests. Its interop fixture (`android/protocol/src/test/interop/`) implements host-service's interfaces and is part of `npm run typecheck`, so changing one of them fails the typecheck there until the fixture follows. The Android workflow runs on every file that fixture bundles (`src/core`, `src/shared`, diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index 6f6d267841..bc8680b99b 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -50,6 +50,13 @@ tasks.test { // workflow that runs R8 (R8RulesTest). // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) + // The desktop code the interop fixture bundles (the same dirs as android.yml's path filter). Without + // them a desktop change, say to the projects.list assembly or the agent-status mirror the relay + // tests read (audit A64), left the tests "up to date" locally and was never run against. + for (dir in listOf("src/core", "src/shared", "src/main")) { + inputs.dir(rootDir.resolve("../../$dir")).withPathSensitivity(PathSensitivity.RELATIVE) + } + inputs.file(rootDir.resolve("../../tsconfig.json")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.dir(rootDir.resolve("../app/src/main/assets/terminal")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.dir(rootDir.resolve("../app/src/main/kotlin")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../app/proguard-rules.pro")).withPathSensitivity(PathSensitivity.RELATIVE) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt index 8344cf7688..1ee5bdb831 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt @@ -2,8 +2,9 @@ package dev.nodeterm.protocol.model /** * The phone's view of one paired computer: its projects (the ASSEMBLED v2 workspace the desktop's - * `listProjectsOutput` serves), which tmux sessions are live, and the agent-status mirror file - * (`agent-status.json`, see `src/core/agent-status-mirror.ts` and docs/mobile-usage-inbox.md). + * `projects.list` serves, src/core/projects-list-blob.ts), which tmux sessions are live, and the + * agent-status mirror file (`agent-status.json`, see `src/core/agent-status-mirror.ts` and + * docs/mobile-usage-inbox.md). */ data class ProjectsSnapshot( val projects: List, diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt index 57f1067dee..a6b6b18b85 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt @@ -12,7 +12,8 @@ import kotlinx.serialization.json.JsonObject /** * Parses the `projects.list` blob: `\n--NT-PROJECTS-SPLIT--\n\n--NT-STATUS-SPLIT--\n` (src/main/index.ts `listProjectsOutput`). The + * names>\n--NT-STATUS-SPLIT--\n` (`buildProjectsListBlob` in + * src/core/projects-list-blob.ts, which the desktop's `listProjectsOutput` serves). The * direct-SSH browse produces the same shape, so both transports share this parser. Every section * is best-effort: a missing or corrupt one degrades to empty, never to an exception. */ diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index bf26582fdb..e895d701ad 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -5,6 +5,9 @@ // frames preserving text/binary — all the real relay does) plus the desktop's // `connectHostSession` (src/main/remote/host-service.ts → relay-socket.ts host role) // serving a FAKE pty/kanban/inbox bridge that records what the phone asked for. +// `projects.list` is NOT faked: the desktop's `buildProjectsListBlob` over a real +// `WorkspaceStore` and a mirror file the real agent-status mirror wrote, all under +// FIXTURE_USERDATA (see seedDesktopState; audit A64). // mode "pair": the desktop's real `createPairingService` (src/main/pairing-service.ts) with the // home dir pointed at a temp dir by the caller (HOME, and USERPROFILE for Windows, see // InteropHarness.scratchHomeEnv; refused unless `os.homedir()` is FIXTURE_HOME), and a @@ -24,7 +27,7 @@ import http from 'http' import os from 'os' import path from 'path' import { WebSocketServer, type WebSocket } from 'ws' -import { initPlatform } from '../../../../../src/core/platform' +import { initPlatform, platform } from '../../../../../src/core/platform' import { genKeyPair, publicKeyToB64 } from '../../../../../src/main/remote/e2ee' import { connectHostSession, @@ -32,10 +35,27 @@ import { type HostSession } from '../../../../../src/main/remote/host-service' import { createHostNewSessions } from '../../../../../src/main/remote/host-new-sessions' -import { mirrorClaudeAccount } from '../../../../../src/core/agent-status-mirror' +import { + flush as flushMirror, + initAgentStatusMirror, + mirrorClaudeAccount, + recordAgentEvent, + recordRawToolEvent, + setMirrorSettingsProvider, + setNodeSessionName +} from '../../../../../src/core/agent-status-mirror' +import { + claudeAccountsSnapshot, + claudeConfigDirFor, + observedClaudeAccount, + registerClaudeAccountsSource +} from '../../../../../src/core/claude-config-dir' +import { buildProjectsListBlob } from '../../../../../src/core/projects-list-blob' +import { WorkspaceStore } from '../../../../../src/core/workspace-store' +import { normalizeFor } from '../../../../../src/shared/agents/normalize' import { createPairingService } from '../../../../../src/main/pairing-service' import type { DetachedSinks } from '../../../../../src/core/pty-manager' -import { DEFAULT_SETTINGS } from '../../../../../src/shared/types' +import { DEFAULT_SETTINGS, type ClaudeAccount, type Workspace } from '../../../../../src/shared/types' const emit = (obj: unknown): void => { process.stdout.write(JSON.stringify(obj) + '\n') @@ -97,7 +117,87 @@ function startBroker(): Promise { // ---- mode "relay" ---------------------------------------------------------------------------------- +/** + * What the phone lists (`projects.list`), produced by the desktop's own code (audit A64), under the + * caller's scratch userData (FIXTURE_USERDATA; the Kotlin test deletes it): + * - the workspace: the real `WorkspaceStore` writes it as the desktop does (a v3 index plus the + * folder's `.nodeterm/project.json`), and the blob serves the store's read-only `load()` of it; + * - `agent-status.json`: the real mirror, fed the hook POSTs a Claude session makes, as both shells + * feed it (the raw listener's `recordRawToolEvent`, then the normalized event with the hook + * server's account label into `recordAgentEvent`), and flushed by its own writer; + * - the mirror's settings block: the shells' provider body over one managed Claude account. + * Hand-written here: the workspace the renderer would hand the store, the hook payloads (with the + * `nodeterm_pending_id` the hook server merges in from its form field), the session name the + * name sweep would publish, and the tmux session list (there is no tmux). + */ +async function seedDesktopState(): Promise { + const userData = platform().userDataDir + const workspace: Workspace = { + version: 2, + activeProjectId: 'p1', + projects: [ + { + id: 'p1', + name: 'Demo', + color: '#0a84ff', + cwd: path.join(userData, 'demo'), + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [ + { id: 'term-abc-1', kind: 'terminal', title: 'Claude', color: '#d97757', agentId: 'claude', group: null, position: { x: 0, y: 0 }, size: { width: 1, height: 1 } }, + { id: 'note-1', kind: 'sticky', title: 'Note', color: '#fff', group: null, text: 'hi', position: { x: 0, y: 0 }, size: { width: 1, height: 1 } } + ], + kanban: { + columns: [{ id: 'c1', title: 'To Do', color: '#0a84ff' }], + assignments: [{ nodeId: 'term-abc-1', columnId: 'c1' }], + labels: [{ id: 'l1', name: 'bug', color: 'red' }], + meta: [{ nodeId: 'term-abc-1', labels: ['l1'], priority: 'high' }] + } + } + ] + } + const store = new WorkspaceStore() + await store.save(workspace) + + // A39/A75: one managed Claude account, registered the way both shells register settings. + const accounts: ClaudeAccount[] = [{ id: 'acct-1', label: 'Work', email: 'me@work.example', createdAt: 0 }] + registerClaudeAccountsSource(() => accounts) + setMirrorSettingsProvider(() => ({ + claudePermissionMode: 'manual', + autoSupported: false, + claudeAccounts: claudeAccountsSnapshot() + .filter((a) => !a.host && !a.pending) + .map((a) => mirrorClaudeAccount(a, claudeConfigDirFor(a.id))) + })) + + // As both shells do at boot: the mirror's default file, `/agent-status.json`, which is + // where `buildProjectsListBlob` reads it back. + initAgentStatusMirror() + const nodeId = 'term-abc-1' + const session = { + session_id: 's-1', + // Under the account's config dir, so the hook server's label names acct-1. + transcript_path: path.join(claudeConfigDirFor('acct-1'), 'projects', '-demo', 's-1.jsonl'), + cwd: workspace.projects[0].cwd + } + const hook = (payload: Record): void => { + recordRawToolEvent(nodeId, payload) + const ev = normalizeFor('claude', { nodeId, agentId: 'claude', payload }) + const account = observedClaudeAccount('claude', payload) + if (ev) recordAgentEvent({ ...ev, ...(account ? { account } : {}) }) + } + const bash = { tool_name: 'Bash', tool_input: { command: 'npm test' } } + hook({ ...session, hook_event_name: 'PreToolUse', ...bash }) + // A held hook-reply approval: the managed hook's deterministic ticket. + hook({ ...session, hook_event_name: 'PermissionRequest', ...bash, nodeterm_pending_id: 'term-abc-1-1700000000000-42' }) + setNodeSessionName(nodeId, 'fix bug') + await flushMirror() + return store +} + async function runRelay(): Promise { + // Relay mode writes a workspace and the agent-status mirror under userData: never into the + // checkout the fixture runs from. + if (!process.env.FIXTURE_USERDATA) throw new Error('relay mode needs FIXTURE_USERDATA (a scratch dir)') const port = await startBroker() const keys = genKeyPair() const approveAfter = Number(process.env.FIXTURE_APPROVE_AFTER_MS ?? '0') @@ -147,55 +247,7 @@ async function runRelay(): Promise { } } - const blob = [ - JSON.stringify({ - version: 2, - activeProjectId: 'p1', - projects: [ - { - id: 'p1', - name: 'Demo', - color: '#0a84ff', - cwd: '/work/demo', - viewport: { x: 0, y: 0, zoom: 1 }, - nodes: [ - { id: 'term-abc-1', kind: 'terminal', title: 'Claude', color: '#d97757', agentId: 'claude', group: null, position: { x: 0, y: 0 }, size: { width: 1, height: 1 } }, - { id: 'note-1', kind: 'sticky', title: 'Note', color: '#fff', group: null, text: 'hi', position: { x: 0, y: 0 }, size: { width: 1, height: 1 } } - ], - kanban: { - columns: [{ id: 'c1', title: 'To Do', color: '#0a84ff' }], - assignments: [{ nodeId: 'term-abc-1', columnId: 'c1' }], - labels: [{ id: 'l1', name: 'bug', color: 'red' }], - meta: [{ nodeId: 'term-abc-1', labels: ['l1'], priority: 'high' }] - } - } - ] - }), - '--NT-PROJECTS-SPLIT--', - 'nt-term-abc-1', - '--NT-STATUS-SPLIT--', - JSON.stringify({ - v: 1, - updatedAt: 1, - nodes: { - 'term-abc-1': { - state: 'blocked', agentId: 'claude', sessionId: 's-1', name: 'fix bug', updatedAt: 2, - account: { configDir: '/data/claude-accounts/acct-1', accountId: 'acct-1', known: true } - } - }, - // A39/A75: each entry is built by the desktop's real builder, so the phone is checked against - // what both shells write (label + email beside id + dir), not against a copy of it. - settings: { - claudePermissionMode: 'manual', - autoSupported: false, - claudeAccounts: [mirrorClaudeAccount({ id: 'acct-1', label: 'Work', email: 'me@work.example' }, '/data/claude-accounts/acct-1')] - }, - inbox: { - events: [{ id: 'e1', ts: 3, nodeId: 'term-abc-1', kind: 'approval', title: 'Approve Bash', pendingId: 'term-abc-1-1700000000000-42' }], - nodes: { 'term-abc-1': { activity: 'Running npm test', updatedAt: 4 } } - } - }) - ].join('\n') + const store = await seedDesktopState() let session: HostSession | null = null const approveNow = (): void => { @@ -212,7 +264,10 @@ async function runRelay(): Promise { getLatestCanvas: () => null, subscribeCanvas: () => () => {}, applyMutation: () => {}, - listProjects: async () => blob, + // The desktop's own assembly (listProjectsOutput in src/main/index.ts calls the same function): + // the store's read-only load, the mirror file, and the session names, between the markers. + listProjects: () => + buildProjectsListBlob({ workspace: store, userDataDir: platform().userDataDir, listSessions: async () => ['nt-term-abc-1'] }), // A viewer on a node is an Eco shield and a size ceiling on the desktop (A18): the phone must // never leave one behind. remoteViewer: { @@ -254,7 +309,8 @@ async function runRelay(): Promise { }, // A33/A72: the desktop's REAL resolver decides what a phone-started session is created with — // folder, account, agent and pane owner — over a fake index (local folder project p1) and one - // logged-in managed Claude account. + // logged-in managed Claude account. Kept apart from the store behind `projects.list` on purpose: + // the fixed `/repo` lets RelayInteropTest assert the folder without knowing the scratch dir. newSessions: createHostNewSessions({ projectTargetInfo: (projectId) => (projectId === 'p1' ? { cwd: '/repo' } : null), claudeAccounts: () => [{ id: 'acct-1' }] diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index 2249927319..0c2fcba3f0 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -24,6 +24,8 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -38,19 +40,37 @@ import kotlin.test.assertTrue /** * The Kotlin relay client against the DESKTOP's real host code: `connectHostSession` + * `createHostHandlers` (host-service.ts) over `connectRelay` (relay-socket.ts, host role), through a - * local broker. Everything the phone does over the relay goes across this wire at least once. + * local broker. Everything the phone does over the relay goes across this wire at least once. The + * `projects.list` blob it parses is the desktop's own (`buildProjectsListBlob` over a real + * `WorkspaceStore` and a mirror file the real agent-status mirror wrote, audit A64); the pty, + * kanban, inbox and node-action bridges behind the verbs are fakes that record what was asked. */ class RelayInteropTest { private val harnesses = ArrayList() + private val userDataDirs = ArrayList() @AfterTest - fun tearDown() = harnesses.forEach { it.close() } + fun tearDown() { + harnesses.forEach { it.close() } + userDataDirs.forEach { it.deleteRecursively() } + } - private fun start(approveAfterMs: Long = 0, rejectAfterMs: Long = -1, extra: Map = emptyMap()): InteropHarness = - InteropHarness.start( + /** + * The fixture's userData is a scratch dir per harness: relay mode writes the desktop's workspace + * (a v3 index and the project's `.nodeterm/project.json`) and its agent-status mirror there, and + * serves `projects.list` from them through the desktop's own assembly (audit A64). + */ + private fun start(approveAfterMs: Long = 0, rejectAfterMs: Long = -1, extra: Map = emptyMap()): InteropHarness { + val userData = Files.createTempDirectory("nt-relay-ud").toFile().also { userDataDirs += it } + return InteropHarness.start( "relay", - mapOf("FIXTURE_APPROVE_AFTER_MS" to approveAfterMs.toString(), "FIXTURE_REJECT_AFTER_MS" to rejectAfterMs.toString()) + extra + mapOf( + "FIXTURE_APPROVE_AFTER_MS" to approveAfterMs.toString(), + "FIXTURE_REJECT_AFTER_MS" to rejectAfterMs.toString(), + "FIXTURE_USERDATA" to userData.path + ) + extra ).also { harnesses += it } + } private fun InteropHarness.str(key: String) = ready[key]!!.jsonPrimitive.content private fun JsonObject.str(key: String) = this[key]!!.jsonPrimitive.content @@ -176,6 +196,11 @@ class RelayInteropTest { val ev = snap.status!!.inbox!!.events.single() assertEquals(InboxKind.APPROVAL, ev.kind) assertEquals("term-abc-1-1700000000000-42", ev.pendingId) + // The mirror's own summary of the held Bash call (A64: nothing here is hand-written). + assertEquals("Run command", ev.title) + assertEquals("npm test", ev.detail) + assertEquals("s-1", ev.sessionId) + assertFalse(ev.resolved) assertEquals("Running npm test", snap.status!!.inbox!!.nodes["term-abc-1"]!!.activity) val board = p.board!! assertEquals("c1", board.columnOf("term-abc-1")) diff --git a/docs/android.md b/docs/android.md index e10b13c451..130d48b73a 100644 --- a/docs/android.md +++ b/docs/android.md @@ -96,7 +96,12 @@ unchanged. through a local broker: handshake, SAS agreement, approval wait, `projects.list`, attach with snapshot paint (including a >256 KB snapshot whose chunk boundary splits a code point), input, resize/`OP.Resized`, exit codes, scroll, detach/destroy, node actions, board verbs (`null` = - Ungrouped), `approvals.answer`, `inbox.ack`, registration. + Ungrouped), `approvals.answer`, `inbox.ack`, registration. What is real is the verb routing and + its validation; the pty, board, inbox and node-action bridges behind the verbs are fakes that + record what was asked. The `projects.list` blob is the desktop's own: `buildProjectsListBlob` + (`src/core/projects-list-blob.ts`, which the desktop's `listProjectsOutput` calls too) over a real + `WorkspaceStore` (it writes the v3 index and the project file, then assembles them) and an + `agent-status.json` written by the real mirror from Claude hook payloads (audit `A64`). - **Relay security** — scripted-host tests for: no re-key after ready, reflected boxes (own role) dropped, replayed/reordered sequence numbers dropped, boxes under a foreign key dropped. - **Pairing** — against the desktop's real `createPairingService` with HOME in a temp dir: the @@ -110,6 +115,10 @@ unchanged. - **SSH** — against Apache MINA sshd running every command through a shell, with real tmux on a private `TMUX_TMPDIR`: v3 index resolution, attach with keystrokes both ways, cold-start detection, literal `send-keys` (a leading `-` is text), answer files, read-acks, host-key pinning. + No desktop code runs on this leg: the test writes the files the desktop would have (the v3 + `workspace.json` index and project files, `agent-status.json`, the held request in + `~/.nodeterm/pending`), and checks what the phone writes against file names copied from + `pending-approvals.ts` and `ack-sweep.ts`. A command without a pty runs as `/bin/sh -c `. One that asks for a pty runs under `script(1)` in place of sshd's pty: util-linux's `script -qfec /dev/null` on Linux (which runs `` through `$SHELL`), BSD's `script -q /dev/null /bin/sh -c ` on macOS. They are told apart by @@ -135,10 +144,15 @@ The debug APK stays unminified and is the one distributed. The app has no instru index, and the rest are open: [`android-audit-2026-09.md`](android-audit-2026-09.md). The plan, the device checklist and the decisions still open are in [`android-handover.md`](android-handover.md). -A test caveat: the fixtures hand-copy some desktop shapes — the mirror, the `projects.list` blob -and the `~/.nodeterm` files (audit `A64`). That is how a wrong userData path (`A02`: the desktop's +A test caveat (audit `A64`): the relay leg's `projects.list` blob and mirror now come from the +desktop's code, but the SSH leg still hand-copies desktop shapes — the v3 index and project files, +`agent-status.json`, and the `~/.nodeterm/pending` and `acks` files — and nothing tests +`~/.nodeterm/relay.json`. A desktop change to one of those fails no Android test; it needs the +matching hand edit in `SshTransportTest`. That is how a wrong userData path (`A02`: the desktop's directory is `node-terminal`, not `nodeterm`) once passed its test; the fixture now uses the real -name, and `SshScriptsTest` runs the prelude under `/bin/sh` against both spellings. +name, and `SshScriptsTest` runs the prelude under `/bin/sh` against both spellings. The parser unit +tests (`ModelTest`, `UsagePaceTest`) also feed hand-written blobs, on purpose: they pin how the +client reads malformed and edge-case input, not what the desktop writes. Since the audit, the SSH tests also cover: resize/keystrokes/close from a thread that must not do network I/O (a JVM stand-in for Android's StrictMode, `A01`), a transport that breaks mid-write diff --git a/src/core/agent-status-mirror.ts b/src/core/agent-status-mirror.ts index c120058b33..7a9219b044 100644 --- a/src/core/agent-status-mirror.ts +++ b/src/core/agent-status-mirror.ts @@ -37,6 +37,9 @@ export const DONE_HOLDOFF_MS = 3000 export const EXPIRE_MS = 6 * 60 * 60_000 // Coalesce bursty hook POSTs (a single turn fires many tool events) into one disk write. export const WRITE_DEBOUNCE_MS = 300 +/** The mirror's file name under userData. Read back by the `projects.list` blob + * (core/projects-list-blob.ts) and, over SSH, by the phones themselves. */ +export const AGENT_STATUS_FILE = 'agent-status.json' export interface MirrorEntry { /** Tickets introduced concurrently with a picker, retained until reply or explicit reset. */ @@ -1484,7 +1487,7 @@ function loadPersisted(file: string): void { * field bug). */ export function initAgentStatusMirror(filePath?: string): void { - targetFile = filePath ?? path.join(platform().userDataDir, 'agent-status.json') + targetFile = filePath ?? path.join(platform().userDataDir, AGENT_STATUS_FILE) loadPersisted(targetFile) startStaleSweep() } @@ -1492,7 +1495,7 @@ export function initAgentStatusMirror(filePath?: string): void { function resolveFile(): string | null { if (targetFile) return targetFile try { - targetFile = path.join(platform().userDataDir, 'agent-status.json') + targetFile = path.join(platform().userDataDir, AGENT_STATUS_FILE) return targetFile } catch { return null diff --git a/src/core/agents/hook-server.ts b/src/core/agents/hook-server.ts index e8df3643d8..25107a31b6 100644 --- a/src/core/agents/hook-server.ts +++ b/src/core/agents/hook-server.ts @@ -3,7 +3,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { randomUUID, timingSafeEqual } from 'crypto' import { readFileSync, mkdirSync, chmodSync, unlinkSync } from 'fs' import { assertHookEndpointAvailable, clearStaleHookSocket, HookSocketOwnedError } from './hook-socket-owner' -import { homedir } from 'os' import path from 'path' import { platform } from '../platform' import { writeFileAtomic } from '../fs-atomic' @@ -11,9 +10,8 @@ import { parseEndpointEnv } from './hook-endpoint-parse' import { hookSockPath } from './hook-sock-path' import { canControlCanvas, type AgentId } from '../../shared/agents/config' import { normalizeFor, type NormalizedAgentEvent } from '../../shared/agents/normalize' -import { classifyClaudeConfigDir, configDirFromTranscriptPath } from '../claude-accounts-core' -import { claudeAccountsSnapshot } from '../claude-config-dir' -import type { CodexIdentityEvent, ObservedClaudeAccount } from '../../shared/types' +import { observedClaudeAccount } from '../claude-config-dir' +import type { CodexIdentityEvent } from '../../shared/types' import type { NodeTokenVerdict } from './node-auth-token' import { nodeTokenDir } from './node-token-files' import { isForeignKidToken, isSafeNodeId, verifyNodeToken } from './node-auth-token' @@ -153,38 +151,6 @@ function parseClientRevision(raw: string | string[] | undefined): number | undef return Number.isSafeInteger(n) ? n : undefined } -/** - * The `account` LABEL for a claude hook payload: `transcript_path` → - * `/projects/…` → which account that dir is. Undefined for every other agent and for a - * payload with no usable `transcript_path` — "we did not observe an account" and "the system - * account" are different facts and must stay distinguishable (CONTRIBUTING: a failed read is never - * evidence of absence), so an absent field is the honest answer, not a synthesized system row. - * - * NEVER throws: this sits on the 204 path, and a classification failure — a settings store mid- - * write, a platform seam not yet initialized in an odd boot order — must cost the label, not the - * event. NO filesystem access happens here: the dir is classified as a string, so a forged - * POST naming `~/.ssh/projects/x.jsonl` gets a `known: false` label and nothing is opened. - */ -function observedClaudeAccount( - agentId: string, - payload: Record -): ObservedClaudeAccount | undefined { - if (agentId !== 'claude') return undefined - const tp = payload.transcript_path - if (typeof tp !== 'string' || !tp) return undefined - try { - const dir = configDirFromTranscriptPath(tp) - if (!dir) return undefined - return classifyClaudeConfigDir(dir, { - homeDir: homedir(), - userDataDir: platform().userDataDir, - accounts: claudeAccountsSnapshot() - }) - } catch { - return undefined - } -} - /** * What the hook server knows about the POST an event arrived on, beyond the event itself. * diff --git a/src/core/claude-config-dir.ts b/src/core/claude-config-dir.ts index fefdc3870b..336995778d 100644 --- a/src/core/claude-config-dir.ts +++ b/src/core/claude-config-dir.ts @@ -10,9 +10,15 @@ // rather than threading the list through pty-manager, the usage service, the transcript readers // and both jails: a second copy of "where does this account's config live?" is exactly the drift // CLAUDE.md warns about, and the answer moved for linked accounts. +import { homedir } from 'os' import { platform } from './platform' -import { accountConfigDir, normalizeLinkedConfigDir } from './claude-accounts-core' -import type { ClaudeAccount } from '../shared/types' +import { + accountConfigDir, + classifyClaudeConfigDir, + configDirFromTranscriptPath, + normalizeLinkedConfigDir +} from './claude-accounts-core' +import type { ClaudeAccount, ObservedClaudeAccount } from '../shared/types' let accountsSource: (() => readonly ClaudeAccount[]) | undefined @@ -82,3 +88,39 @@ export function claudeConfigDirFor(accountId: string): string { const managed = accountConfigDir(platform().userDataDir, accountId) return linkedClaudeConfigDirFor(accountId) ?? managed } + +/** + * The `account` LABEL for a claude hook payload: `transcript_path` → + * `/projects/…` → which account that dir is. Undefined for every other agent and for a + * payload with no usable `transcript_path` — "we did not observe an account" and "the system + * account" are different facts and must stay distinguishable (CONTRIBUTING: a failed read is never + * evidence of absence), so an absent field is the honest answer, not a synthesized system row. + * + * The hook server attaches it to every normalized event (hook-server.ts), once, for both shells. It + * lives here rather than inside the server so the Android interop fixture labels the events it + * feeds the agent-status mirror with this same function (audit A64). + * + * NEVER throws: this sits on the 204 path, and a classification failure — a settings store mid- + * write, a platform seam not yet initialized in an odd boot order — must cost the label, not the + * event. NO filesystem access happens here: the dir is classified as a string, so a forged + * POST naming `~/.ssh/projects/x.jsonl` gets a `known: false` label and nothing is opened. + */ +export function observedClaudeAccount( + agentId: string, + payload: Record +): ObservedClaudeAccount | undefined { + if (agentId !== 'claude') return undefined + const tp = payload.transcript_path + if (typeof tp !== 'string' || !tp) return undefined + try { + const dir = configDirFromTranscriptPath(tp) + if (!dir) return undefined + return classifyClaudeConfigDir(dir, { + homeDir: homedir(), + userDataDir: platform().userDataDir, + accounts: claudeAccountsSnapshot() + }) + } catch { + return undefined + } +} diff --git a/src/core/projects-list-blob.test.ts b/src/core/projects-list-blob.test.ts new file mode 100644 index 0000000000..c0ea10539f --- /dev/null +++ b/src/core/projects-list-blob.test.ts @@ -0,0 +1,122 @@ +// The `projects.list` blob (core/projects-list-blob.ts): the one assembly the desktop's +// `listProjectsOutput` and the Android interop fixture share (audit A64). These pin its contract on +// the desktop side, where every CI run sees it; the Android relay interop test parses what the same +// function produces with the Kotlin client. +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, promises as fs, rmSync } from 'fs' +import os from 'os' +import path from 'path' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform } from './platform-fake' +import { WorkspaceStore } from './workspace-store' +import { + AGENT_STATUS_FILE, + _resetForTest as resetMirror, + flush as flushMirror, + initAgentStatusMirror, + recordAgentEvent +} from './agent-status-mirror' +import { buildProjectsListBlob, PROJECTS_SPLIT_MARK, STATUS_SPLIT_MARK } from './projects-list-blob' +import type { Workspace } from '../shared/types' + +let userData = '' + +beforeEach(() => { + userData = mkdtempSync(path.join(os.tmpdir(), 'nt-projects-blob-')) + resetPlatformForTests() + initPlatform(fakePlatform({ userDataDir: userData })) + resetMirror() +}) + +afterEach(() => { + resetMirror() + resetPlatformForTests() + rmSync(userData, { recursive: true, force: true }) +}) + +const EMPTY: Workspace = { version: 2, activeProjectId: '', projects: [] } + +/** Split exactly the way both phone clients do: on the marker lines. */ +function sections(blob: string): { workspace: string; sessions: string; status: string } { + const [workspace, rest] = blob.split(`\n${PROJECTS_SPLIT_MARK}\n`) + const [sessions, status] = rest.split(`\n${STATUS_SPLIT_MARK}\n`) + return { workspace, sessions, status } +} + +describe('buildProjectsListBlob', () => { + it('lays out workspace, session names and the mirror file between the two markers the phones split on', async () => { + await fs.writeFile(path.join(userData, AGENT_STATUS_FILE), '{"v":1}') + const blob = await buildProjectsListBlob({ + workspace: { load: async () => EMPTY }, + userDataDir: userData, + listSessions: async () => ['nt-a', 'nt-b'] + }) + // The markers are wire contract (NodetermProjects.swift, ProjectsParser.kt): pinned verbatim. + expect(PROJECTS_SPLIT_MARK).toBe('--NT-PROJECTS-SPLIT--') + expect(STATUS_SPLIT_MARK).toBe('--NT-STATUS-SPLIT--') + expect(blob).toBe(`${JSON.stringify(EMPTY)}\n--NT-PROJECTS-SPLIT--\nnt-a\nnt-b\n--NT-STATUS-SPLIT--\n{"v":1}`) + }) + + it('loads the workspace read-only, so a listing mid git-merge never sidelines a project file', async () => { + const seen: Array<{ sideline: boolean }> = [] + await buildProjectsListBlob({ + workspace: { + load: async (opts) => { + seen.push(opts) + return EMPTY + } + }, + userDataDir: userData, + listSessions: async () => [] + }) + expect(seen).toEqual([{ sideline: false }]) + }) + + it('degrades each failed read to an empty section and never rejects', async () => { + const blob = await buildProjectsListBlob({ + workspace: { load: () => Promise.reject(new Error('index unreadable')) }, + userDataDir: path.join(userData, 'missing'), + listSessions: () => Promise.reject(new Error('no tmux')) + }) + expect(blob).toBe(`\n${PROJECTS_SPLIT_MARK}\n\n${STATUS_SPLIT_MARK}\n`) + }) + + it('serves the ASSEMBLED v2 workspace from a v3 index, and the file the mirror itself wrote', async () => { + // The real store: a folder ref's nodes live only in its .nodeterm/project.json, never in the index. + const repo = path.join(userData, 'repo') + await new WorkspaceStore().save({ + version: 2, + activeProjectId: 'p1', + projects: [ + { + id: 'p1', + name: 'Demo', + color: '#0a84ff', + cwd: repo, + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [ + { id: 'term-1', kind: 'terminal', title: 'T', color: '#000', group: null, position: { x: 0, y: 0 }, size: { width: 1, height: 1 } } + ] + } + ] + }) + expect(JSON.parse(await fs.readFile(path.join(userData, 'workspace.json'), 'utf8')).version).toBe(3) + // The real mirror, at its default path: writer and reader must agree on the file. + initAgentStatusMirror() + recordAgentEvent({ nodeId: 'term-1', agentId: 'claude', kind: 'state', state: 'working', sessionId: 's-1' }) + await flushMirror() + + const blob = await buildProjectsListBlob({ + workspace: new WorkspaceStore(), + userDataDir: userData, + listSessions: async () => ['nt-term-1'] + }) + const s = sections(blob) + const workspace = JSON.parse(s.workspace) as Workspace + expect(workspace.version).toBe(2) + expect(workspace.projects[0]).toMatchObject({ id: 'p1', name: 'Demo', cwd: repo }) + expect(workspace.projects[0].nodes.map((n) => n.id)).toEqual(['term-1']) + expect(s.sessions).toBe('nt-term-1') + expect(JSON.parse(s.status).nodes['term-1']).toMatchObject({ state: 'working', agentId: 'claude', sessionId: 's-1' }) + }) +}) diff --git a/src/core/projects-list-blob.ts b/src/core/projects-list-blob.ts new file mode 100644 index 0000000000..9e4f48d11d --- /dev/null +++ b/src/core/projects-list-blob.ts @@ -0,0 +1,51 @@ +import { promises as fs } from 'fs' +import path from 'path' +import type { Workspace } from '../shared/types' +import { AGENT_STATUS_FILE } from './agent-status-mirror' + +/** + * The `projects.list` blob the standing phone host serves over the relay (host-service.ts), and the + * ONE definition of how it is assembled: the desktop's `listProjectsOutput` (src/main/index.ts) and + * the Android interop fixture (android/protocol/src/test/interop/host-fixture.ts) both call + * `buildProjectsListBlob`, so the Kotlin client is tested against the bytes the desktop serves, not + * against a hand-written copy of them (audit A64). + * + * Layout: `\n--NT-PROJECTS-SPLIT--\n\n + * --NT-STATUS-SPLIT--\n` — the same three sections, in the same order, that a phone + * on direct SSH reads off the host itself (`workspace.json`, `tmux ls`, `agent-status.json`). Both + * phone clients split on the markers: keep them in sync with NodetermProjects.swift (iOS, + * nodeterm-ios) and android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt. + */ +export const PROJECTS_SPLIT_MARK = '--NT-PROJECTS-SPLIT--' +export const STATUS_SPLIT_MARK = '--NT-STATUS-SPLIT--' + +export interface ProjectsListSources { + /** The machine's workspace store (`WorkspaceStore`). Loaded read-only; see `buildProjectsListBlob`. */ + workspace: { load(opts: { sideline: boolean }): Promise } + /** userData: where the agent-status mirror writes `AGENT_STATUS_FILE`. */ + userDataDir: string + /** The live nodeterm tmux session names (`nt-`). */ + listSessions(): Promise +} + +/** + * Build the blob. Reads the same things the SSH browse path reads on the host, without SSH. Every + * read is best-effort (a failed one degrades to an empty section), so this never rejects. + * + * The workspace section is the ASSEMBLED v2-shaped workspace, never the raw workspace.json: since the + * v3 migration that file is an index whose folder refs hold no node data at all, and the phones decode + * `{ projects: [Project] }`, so a raw v3 file would list zero projects. `load()` re-reads each ref's + * `.nodeterm/project.json` and returns `{version: 2, projects: […]}`; it is idempotent, so calling it + * per request is safe. It runs with `sideline: false`: a phone listing projects in the middle of a git + * merge must not move a conflict-marked project.json aside to `.corrupt-` (sidelining is for the + * boot and renderer loads only). + */ +export async function buildProjectsListBlob(src: ProjectsListSources): Promise { + const workspace = await src.workspace + .load({ sideline: false }) + .then((w) => JSON.stringify(w)) + .catch(() => '') + const status = await fs.readFile(path.join(src.userDataDir, AGENT_STATUS_FILE), 'utf8').catch(() => '') + const sessions = (await src.listSessions().catch(() => [])).join('\n') + return `${workspace}\n${PROJECTS_SPLIT_MARK}\n${sessions}\n${STATUS_SPLIT_MARK}\n${status}` +} diff --git a/src/main/index.ts b/src/main/index.ts index a1f77e24bf..0b46da42da 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,7 +7,7 @@ import { join, resolve, posix } from 'path' import { startSessionNameSweep, displayNodeTitle } from '../core/session-name-sweep' import { startTriggerService } from '../core/trigger-service' import { readAgentSessionName, type AgentSessionNameDeps } from '../core/agent-session-name' -import { readFile, realpath as fsRealpath, lstat as fsLstat, writeFile as fsWriteFile } from 'fs/promises' +import { realpath as fsRealpath, lstat as fsLstat, writeFile as fsWriteFile } from 'fs/promises' import { existsSync, statSync, openSync, fstatSync, readFileSync, closeSync } from 'fs' import { homedir, hostname } from 'os' import { randomUUID } from 'crypto' @@ -186,6 +186,7 @@ import { nodeSessionName, workingNodes } from '../core/agent-status-mirror' +import { buildProjectsListBlob } from '../core/projects-list-blob' import { paneOwnerProject } from '../core/agents/pane-ownership' import { createPushNotify, createLiveUpdatePush } from '../core/push-notify' import { createGrantsAccessor, type PushGrant } from '../core/push-grants' @@ -622,37 +623,18 @@ ptyManager.setProjectSpawnOverrides( }) ) -// Markers delimiting the `projects.list` relay blob. Both phone clients split on these exact -// strings to recover [workspace.json | newline-joined tmux session names | agent-status.json], -// matching the SSH browse pipeline they already use — keep them in sync with NodetermProjects.swift -// (iOS, nodeterm-ios) and android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt. -// The Android interop fixture (android/protocol/src/test/interop/host-fixture.ts) serves a -// hand-written blob, not this function's output, so a change to the shape owes it too. -const NT_PROJECTS_MARK = '--NT-PROJECTS-SPLIT--' -const NT_STATUS_MARK = '--NT-STATUS-SPLIT--' - /** - * Build the marker-delimited projects blob served over the relay's `projects.list` RPC. Reads the - * same files the SSH browse path reads locally on the host (no SSH): `workspace.json` + - * `agent-status.json` under userData, plus the live nodeterm tmux session names. Every read is - * best-effort (missing files degrade to an empty section) so this never throws. + * The `projects.list` relay blob. Assembled by `buildProjectsListBlob` (src/core/projects-list-blob.ts), + * which the Android interop fixture calls too, so the markers, the section order and the read-only + * workspace load live in one place; this shell only names its sources: the workspace store, userData + * (where the agent-status mirror writes its file) and the live nodeterm tmux session names. */ async function listProjectsOutput(): Promise { - const dir = app.getPath('userData') - // Serve the ASSEMBLED v2-shaped workspace, never the raw workspace.json. Post-migration the file - // is a v3 index ({version:3, entries:[…]}) whose local-ref entries hold no node data at all — the - // paired iOS client decodes `{ projects: [Project] }`, so a raw v3 file lists zero projects. - // load() re-reads each ref's .nodeterm/project.json and returns {version:2, projects:[…]}; it is - // idempotent (and re-syncs the watcher via onPersist), so calling it here is safe. - const workspace = await workspaceStore - // Read-only: a phone listing projects mid git-merge must NOT sideline a conflict-marked - // project.json to `.corrupt-` (the probe/watcher-path fix); sideline is boot/renderer-only. - .load({ sideline: false }) - .then((w) => JSON.stringify(w)) - .catch(() => '') - const status = await readFile(join(dir, 'agent-status.json'), 'utf8').catch(() => '') - const sessions = (await ptyManager.listNodetermSessions().catch(() => [])).join('\n') - return `${workspace}\n${NT_PROJECTS_MARK}\n${sessions}\n${NT_STATUS_MARK}\n${status}` + return buildProjectsListBlob({ + workspace: workspaceStore, + userDataDir: app.getPath('userData'), + listSessions: () => ptyManager.listNodetermSessions() + }) } // Remote git routing is scoped to the ACTIVE project only (set via `git:set-active-remote`). diff --git a/src/main/remote/android-interop-fixture.guard.test.ts b/src/main/remote/android-interop-fixture.guard.test.ts index 13ae3aa29b..a18a0ceca4 100644 --- a/src/main/remote/android-interop-fixture.guard.test.ts +++ b/src/main/remote/android-interop-fixture.guard.test.ts @@ -65,6 +65,30 @@ describe('the Android interop fixture is type-checked against the desktop interf expect(missing, 'add these to tsconfig.node.json\'s include (see the comment there)').toEqual([]) }, 60_000) + it('serves projects.list from the desktop\'s own assembly, never a hand-written blob or mirror (audit A64)', () => { + // The fixture used to serve a blob it wrote by hand, markers and agent-status mirror included, so a + // desktop change to either could not fail an Android test. Now both it and `listProjectsOutput` + // call core's `buildProjectsListBlob`, and the fixture's mirror file comes out of the mirror. + const code = (file: string): string => + readFileSync(file, 'utf8') + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n') + const fixture = code(join(INTEROP_DIR, 'host-fixture.ts')) + const index = code(join(REPO_ROOT, 'src', 'main', 'index.ts')) + for (const [name, src] of [['host-fixture.ts', fixture], ['src/main/index.ts', index]]) { + expect(src, `${name} spells a projects.list marker; import it from src/core/projects-list-blob.ts`).not.toMatch( + /--NT-(PROJECTS|STATUS)-SPLIT--/ + ) + expect(src, `${name} must build the blob with buildProjectsListBlob`).toMatch(/\bbuildProjectsListBlob\(/) + } + expect(fixture).toMatch(/\blistProjects:\s*\(\)\s*=>\s*buildProjectsListBlob\(/) + // A mirror document written by hand carries its version key; the real one comes from the writer. + expect(fixture, 'write agent-status.json with the mirror (recordAgentEvent + flush), not by hand').not.toMatch(/\bv:\s*1\b/) + expect(fixture).toMatch(/\brecordAgentEvent\(/) + }) + it('hands the desktop code nothing through a cast that would switch the type check off', () => { const CAST = /\bas\s+(?:unknown\s+as|never|any)\b/ const offenders: string[] = [] From 6f3a8da51356a6283b3408226c7fe492acfa13dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:12:16 +0000 Subject: [PATCH 081/284] ci(android): validate the Gradle wrapper, cache Gradle, Dependabot and CodeQL for the Kotlin (A69) The Android build had none of the supply-chain coverage the npm side has: no Dependabot entry for its Gradle dependencies (okhttp, sshj, eddsa, bcprov, AGP, Kotlin), no static analysis of its Kotlin, and CI executed the committed gradle-wrapper.jar unchecked and re-downloaded Gradle, AGP and every dependency on each run. - android.yml: every job that runs ./gradlew now runs gradle/actions/setup-gradle@v6 first. It validates android/gradle/wrapper/gradle-wrapper.jar against Gradle's published checksums and caches ~/.gradle. The cache is the MIT `basic` provider, not the default `enhanced` one, which is the closed-source gradle-actions-caching library under the Gradle Technologies Terms of Use; accepting those for the repository is the owner's call and is a one-input switch. The basic provider uses one key for every job (a hash of the build files; read from its source), and the first job to save a key keeps it, so `app` is the single writer (on the default branch only, the action's default) and the protocol, app-release and CodeQL jobs set cache-read-only. - security.yml: a new `CodeQL (Kotlin)` job (java-kotlin, security-extended, build-mode manual) builds `:app:compileDebugKotlin`, which compiles the app and, through includeBuild("protocol"), the protocol module. build-mode none would have scanned nothing, since it reads Java only and android/ is all Kotlin. --no-daemon and kotlin.compiler.execution.strategy=in-process keep the compiler inside the traced Gradle process (checked locally: with the property no KotlinCompileDaemon starts for the root or the included build, without it one does); --no-build-cache keeps a cache hit from skipping the compile. It is a separate job so the required `CodeQL` check is unchanged, and it runs on security.yml's triggers (every PR to main, push to main, merge queue, weekly) rather than android.yml's path filter, so main's baseline and every PR carry the analysis. It is not a required check. Permissions: contents read, security-events write. - dependabot.yml: one weekly gradle entry for /android, grouped like npm (minor/patch in one PR, majors alone). Dependabot follows includeBuild (dependabot-core's gradle FileFetcher reads included_build_paths from settings.gradle.kts), so /android covers the app, the protocol build and the wrapper. android/tools/typecheck is deliberately not watched: CI does not run it, and its pins mirror or stand in for the app's, so they move by hand (its README says so now). - GradleCiCoverageTest (android/protocol) pins the four facts: setup-gradle with wrapper validation before the first ./gradlew in every job of android.yml and security.yml, exactly one basic-cache writer, a java-kotlin CodeQL job that is not build-mode none and compiles the app (plus protocol via includeBuild) between init and analyze, and each Gradle build under android/ covered by Dependabot exactly once except tools/typecheck. It reads security.yml and dependabot.yml, so both join android.yml's path filters (WorkflowPathFilterTest requires it) and the protocol test task's inputs. Departures from the audit's proposal: one Dependabot directory (/android), not /android and /android/protocol, since the second would open every protocol bump twice; CodeQL with a traced build (manual), not build-mode none, per the verifier. Not done: distributionSha256Sum. There is no local gradle-8.14.3-bin.zip here (only the extracted /opt/gradle-8.14.3) and I am not certain of the value from memory; a wrong one breaks every run. Once added, Dependabot keeps it current (its wrapper updater passes --gradle-distribution-sha256-sum). Also not done: gradle/actions/dependency-submission, which Dependabot alerts and dependency review need for Gradle; it needs contents: write and is left as a follow-up. Verified: the full protocol suite (331 tests, 0 skipped, 0 failed); the offline app type-check; the three edited YAML files parse and validate against SchemaStore's dependabot-2.0 and github-workflow schemas; the action inputs used (cache-provider, cache-read-only, build-mode) were read from the v6 and v4 action.yml files. Mutation checks: with the three config files at HEAD the new test fails on the wrapper, CodeQL and Dependabot checks (and WorkflowPathFilterTest on the path filter); removing setup-gradle from app-release, a second cache writer, build-mode none, and an extra /android/protocol entry each fail it. Not verified: no Actions run here, so the cache, the wrapper validation and whether CodeQL's Kotlin extractor handles this build (Kotlin 2.2.0, AGP 8.9.1) show only on the first CI run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/dependabot.yml | 21 ++ .github/workflows/android.yml | 35 ++ .github/workflows/security.yml | 53 ++- CONTRIBUTING.md | 2 + android/README.md | 8 + android/protocol/build.gradle.kts | 8 +- .../nodeterm/protocol/GradleCiCoverageTest.kt | 330 ++++++++++++++++++ android/tools/typecheck/README.md | 5 + 8 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f439a17c86..b82c6c7722 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,3 +39,24 @@ updates: interval: weekly day: monday open-pull-requests-limit: 5 + + # The Android companion's Gradle build (audit A69), weekly and grouped like npm: one PR carries the + # routine minor/patch bumps, a major (AGP, Kotlin, Gradle itself) comes on its own. /android covers + # the whole build: the app (include(":app")), the protocol module, which is an included build + # (includeBuild("protocol") in android/settings.gradle.kts; Dependabot follows includeBuild, so it + # needs no entry of its own, and a second entry for /android/protocol would open each of its bumps + # twice), and the Gradle wrapper in android/gradle/wrapper. Not android/tools/typecheck: that offline + # type-check is not run by CI, so a PR against it would be untested, and its pins mirror the app's + # (Kotlin, BouncyCastle) or stand in for them (Compose Multiplatform desktop for Jetpack Compose, + # robolectric's android-all for compileSdk 35), so they move by hand with the app's, never to + # whatever is newest. GradleCiCoverageTest (android/protocol) pins this. + - package-ecosystem: gradle + directory: /android + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + android-minor-patch: + patterns: ["*"] + update-types: [minor, patch] diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a9d90bfc71..24b392fa0e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,6 +22,8 @@ name: Android # the fixture's type check runs in ci.yml, which has no path filter) # package.json, package-lock.json `npm ci` installs esbuild, ws and tweetnacl from them # .github/workflows/android.yml this file +# .github/workflows/security.yml, .github/dependabot.yml GradleCiCoverageTest (android/protocol) +# reads them: the Kotlin CodeQL job and the Gradle Dependabot entry (audit A69) # To re-derive: `node android/protocol/src/test/interop/bundle-fixture.cjs # android/protocol/src/test/interop/electron-stub.ts` from the repo root writes .meta.json, # whose `inputs` are the bundled files. WorkflowPathFilterTest (android/protocol) fails when one is not @@ -39,6 +41,8 @@ on: - 'package.json' - 'package-lock.json' - '.github/workflows/android.yml' + - '.github/workflows/security.yml' + - '.github/dependabot.yml' pull_request: paths: - 'android/**' @@ -50,6 +54,8 @@ on: - 'package.json' - 'package-lock.json' - '.github/workflows/android.yml' + - '.github/workflows/security.yml' + - '.github/dependabot.yml' workflow_dispatch: concurrency: @@ -59,6 +65,20 @@ concurrency: permissions: contents: read +# Every job runs gradle/actions/setup-gradle before ./gradlew (audit A69). It checks the committed +# android/gradle/wrapper/gradle-wrapper.jar against Gradle's published wrapper checksums, so a jar that +# is not a released Gradle wrapper fails the job instead of running, and it caches ~/.gradle (the wrapper's Gradle distribution, AGP +# and the dependencies), which every run used to download again. The cache is the MIT-licensed `basic` +# provider (a thin wrapper over actions/cache), not the action's default `enhanced` one: that is the +# closed-source gradle-actions-caching library under the Gradle Technologies Terms of Use, and using it +# accepts those terms for this repository, which is the owner's call (switching is this one input). +# The basic provider uses ONE key for every job, setup-java---gradle-, shared with security.yml's CodeQL (Kotlin) job, and the first job to +# save a key keeps it. So exactly one job writes it: `app`, which resolves the most (AGP, the app's and +# the protocol module's dependencies, the Gradle distribution), and only on the default branch (the +# action's own default; a pull request never writes). The others read it with cache-read-only and +# download what only they use. GradleCiCoverageTest (android/protocol) pins the validation step and the +# single writer. jobs: protocol: name: Protocol (JVM + desktop interop) @@ -88,6 +108,11 @@ jobs: distribution: temurin java-version: '17' + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + cache-read-only: true + - name: Protocol tests working-directory: android run: ./gradlew -p protocol test --stacktrace @@ -109,6 +134,11 @@ jobs: distribution: temurin java-version: '17' + # The one job that writes the shared Gradle cache (see the note above `jobs:`). + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + - name: Build debug APK working-directory: android run: ./gradlew :app:assembleDebug --stacktrace @@ -137,6 +167,11 @@ jobs: distribution: temurin java-version: '17' + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + cache-read-only: true + - name: Build release APK (R8) working-directory: android run: ./gradlew :app:assembleRelease --stacktrace diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 25e72c66fb..f514900823 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,9 +1,11 @@ name: Security -# Two independent security gates on every PR into main (and a weekly baseline scan): +# Independent security gates on every PR into main (and a weekly baseline scan): # - CodeQL static analysis of the JS/TS sources (main + preload + renderer + server). +# - CodeQL static analysis of the Android companion's Kotlin (android/: the app and the protocol +# module), in its own job; see the note above it. # - Dependency review: blocks a PR that adds a vulnerable or disallowed-license dep. -# Both are free on public repos and add no secrets to the PR runner. +# All are free on public repos and add no secrets to the PR runner. on: # Merge-queue runs: required checks must also report on the queue's synthetic # merge commits, or every queued PR times out waiting for them. @@ -22,7 +24,7 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -# Least privilege: only CodeQL needs security-events:write to upload results. +# Least privilege: only the CodeQL jobs need security-events:write to upload results. permissions: contents: read @@ -44,6 +46,51 @@ jobs: with: category: '/language:javascript-typescript' + # The Android companion's Kotlin (audit A69): the protocol module (the NaCl box, the relay client, + # pairing, SSH host-key pinning) and the app (the Keystore-held secrets, the terminal WebView bridge). + # A job of its own rather than a second language in the job above, so that job's name and required + # check (`CodeQL`) stay exactly as they were; this one is not a required check. + # build-mode none would analyse nothing: for java-kotlin it reads Java sources only, and android/ is all + # Kotlin. So CodeQL traces a build of it (manual). `:app:compileDebugKotlin` compiles the app and, + # through settings.gradle.kts' includeBuild("protocol"), the protocol module's main sources (tests are + # not analysed); it needs the Android SDK that ubuntu-latest ships, as android.yml's app job does. + # --no-daemon keeps the build in a process CodeQL traces; the in-process strategy keeps the Kotlin + # compiler inside it rather than in a separate Kotlin daemon (the property reaches an included build + # too: checked locally on tools/typecheck, which includes the protocol build the same way; with it no + # KotlinCompileDaemon starts, without it one does); + # --no-build-cache keeps a cache hit from skipping the compile CodeQL extracts from. + # It runs on this workflow's triggers, not android.yml's path filter, so main's baseline, the merge + # queue and the weekly re-scan all carry the analysis and every pull request is compared with it. + # setup-gradle validates the wrapper jar before ./gradlew runs and reads the Gradle cache android.yml's + # app job writes (see the note there). GradleCiCoverageTest (android/protocol) pins this job. + codeql-kotlin: + name: CodeQL (Kotlin) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + cache-read-only: true + - uses: github/codeql-action/init@v4 + with: + languages: java-kotlin + build-mode: manual + queries: security-extended + - name: Build the Kotlin (app + protocol) under CodeQL + working-directory: android + run: ./gradlew --no-daemon --no-build-cache -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugKotlin --stacktrace + - uses: github/codeql-action/analyze@v4 + with: + category: '/language:java-kotlin' + dependency-review: name: Dependency review # Only meaningful on a PR (it diffs the PR's manifest against the base). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec5e3be0f5..6c1603297b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -853,6 +853,8 @@ in CLAUDE.md § Idle energy. ## Pull requests - Branch from `main`. CI runs `quality`, `CodeQL` and `Dependency review`; all three are required. + `CodeQL (Kotlin)` (the Android companion's Kotlin, built under CodeQL) runs on every PR too, but is + not one of them. - Explain **why**, not just what. If a decision has a trade-off, name it and say what you rejected. - If you measured something, put the numbers in — they save the next person the same afternoon. - Say what you did **not** verify. That is more useful than a confident summary. diff --git a/android/README.md b/android/README.md index bd7312cdd9..d5a04bb217 100644 --- a/android/README.md +++ b/android/README.md @@ -54,6 +54,14 @@ one exception name). A keep that NEW reflection needs is not detected, because R such code without a word; add the keep and a line in `tools/check-r8-output.sh`. None of this proves a minified APK works on a phone; none has been run on one. +Every CI job that runs `./gradlew` first checks `gradle/wrapper/gradle-wrapper.jar` against Gradle's +published checksums (`gradle/actions/setup-gradle`, which also caches `~/.gradle`). Dependabot opens +weekly Gradle update PRs for `android/`, the protocol build and the wrapper included (not for +`tools/typecheck`, whose pins follow the app's by hand), and CodeQL analyses the app's and the protocol +module's Kotlin on every pull request (`.github/workflows/security.yml`, job `CodeQL (Kotlin)`). +`GradleCiCoverageTest` pins all three. The wrapper properties carry no `distributionSha256Sum` yet, so +the Gradle distribution itself is not pinned. + **Debug builds are signed with a public key that is committed on purpose** (`app/debug.keystore`, password `android`). That is what lets you install a newer CI or local debug APK over an older one without uninstalling — uninstalling wipes every pairing. The other side diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index bc8680b99b..6c44b5ccda 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -46,8 +46,9 @@ tasks.test { systemProperty("nodeterm.repoRoot", rootDir.resolve("../..").canonicalPath) // Files some tests run or read that are not on the test classpath: the node drivers, the app's // terminal page (TerminalJs*Test), the app's sources (the source pins in TerminalKeyboardChipTest, - // SettingsLeaveTest and R8RulesTest), and the release build's R8 rules, its build type and the CI - // workflow that runs R8 (R8RulesTest). + // SettingsLeaveTest and R8RulesTest), the release build's R8 rules, its build type and the CI + // workflow that runs R8 (R8RulesTest), and the CI config GradleCiCoverageTest checks: the workflows + // that run Gradle, the Dependabot config and the root build's settings. // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) // The desktop code the interop fixture bundles (the same dirs as android.yml's path filter). Without @@ -62,6 +63,9 @@ tasks.test { inputs.file(rootDir.resolve("../app/proguard-rules.pro")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../app/build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../../.github/workflows/android.yml")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../../.github/workflows/security.yml")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../../.github/dependabot.yml")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../settings.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) // R8RulesTest reads the jars this module ships to the app (its runtime classpath, not the test one) // for the classes they reference that Android lacks (audit A37). val shippedJars: FileCollection = configurations.runtimeClasspath.get() diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt new file mode 100644 index 0000000000..8931ffb13f --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt @@ -0,0 +1,330 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Audit A69: the Gradle/Kotlin build had none of the supply-chain coverage the npm side has. This pins + * the three pieces that closed it, all in CI config nothing else checks: + * + * 1. Every CI job that runs `./gradlew` runs `gradle/actions/setup-gradle` first, with wrapper + * validation on: it checks the committed `android/gradle/wrapper/gradle-wrapper.jar` against + * Gradle's published checksums before CI executes it. And exactly one job writes the Gradle cache + * the jobs share (android.yml says why). + * 2. CodeQL analyses the Kotlin. A `java-kotlin` analysis with build mode `none` reads Java sources + * only, and everything under android/ is Kotlin, so it would scan nothing while looking like + * coverage; the Kotlin has to be compiled between CodeQL's `init` and `analyze`. + * 3. Dependabot watches every Gradle build under android/ exactly once: a build another build + * includes is covered by that build's entry (Dependabot follows `includeBuild`), and a second + * entry would open each of its bumps twice. Except the builds in [notWatched]. + * + * The YAML is read as text, in the block style this repo's workflows use, as [WorkflowPathFilterTest] + * does; what these readers do not understand fails rather than being guessed. + */ +class GradleCiCoverageTest { + private val workflows = listOf( + File(InteropHarness.repoRoot, ".github/workflows/android.yml"), + File(InteropHarness.repoRoot, ".github/workflows/security.yml"), + ) + private val securityWorkflow = File(InteropHarness.repoRoot, ".github/workflows/security.yml") + private val dependabot = File(InteropHarness.repoRoot, ".github/dependabot.yml") + private val androidSettings = File(InteropHarness.repoRoot, "android/settings.gradle.kts") + + /** Gradle builds under android/ that Dependabot deliberately does not watch, with why. */ + private val notWatched = mapOf( + "android/tools/typecheck" to "the offline type-check is not run by CI, and its pins mirror or stand in " + + "for the app's, so they move by hand with the app's (see .github/dependabot.yml)", + ) + + @Test + fun `every job that runs gradlew validates the wrapper first`() { + var gradleJobs = 0 + for (file in workflows) { + for ((id, job) in jobs(read(file))) { + val steps = steps(job) + val firstGradlew = steps.indexOfFirst(::runsGradlew) + if (firstGradlew < 0) continue + gradleJobs++ + val setup = steps.indexOfFirst(::isSetupGradle) + assertTrue( + setup in 0 until firstGradlew, + "${file.name} job `$id` runs ./gradlew without a gradle/actions/setup-gradle step before it. That " + + "step validates android/gradle/wrapper/gradle-wrapper.jar, a committed binary CI is about " + + "to execute (audit A69)." + ) + assertTrue( + steps[setup].none { Regex("""^validate-wrappers:\s*['"]?false""").containsMatchIn(it) }, + "${file.name} job `$id` turns setup-gradle's wrapper validation off" + ) + } + } + // android.yml's protocol, app and app-release jobs and security.yml's CodeQL (Kotlin) job. + assertTrue(gradleJobs >= 4, "found only $gradleJobs jobs running ./gradlew; has the reader stopped seeing them?") + } + + @Test + fun `exactly one job writes the Gradle cache the jobs share`() { + // The basic provider's key is the same for every job (a hash of the build files), and the first + // job to save a key keeps it: with several writers, whichever finished first decided what was + // cached. The enhanced provider keys per job, so the rule applies to basic steps only. + val basic = workflows.flatMap { file -> + jobs(read(file)).flatMap { (id, job) -> + steps(job).filter { isSetupGradle(it) && value(it, "cache-provider") == "basic" }.map { "${file.name}:$id" to it } + } + } + if (basic.isEmpty()) return + val writers = basic.filter { (_, step) -> value(step, "cache-read-only") != "true" && value(step, "cache-disabled") != "true" } + assertEquals( + 1, writers.size, + "setup-gradle steps with cache-provider: basic that write the shared cache: ${writers.map { it.first }}; " + + "exactly one may (the others set cache-read-only: true; android.yml says why)" + ) + } + + @Test + fun `CodeQL compiles and analyses the app's and the protocol module's Kotlin`() { + val kotlinJobs = jobs(read(securityWorkflow)).filter { (_, job) -> + steps(job).any { isCodeqlInit(it) && "java-kotlin" in value(it, "languages").orEmpty() } + } + assertEquals(1, kotlinJobs.size, "security.yml must have one CodeQL job for java-kotlin (audit A69)") + val (id, job) = kotlinJobs.entries.single() + val steps = steps(job) + val init = steps.indexOfFirst(::isCodeqlInit) + val analyze = steps.indexOfFirst { uses(it)?.startsWith("github/codeql-action/analyze@") == true } + assertTrue(analyze > init, "job `$id`: no github/codeql-action/analyze after init") + val mode = value(steps[init], "build-mode") + assertTrue( + mode == "manual" || mode == "autobuild", + "job `$id`: build-mode is ${mode ?: "not set"}; for java-kotlin only a build (manual or autobuild) " + + "extracts Kotlin, and android/ holds no Java to read without one" + ) + assertTrue( + job.any { it.trim().replace(" ", "") == "security-events:write" }, + "job `$id` needs security-events: write to upload its results" + ) + if (mode == "manual") { + val build = steps.subList(init + 1, analyze).filter(::runsGradlew) + assertTrue(build.isNotEmpty(), "job `$id`: build-mode manual, but no ./gradlew step between init and analyze") + val command = build.joinToString(" ") { it.joinToString(" ") } + assertTrue(Regex("""compile\w*Kotlin""").containsMatchIn(command), "job `$id` does not compile Kotlin: $command") + // The app is compiled from android/ (the root build); the protocol module comes with it as an + // included build, or has to be named on its own. + val app = Regex(""":app:compile\w*Kotlin""").containsMatchIn(command) + val protocolIncluded = "includeBuild(\"protocol\")" in stripComments(read(androidSettings)) + assertTrue(app, "job `$id` does not compile the app (its Keystore and WebView code): $command") + assertTrue( + protocolIncluded || "-p protocol" in command, + "job `$id` compiles the app, but android/settings.gradle.kts no longer includes the protocol " + + "build, so the protocol module's Kotlin is not analysed: $command" + ) + } + } + + @Test + fun `Dependabot watches each Gradle build under android once`() { + val root = InteropHarness.repoRoot + val androidDir = androidSettings.parentFile + val builds = androidDir.walkTopDown() + .onEnter { it.name != "build" && it.name != ".gradle" && it.name != "node_modules" } + .filter { it.isFile && it.name.startsWith("settings.gradle") } + .map { it.parentFile.relativeTo(root).invariantSeparatorsPath } + .toSortedSet() + assertTrue("android" in builds && "android/protocol" in builds, "found only these Gradle builds: $builds") + + val directories = gradleDirectories(read(dependabot)) + val covered = directories.flatMap { dir -> + val build = File(root, dir.removePrefix("/")) + assertTrue( + File(build, "settings.gradle.kts").isFile || File(build, "build.gradle.kts").isFile, + "Dependabot's gradle directory $dir holds no Gradle build" + ) + coveredBuilds(build).map { it.relativeTo(root).invariantSeparatorsPath } + } + for (build in builds) { + val times = covered.count { it == build } + val reason = notWatched[build] + if (reason != null) { + assertEquals(0, times, "Dependabot watches $build, which is deliberately not watched: $reason") + } else { + assertEquals( + 1, times, + "Dependabot's gradle entries (directories $directories) cover the Gradle build $build $times " + + "times; it must be exactly once (audit A69: none leaves it without update PRs, two open " + + "each of its bumps twice)" + ) + } + } + } + + @Test + fun `the readers find what they are asked for and refuse what they do not understand`() { + val workflow = """ + name: x + on: + push: + jobs: + # a comment + one: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - uses: actions/checkout@v7 + # a comment naming ./gradlew + - uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: true + - name: Build + run: | + # a shell comment naming ./gradlew + ./gradlew build + two: + steps: + - name: Build ./gradlew in the name only + run: echo nothing + """.trimIndent() + val jobs = jobs(workflow) + assertEquals(listOf("one", "two"), jobs.keys.toList()) + val steps = steps(jobs.getValue("one")) + assertEquals(3, steps.size) + assertEquals(listOf(false, false, true), steps.map(::runsGradlew)) + assertEquals("true", value(steps[1], "cache-read-only")) + assertEquals(listOf(false), steps(jobs.getValue("two")).map(::runsGradlew)) + + val config = """ + version: 2 + updates: + - package-ecosystem: npm + directory: / + ignore: + - dependency-name: electron + - package-ecosystem: "gradle" + directory: '/android' + """.trimIndent() + assertEquals(listOf("/android"), gradleDirectories(config)) + assertFailsWith { + gradleDirectories("updates:\n - package-ecosystem: gradle\n directories:\n - /android\n") + } + assertEquals( + listOf("protocol", "../x"), + includedBuilds("// includeBuild(\"commented\")\nincludeBuild(\"protocol\")\n includeBuild('../x')\ninclude(\":app\")\n") + ) + } + + companion object { + private fun read(file: File) = file.readText().replace("\r\n", "\n") + + private fun indent(line: String) = line.length - line.trimStart().length + private fun skip(line: String) = line.isBlank() || line.trimStart().startsWith("#") + + /** The top-level `jobs:` of a workflow, as each job's non-comment lines (its id line excluded). */ + internal fun jobs(yaml: String): Map> { + val lines = yaml.lines() + val start = lines.indexOfFirst { it.trimEnd() == "jobs:" } + if (start < 0) throw AssertionError("no top-level `jobs:`") + val out = LinkedHashMap>() + var jobIndent = -1 + for (line in lines.drop(start + 1)) { + if (skip(line)) continue + val ind = indent(line) + if (ind == 0) break + if (jobIndent < 0) jobIndent = ind + if (ind == jobIndent) { + val id = Regex("""([A-Za-z0-9_-]+):""").matchEntire(line.trim())?.groupValues?.get(1) + ?: throw AssertionError("unexpected line in jobs: $line") + out[id] = ArrayList() + } else if (ind > jobIndent) { + out.values.last().add(line) + } else { + throw AssertionError("unexpected indentation in jobs: $line") + } + } + return out + } + + /** A job's steps, each as its trimmed lines (the first without its `- `). */ + internal fun steps(job: List): List> { + val s = job.indexOfFirst { it.trim() == "steps:" } + if (s < 0) return emptyList() + val stepsIndent = indent(job[s]) + val out = ArrayList>() + var itemIndent = -1 + for (line in job.drop(s + 1)) { + if (skip(line)) continue + val ind = indent(line) + if (ind <= stepsIndent) break + if (itemIndent < 0) itemIndent = ind + val text = line.trim() + if (ind == itemIndent) { + if (!text.startsWith("- ")) throw AssertionError("unexpected line in steps: $line") + out.add(arrayListOf(text.removePrefix("- ").trim())) + } else if (ind > itemIndent) { + out.last().add(text) + } else { + throw AssertionError("unexpected indentation in steps: $line") + } + } + return out + } + + /** The value of `key:` among a step's (or an update entry's) lines, unquoted; null when absent. */ + internal fun value(lines: List, key: String): String? = + lines.firstOrNull { it.startsWith("$key:") }?.removePrefix("$key:")?.trim()?.removeSurrounding("'")?.removeSurrounding("\"") + + private fun uses(step: List) = value(step, "uses") + private fun isSetupGradle(step: List) = uses(step)?.startsWith("gradle/actions/setup-gradle@") == true + private fun isCodeqlInit(step: List) = uses(step)?.startsWith("github/codeql-action/init@") == true + + /** Whether a step's `run:` invokes the wrapper (a step's name does not count). */ + internal fun runsGradlew(step: List): Boolean { + val run = step.indexOfFirst { it.startsWith("run:") } + return run >= 0 && step.drop(run).any { "gradlew" in it } + } + + /** The `directory:` of each `package-ecosystem: gradle` entry of a dependabot.yml. */ + internal fun gradleDirectories(yaml: String): List { + val lines = yaml.lines().filterNot(::skip) + val start = lines.indexOfFirst { it.trimEnd() == "updates:" } + if (start < 0) throw AssertionError("no top-level `updates:`") + val entries = ArrayList>() + var itemIndent = -1 + for (line in lines.drop(start + 1)) { + val ind = indent(line) + if (ind == 0) break + if (itemIndent < 0) itemIndent = ind + val text = line.trim() + if (ind == itemIndent) { + if (!text.startsWith("- ")) throw AssertionError("unexpected line in updates: $line") + entries.add(arrayListOf(text.removePrefix("- ").trim())) + } else { + entries.last().add(text) + } + } + return entries.filter { value(it, "package-ecosystem") == "gradle" }.map { entry -> + if (entry.any { it.startsWith("directories:") }) { + throw AssertionError("`directories:` is not understood by this reader; extend it") + } + value(entry, "directory") ?: throw AssertionError("a gradle entry without a directory: $entry") + } + } + + private fun stripComments(kts: String) = + kts.replace(Regex("""(?s)/\*.*?\*/"""), "").lines().joinToString("\n") { it.replace(Regex("""(^|\s)//.*$"""), "") } + + /** The builds a settings script includes with `includeBuild(...)`, as written (Dependabot's reading). */ + internal fun includedBuilds(settings: String): List = + Regex("""(?:^|\s)includeBuild\s*\(\s*["']([^"']+)["']\s*\)""").findAll(stripComments(settings)).map { it.groupValues[1] }.toList() + + /** [build] and every build it includes, transitively: what one Dependabot gradle entry covers. */ + private fun coveredBuilds(build: File, seen: MutableSet = HashSet()): List { + val dir = build.canonicalFile + if (!seen.add(dir)) return emptyList() + val settings = listOf("settings.gradle.kts", "settings.gradle").map { File(dir, it) }.firstOrNull { it.isFile } + ?: return listOf(dir) + return listOf(dir) + includedBuilds(read(settings)).flatMap { coveredBuilds(File(dir, it), seen) } + } + } +} diff --git a/android/tools/typecheck/README.md b/android/tools/typecheck/README.md index e4e60aa8f8..d7d9befc6a 100644 --- a/android/tools/typecheck/README.md +++ b/android/tools/typecheck/README.md @@ -22,6 +22,11 @@ API-level availability, and any difference between Compose Multiplatform desktop for Android. The real build is `./gradlew :app:assembleDebug` in `android/`, which CI runs (`.github/workflows/android.yml`). +Its dependency pins mirror the app's (the Kotlin plugin, BouncyCastle) or stand in for them (Compose +Multiplatform desktop for Jetpack Compose, robolectric's `android-all` for `compileSdk` 35), so they are +moved by hand when the app's move. Dependabot deliberately does not watch this build +(`.github/dependabot.yml` says why): its PRs would move them to whatever is newest instead. + When the app starts using a new androidx/Google-Maven-only class, add a stub with the same package, name and the signatures the app calls. `stubs/java/dev/nodeterm/android/R.java` must list every `R.*` id the Kotlin references. From 39e7995da8b1d221a7209684ded78084405b5a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:20:26 +0000 Subject: [PATCH 082/284] docs(android): scope npm ci --ignore-scripts to CI and bound the JDK range (A61, A71) A61: CONTRIBUTING.md and android/README.md told desktop contributors to run `npm ci --ignore-scripts` before the protocol tests. `npm ci` deletes node_modules and the flag skips the root postinstall (node-pty patch + electron-rebuild) and node-pty's own install, so a working desktop checkout came out with node-pty unpatched (on Linux, which has no node-pty prebuild, not built at all) and src/main/node-pty-patch.test.ts red. The interop harness only needs node plus esbuild, ws and tweetnacl in node_modules, which the ordinary `npm install` already provides. Both docs now say so, say the flag is CI's (for a machine without the native toolchain), and name the recovery: `npm install` or `npm run rebuild` (bootstrap-windows.bat on Windows). A71: android/README.md said "JDK 17+", but the wrapper's Gradle 8.14.3 runs on JDK 24 at most (its embedded Kotlin compiler rejects "25" while compiling the build scripts). The README now says JDK 17-24, that Android Studio's bundled JDK works and CI uses 17, and that JDK 25 needs Gradle 9.1+. Every other doc stating a JDK requirement was checked (grep): the typecheck README said "a JDK 17+" and now states the same bound; CONTRIBUTING.md's protocol test line gains "on JDK 17-24". Regression test: ContributorDocsTest reads the four contributor docs and checks (1) no open-ended "JDK n+" claim, and every "JDK a-b" range ends at or below the newest Java the wrapper's Gradle can run on (from Gradle's compatibility matrix), starts at or above the protocol module's JvmTarget, and includes every java-version android.yml pins; (2) every paragraph that mentions --ignore-scripts names `npm run rebuild`. The docs and the wrapper properties are declared test inputs in protocol/build.gradle.kts, like the other files tests read, so a doc edit re-runs the test instead of leaving it up to date (the first mutation run showed exactly that stale pass). Verified: mutation check with the three docs reverted to HEAD fails both doc tests (README `JDK 17+`, CONTRIBUTING's --ignore-scripts paragraph), and a README range of `JDK 17-25` fails the bound. Full protocol suite (gradle test --offline, 334 tests, 0 failures) and the offline app type-check pass. JDK 25 is not installed here, so the JDK 25 failure itself is taken from the audit's verifier, not reproduced. Departures from the audit: none in substance. The option of pinning the daemon JVM (gradle-daemon-jvm.properties) or bumping the wrapper to Gradle 9.1+ was not taken; the audit's simplest fix (state the range) was. docs/android-handover.md still says `npm ci --ignore-scripts` (lines ~202 and ~371); left to the orchestrator as instructed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- CONTRIBUTING.md | 9 +- android/README.md | 15 +- android/protocol/build.gradle.kts | 9 +- .../nodeterm/protocol/ContributorDocsTest.kt | 148 ++++++++++++++++++ android/tools/typecheck/README.md | 5 +- 5 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c1603297b..e716eac26a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,8 +107,8 @@ applicable here": The **Android** companion is in this repo (`android/`, see docs/android.md). If you change what a phone sees — a `host-service.ts` relay verb, the `projects.list` blob, the pairing payload, the agent-status mirror, or the `~/.nodeterm/{pending,acks,relay.json}` files — update the - Android client in the same PR. `./gradlew -p protocol test` (from `android/`, after - `npm ci --ignore-scripts`) runs the relay leg against this repo's own host code: the handshake + Android client in the same PR. `./gradlew -p protocol test` (from `android/`, on JDK 17–24, + after your usual `npm install`) runs the relay leg against this repo's own host code: the handshake and host-service's verb routing (`connectHostSession`; the pty, board, inbox and node-action bridges behind the verbs are fakes), pairing (`createPairingService`), and the `projects.list` blob as the desktop builds it (`buildProjectsListBlob` in `src/core/projects-list-blob.ts` over @@ -122,7 +122,10 @@ applicable here": `npm run typecheck`, so changing one of them fails the typecheck there until the fixture follows. The Android workflow runs on every file that fixture bundles (`src/core`, `src/shared`, `src/main/*.ts`, `src/main/remote`); an import that pulls a file from elsewhere into it fails - `WorkflowPathFilterTest` until the workflow's `paths` list it. + `WorkflowPathFilterTest` until the workflow's `paths` list it. Do not copy CI's + `npm ci --ignore-scripts` onto a desktop checkout; it is for a machine without the native + toolchain. `npm ci` deletes `node_modules` first and the flag skips the node-pty patch and build, + so node-pty stays unpatched (unbuilt on Linux) until `npm install` or `npm run rebuild` runs again. Anything reachable from `window.nodeTerminal` needs a **real** implementation in `src/renderer/bridge/`, or a deliberate, documented degrade. The `satisfies NodeTerminalApi` gate diff --git a/android/README.md b/android/README.md index d5a04bb217..ea65558743 100644 --- a/android/README.md +++ b/android/README.md @@ -42,7 +42,11 @@ cd android ./gradlew -p protocol test # the wire layer; see below ``` -Needs JDK 17+ and the Android SDK (Android Studio's, or `ANDROID_HOME`). `minSdk` 26, `targetSdk` 35. +Needs JDK 17–24 and the Android SDK (Android Studio's, or `ANDROID_HOME`). `minSdk` 26, `targetSdk` 35. +Android Studio's bundled JDK works, and CI uses 17. JDK 25 does not: the wrapper's Gradle 8.14.3 cannot +run on it (its embedded Kotlin compiler rejects the version while compiling the build scripts, so +`./gradlew` stops before configuring anything); that needs Gradle 9.1 or newer. Point `JAVA_HOME` at a +17–24 JDK if your default is 25. CI builds the debug APK on every change under `android/` or to the desktop code the protocol tests run (`.github/workflows/android.yml`; its path filter says which) and attaches it to the run. The debug APK is not minified. Only the release build type runs R8 (`app/proguard-rules.pro`), and CI @@ -78,8 +82,13 @@ or your own build.** Release builds will need their own private key; none exists `createPairingService` through a local relay broker (`src/test/interop/host-fixture.ts`, bundled with the repo's esbuild and type-checked by the repo's `npm run typecheck` against the desktop interfaces it implements), and the SSH transport against a real SSH server and a sandboxed tmux. - Run `npm ci --ignore-scripts` at the repo root first; without node the interop tests skip. The SSH - tests need tmux and `script(1)` (util-linux on Linux, BSD on macOS) and skip without them. + They need node and the repo's `node_modules` (esbuild, ws, tweetnacl) and skip without them; a + desktop checkout's existing `npm install` is enough. `npm ci --ignore-scripts`, which CI runs, is only + for a machine without the native toolchain: `npm ci` deletes `node_modules` first and the flag skips + the node-pty patch and build, so over a working desktop checkout it leaves node-pty unpatched (on + Linux, where node-pty ships no prebuild, not built at all) and `src/main/node-pty-patch.test.ts` red + until you run `npm install` or `npm run rebuild` again (`bootstrap-windows.bat` on Windows). The + SSH tests need tmux and `script(1)` (util-linux on Linux, BSD on macOS) and skip without them. - **`app/`** — the Compose UI on top: pairing, the computers list, a computer's Sessions / Board / Inbox / Usage tabs, the terminal screen, settings, background notifications. diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index 6c44b5ccda..b132a6371f 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -47,8 +47,9 @@ tasks.test { // Files some tests run or read that are not on the test classpath: the node drivers, the app's // terminal page (TerminalJs*Test), the app's sources (the source pins in TerminalKeyboardChipTest, // SettingsLeaveTest and R8RulesTest), the release build's R8 rules, its build type and the CI - // workflow that runs R8 (R8RulesTest), and the CI config GradleCiCoverageTest checks: the workflows - // that run Gradle, the Dependabot config and the root build's settings. + // workflow that runs R8 (R8RulesTest), the CI config GradleCiCoverageTest checks: the workflows + // that run Gradle, the Dependabot config and the root build's settings, and the contributor docs + // and the wrapper's Gradle version ContributorDocsTest checks against each other. // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) // The desktop code the interop fixture bundles (the same dirs as android.yml's path filter). Without @@ -66,6 +67,10 @@ tasks.test { inputs.file(rootDir.resolve("../../.github/workflows/security.yml")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../../.github/dependabot.yml")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../settings.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../gradle/wrapper/gradle-wrapper.properties")).withPathSensitivity(PathSensitivity.RELATIVE) + for (doc in listOf("CONTRIBUTING.md", "docs/android.md", "android/README.md", "android/tools/typecheck/README.md")) { + inputs.file(rootDir.resolve("../../$doc")).withPathSensitivity(PathSensitivity.RELATIVE) + } // R8RulesTest reads the jars this module ships to the app (its runtime classpath, not the test one) // for the classes they reference that Android lacks (audit A37). val shippedJars: FileCollection = configurations.runtimeClasspath.get() diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt new file mode 100644 index 0000000000..ca3daa95e4 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt @@ -0,0 +1,148 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Two setup lines in the contributor docs that sent a reader into a broken checkout, pinned against + * the files they describe: + * + * - Audit A71: android/README.md said "JDK 17+", but the Gradle the wrapper pins cannot run on every + * JDK after 17 (8.14.3 stops at 24; its embedded Kotlin compiler rejects "25" while compiling the + * build scripts). Every Gradle has a newest Java it runs on, so an open-ended claim is false from + * the next JDK release on. A stated range must end where the wrapper's Gradle does, start no lower + * than the bytecode the build targets, and include the JDK CI actually uses. + * - Audit A61: the docs told desktop contributors to run `npm ci --ignore-scripts` before the protocol + * tests. `npm ci` deletes `node_modules`, and the flag skips the node-pty patch and build, so a + * working desktop checkout came out with node-pty unpatched (and on Linux unbuilt). Their existing + * `npm install` was already enough. The flag is CI's, for a machine without the native toolchain; + * any paragraph that mentions it has to say how a desktop checkout recovers. + * + * The audit and the handover are logs that quote the old wording, so they are not read. + */ +class ContributorDocsTest { + private val root = InteropHarness.repoRoot + + /** Contributor-facing docs that say how to build or test the Android side. */ + private val docs = listOf( + "CONTRIBUTING.md", + "docs/android.md", + "android/README.md", + "android/tools/typecheck/README.md", + ) + + /** + * For each Java release, the first Gradle release that can RUN on it (not build for it through a + * toolchain), from the compatibility matrix at docs.gradle.org/current/userguide/compatibility.html. + * A doc that claims a JDK newer than the last entry fails until this table learns it. + */ + private val firstGradleRunningOn = mapOf( + 17 to "7.3", 18 to "7.5", 19 to "7.6", 20 to "8.3", 21 to "8.5", + 22 to "8.8", 23 to "8.10", 24 to "8.14", 25 to "9.1", + ) + + @Test + fun `every stated JDK requirement is a range the wrapper's Gradle can run on`() { + val gradle = wrapperGradleVersion() + val newestJdk = firstGradleRunningOn.filterValues { compareVersions(it, gradle) <= 0 }.keys.maxOrNull() + ?: fail("the wrapper's Gradle $gradle is older than every entry of the table") + val target = jvmTarget() + val ci = ciJavaVersions() + var ranges = 0 + for (path in docs) { + val text = read(File(root, path)) + openEnded.findAll(text).forEach { + fail( + "$path says `${it.value}`, but the wrapper's Gradle $gradle runs on JDK $newestJdk at most. " + + "State a range (`JDK $target–$newestJdk`) instead (audit A71)." + ) + } + for (m in range.findAll(text)) { + ranges++ + val (low, high) = m.destructured.toList().map(String::toInt) + assertTrue( + high <= newestJdk, + "$path says `${m.value}`, but the wrapper's Gradle $gradle cannot run on JDK $high (newest: " + + "$newestJdk). Lower the range or move the wrapper (audit A71)." + ) + assertTrue(low >= target, "$path says `${m.value}`, below the JVM target $target the build compiles for") + assertTrue( + ci.all { it in low..high }, + "$path says `${m.value}`, which leaves out the JDK $ci CI builds with" + ) + } + } + assertTrue(range.containsMatchIn(read(File(root, "android/README.md"))), "android/README.md no longer states a JDK range") + assertTrue(ranges >= 2, "found only $ranges JDK ranges in $docs; has the reader stopped seeing them?") + } + + @Test + fun `a paragraph that mentions npm ci --ignore-scripts says how a desktop checkout recovers`() { + var mentions = 0 + for (path in docs) { + for (paragraph in read(File(root, path)).split(Regex("""\n\s*\n"""))) { + if (!paragraph.contains("--ignore-scripts")) continue + mentions++ + assertTrue( + paragraph.contains("npm run rebuild"), + "$path mentions `--ignore-scripts` without saying that `npm ci` deletes node_modules and leaves " + + "node-pty unpatched until `npm install` / `npm run rebuild` runs again (audit A61):\n$paragraph" + ) + } + } + // Both docs name the flag so that a contributor who sees it in CI's workflow knows not to copy it. + assertTrue(mentions >= 2, "found only $mentions paragraphs naming --ignore-scripts; has the wording moved?") + } + + @Test + fun `the version comparison orders Gradle releases numerically`() { + assertTrue(compareVersions("8.14.3", "8.14") > 0) + assertTrue(compareVersions("8.14", "8.8") > 0) // not a string comparison + assertTrue(compareVersions("8.14.3", "9.1") < 0) + assertEquals(0, compareVersions("9.1", "9.1.0")) + } + + private fun wrapperGradleVersion(): String { + val props = read(File(root, "android/gradle/wrapper/gradle-wrapper.properties")) + return Regex("""distributionUrl=.*gradle-([0-9][0-9.]*)-(?:bin|all)\.zip""").find(props)?.groupValues?.get(1) + ?: fail("no Gradle version in android/gradle/wrapper/gradle-wrapper.properties") + } + + /** The JVM target the protocol module compiles for; the app and the type-check use the same. */ + private fun jvmTarget(): Int { + val build = read(File(root, "android/protocol/build.gradle.kts")) + return Regex("""JvmTarget\.JVM_(\d+)""").find(build)?.groupValues?.get(1)?.toInt() + ?: fail("no JvmTarget in android/protocol/build.gradle.kts") + } + + /** Every `java-version:` the Android workflow's setup-java steps pin. */ + private fun ciJavaVersions(): Set { + val workflow = read(File(root, ".github/workflows/android.yml")) + val versions = Regex("""java-version:\s*['"]?(\d+)""").findAll(workflow).map { it.groupValues[1].toInt() }.toSet() + assertTrue(versions.isNotEmpty(), "no java-version in .github/workflows/android.yml") + return versions + } + + private companion object { + /** `JDK 17+`, `JDK 17 or newer`, `JDK 17 or later`. */ + val openEnded = Regex("""JDK\s*\d+\s*(?:\+|or\s+(?:newer|later|above))""") + + /** `JDK 17–24` or `JDK 17-24`. */ + val range = Regex("""JDK\s*(\d+)\s*[–-]\s*(\d+)""") + + fun read(file: File) = file.readText().replace("\r\n", "\n") + + fun compareVersions(a: String, b: String): Int { + val x = a.split('.').map(String::toInt) + val y = b.split('.').map(String::toInt) + for (i in 0 until maxOf(x.size, y.size)) { + val c = (x.getOrElse(i) { 0 }).compareTo(y.getOrElse(i) { 0 }) + if (c != 0) return c + } + return 0 + } + } +} diff --git a/android/tools/typecheck/README.md b/android/tools/typecheck/README.md index d7d9befc6a..0db707a01e 100644 --- a/android/tools/typecheck/README.md +++ b/android/tools/typecheck/README.md @@ -11,9 +11,12 @@ plain Kotlin/JVM against: ```bash cd android/tools/typecheck -gradle compileKotlin # needs Gradle 8.x and a JDK 17+ on PATH; no wrapper here on purpose +gradle compileKotlin # needs Gradle 8.x on PATH; no wrapper here on purpose ``` +The JDK has to be at least 17 (the build targets 17) and one your Gradle can run on: JDK 17–24 for +Gradle 8.14.x, the version the wrapper in `android/` pins. JDK 25 needs Gradle 9.1 or newer. + What it catches: Kotlin compile errors, wrong imports, API misuse visible in signatures. What it cannot catch: anything aapt2, the manifest merger, R8/D8 or a device would catch — From 33af5e79cbd0a315d83167dc19579969bd037ee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:35:45 +0000 Subject: [PATCH 083/284] docs(android): a numbered device checklist, and say the debug APK is debuggable (A65, A50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A65: android/README.md marked every feature ✓ with nothing next to the table saying no row had been checked on a phone, and docs/android.md had no device checklist; the only one was a 23-item list in the handover, which is a progress log. - docs/android.md gains "## Device checklist": 51 numbered items in eight areas (install and update, pairing, connecting, terminal, sessions/board/new sessions, inbox/notifications/usage, settings and system UI), each naming the audit finding it checks; A65 marks the baseline checks that finding's verifier asked for. It carries all 23 handover items (copied; the handover is not edited) and adds what each later fix left to a device, collected from the commit messages since 356e8c7: new session then Back within a second and to the background (A40), renderer kill and crash (A45), the ⌨ chip in three states (A46), Settings back and Reset to default (A44), pace line and context ring (A58), a large and an invalid OSC 52 copy (A53), a changed SSH host key in Auto and SSH-only (A49/A74), the lock-screen public version (A52), live notifications for the watched computer (A73), an Android 12+ device transfer (A51), Android 15 IME insets (A77), a release build once a key exists (A37/A50), plus A07, A11/A19, A12, A13, A18, A20, A21, A22, A24, A31, A33, A38, A42, A43, A47, A48, A54, A57, A72, A75, A76. - android/README.md: a line right above the feature table says the APK is built by CI but no row has been checked on a device, linking the checklist; the status note now points at the handover for the plan only. A50: the only installable build is the debug APK, and AGP marks it debuggable, so anyone with adb access to an unlocked phone with USB debugging on can read the app's files (run-as) and attach a debugger whose code can use the Keystore key: the SSH private key, the relay box secret and the relay device token. android/README.md now says so under Security (with what to do: keep USB and wireless debugging off, revoke and re-pair after exposure) and points at it from the debug-key paragraph; the root README's Android install line and docs/android.md's Known gaps say it too. The Security bullet that said only public halves "ever leave the phone" is narrowed. Regression test: DeviceChecklistDocsTest (android/protocol) checks that the checklist is numbered 1..N (at least the handover's 23), that every item names an id the audit has, that every finding a docs/android.md paragraph leaves to a device ("device check", "type-checked", "on a device"; 13 paragraphs today) has an item, that the README links the checklist before the table's first row, and, while the release build type has no signingConfig, that the README's Security section says the debug APK is debuggable (skipped once a release is signed). It reads the docs by literal repo paths, so WorkflowPathFilterTest now requires them in android.yml's path filters: docs/android.md and docs/android-audit-2026-09.md are added to both triggers (with the comment), and the audit is a declared input of the protocol test task. Verified: full protocol suite (gradle test --offline: 338 tests, 0 skipped, 0 failed) and the offline app type-check (gradle compileKotlin --offline) pass; android.yml parses. Mutation checks, each reverted: docs/android.md and android/README.md at HEAD fail all 4 new tests; re-tagging the A46 item as A65, a numbering gap, an unknown id (A99), removing the README's debuggable bullet and moving the table caveat below the table each fail 1; removing the two docs from the path filters fails WorkflowPathFilterTest; a signingConfig in the release block skips the A50 test. No desktop code changed. Nothing has run on a device. Departures from the audit: the verifier's list said QR scanning uses CameraX/ML Kit; the app uses ZXing (zxing-android-embedded), so the item says "the Pair screen's scanner". A50 is documented only: the signed, non-debuggable release it asks for (a CI-fed signingConfig, published artifacts, the README and ANDROID_APP_URL pointed at them) needs a key and CI secrets and stays open, recorded in docs/android.md's Known gaps. Beyond the task: the root README's install line, and the path filter entries the new test needs. The ways item 26 suggests to provoke a renderer kill or crash are marked untried. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 6 + README.md | 4 +- android/README.md | 27 ++- android/protocol/build.gradle.kts | 8 +- .../protocol/DeviceChecklistDocsTest.kt | 144 ++++++++++++ docs/android.md | 211 +++++++++++++++++- 6 files changed, 388 insertions(+), 12 deletions(-) create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 24b392fa0e..a109203155 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -24,6 +24,8 @@ name: Android # .github/workflows/android.yml this file # .github/workflows/security.yml, .github/dependabot.yml GradleCiCoverageTest (android/protocol) # reads them: the Kotlin CodeQL job and the Gradle Dependabot entry (audit A69) +# docs/android.md, docs/android-audit-2026-09.md DeviceChecklistDocsTest reads them: the device +# checklist and the audit ids its items name (audit A65) # To re-derive: `node android/protocol/src/test/interop/bundle-fixture.cjs # android/protocol/src/test/interop/electron-stub.ts` from the repo root writes .meta.json, # whose `inputs` are the bundled files. WorkflowPathFilterTest (android/protocol) fails when one is not @@ -43,6 +45,8 @@ on: - '.github/workflows/android.yml' - '.github/workflows/security.yml' - '.github/dependabot.yml' + - 'docs/android.md' + - 'docs/android-audit-2026-09.md' pull_request: paths: - 'android/**' @@ -56,6 +60,8 @@ on: - '.github/workflows/android.yml' - '.github/workflows/security.yml' - '.github/dependabot.yml' + - 'docs/android.md' + - 'docs/android-audit-2026-09.md' workflow_dispatch: concurrency: diff --git a/README.md b/README.md index a95831688c..f0f62d4e7a 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,9 @@ detects your platform. Everything is also listed at - **Android** — build it from [`android/`](./android) (`./gradlew :app:assembleDebug`); CI also attaches a debug APK to every run of the Android workflow. Debug builds share a public signing key so a newer APK installs over an older one; install them only from this repository's CI or - your own build. Not in a store yet, and not yet tested on a device. + your own build. They are also debuggable, so anyone with adb access to the unlocked phone can pull + its pairing credentials ([`android/README.md`](./android/README.md#security)); there is no signed + release build yet. Not in a store yet, and not yet tested on a device. **Trying it out?** Removal is one script — it stops every process nodeterm started, reverts the status-hook/skill entries it merged into your agent CLIs' config (your own hooks and diff --git a/android/README.md b/android/README.md index ea65558743..244258ef88 100644 --- a/android/README.md +++ b/android/README.md @@ -8,13 +8,16 @@ it is talking to. > **Status (2026-09-26): not ready for users.** CI builds the debug APK, and the release blockers > and medium bugs an audit found are fixed on this branch and tested where the code allows — but the -> app has **not yet been run on a phone**. The plan, the device checklist and what is still open are -> in [`docs/android-handover.md`](../docs/android-handover.md); the findings are in -> [`docs/android-audit-2026-09.md`](../docs/android-audit-2026-09.md). The table below describes what -> the code is written to do, not what has been verified on a device. +> app has **not yet been run on a phone**. The plan and what is still open are in +> [`docs/android-handover.md`](../docs/android-handover.md); the findings are in +> [`docs/android-audit-2026-09.md`](../docs/android-audit-2026-09.md). ## What it does +The APK is built by CI, but **no row below has been checked on a device yet**: ✓ means the code is +written for it and tested where the layer allows, and the numbered +[device checklist](../docs/android.md#device-checklist) is what will check it. + | | Android | Notes | |---|---|---| | Pair by QR (or pasted code, or a `nodeterm://pair` link) | ✓ | E2EE-sealed `/pair`, Ed25519 key made on the phone | @@ -71,7 +74,9 @@ the Gradle distribution itself is not pinned. debug APK over an older one without uninstalling — uninstalling wipes every pairing. The other side of "public": anyone can sign an APK that installs as an update over a debug build and inherits its data, including the keys your computers trust. **Install debug APKs only from this repository's CI -or your own build.** Release builds will need their own private key; none exists yet. +or your own build.** Release builds will need their own private key; none exists yet. Debug builds +are also debuggable, which hands the phone's pairing credentials to anyone with adb access to it +(see [Security](#security)). ## Layout @@ -133,7 +138,17 @@ iOS app does receive the detail, in the push the desktop sends. - The phone's relay identity (a Curve25519 box key) and SSH identity (an Ed25519 seed) are generated on the device and stored encrypted under an Android Keystore AES-GCM key. Only their - public halves ever leave the phone. + public halves are ever sent anywhere; the next point is how the private halves can still be taken. +- **The debug APK is debuggable, and it is the only build there is.** AGP marks every debug build + `android:debuggable`, so anyone with adb access to your unlocked phone while USB debugging is on + (from a computer the phone has authorized, or by accepting the prompt on it) can read the app's + files with `adb shell run-as dev.nodeterm.android` and attach a debugger to the running app. The + Keystore never hands out the key those files are sealed under, but it lets any code running as the + app use it, so that is enough to pull the phone's pairing credentials: the SSH private key your computers + accept, the relay box secret and the relay device token. A signed, non-debuggable release build does + not exist yet. Until it does, keep USB and wireless debugging off when you are not using them; if + someone else may have had adb access, revoke the phone on each computer (nodeterm → Settings → + Phone → Revoke) and pair it again. - Nothing of the app's goes into a backup or a phone-to-phone transfer. `allowBackup="false"` stops cloud backup, and the manifest's data extraction rules stop the Android 12+ device-to-device transfer, which ignores `allowBackup`. A new phone starts unpaired; pair it again. diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index b132a6371f..c456aa4345 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -49,7 +49,8 @@ tasks.test { // SettingsLeaveTest and R8RulesTest), the release build's R8 rules, its build type and the CI // workflow that runs R8 (R8RulesTest), the CI config GradleCiCoverageTest checks: the workflows // that run Gradle, the Dependabot config and the root build's settings, and the contributor docs - // and the wrapper's Gradle version ContributorDocsTest checks against each other. + // and the wrapper's Gradle version ContributorDocsTest checks against each other, and the audit + // whose finding ids DeviceChecklistDocsTest checks the device checklist against. // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) // The desktop code the interop fixture bundles (the same dirs as android.yml's path filter). Without @@ -68,7 +69,10 @@ tasks.test { inputs.file(rootDir.resolve("../../.github/dependabot.yml")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../settings.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../gradle/wrapper/gradle-wrapper.properties")).withPathSensitivity(PathSensitivity.RELATIVE) - for (doc in listOf("CONTRIBUTING.md", "docs/android.md", "android/README.md", "android/tools/typecheck/README.md")) { + for (doc in listOf( + "CONTRIBUTING.md", "docs/android.md", "android/README.md", "android/tools/typecheck/README.md", + "docs/android-audit-2026-09.md", + )) { inputs.file(rootDir.resolve("../../$doc")).withPathSensitivity(PathSensitivity.RELATIVE) } // R8RulesTest reads the jars this module ships to the app (its runtime classpath, not the test one) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt new file mode 100644 index 0000000000..e75c162947 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt @@ -0,0 +1,144 @@ +package dev.nodeterm.protocol + +import org.junit.jupiter.api.Assumptions +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * What the docs admit about the app that has never run on a phone, pinned against the files that make + * it true: + * + * - Audit A65: android/README.md marked every feature ✓ with no word that nothing had been checked on + * a device, and docs/android.md had no device checklist, although CLAUDE.md asks for a numbered one + * for whatever could not be run. The checklist must exist, be numbered, name the finding each item + * checks, and cover every finding the design notes leave to a device ("a device check", "only + * type-checked", "on a device"); the README's feature table must point at it. + * - Audit A50: the only APK to install is the debug build, which AGP marks debuggable, so adb access + * to the phone yields its pairing credentials. While the release build type has no signing config + * (so there is no release to install instead), the README must say so. + * + * The audit and the handover are logs, so only the audit's section headings are read (for the ids). + */ +class DeviceChecklistDocsTest { + // Read by literal paths, so WorkflowPathFilterTest makes the Android workflow run when one changes. + private val notes = read(File(InteropHarness.repoRoot, "docs/android.md")) + private val readme = read(File(InteropHarness.repoRoot, "android/README.md")) + + @Test + fun `the device checklist is numbered from 1 and every item names a finding the audit has`() { + val items = checklistItems() + assertTrue( + items.size >= 23, + "the checklist has ${items.size} items; it started from the handover's 23, so has it been gutted?" + ) + assertEquals((1..items.size).toList(), items.map { it.first }, "checklist items must be numbered 1, 2, 3, … in order") + val known = auditIds() + for ((n, text) in items) { + val ids = findingIds(text) + assertTrue(ids.isNotEmpty(), "checklist item $n names no audit finding (A65 marks a baseline check):\n$text") + val unknown = ids - known + assertTrue(unknown.isEmpty(), "checklist item $n names $unknown, which the audit does not have:\n$text") + } + } + + @Test + fun `every finding the design notes leave to a device has a checklist item`() { + val listed = checklistItems().flatMap { findingIds(it.second) }.toSet() + val outside = notes.replace(checklistSection(), "") + var deferring = 0 + for (paragraph in outside.split(Regex("""\n\s*\n"""))) { + if (!leftToDevice.containsMatchIn(paragraph)) continue + deferring++ + val missing = findingIds(paragraph) - listed + assertTrue( + missing.isEmpty(), + "docs/android.md leaves $missing to a device, but the device checklist has no item for it:\n$paragraph" + ) + } + // The notes defer the ⌨ chip, Settings back, the lock screen, live notifications, device + // transfer, the keyboard insets and more; if none is found, the reader has stopped seeing them. + assertTrue(deferring >= 6, "found only $deferring paragraphs that leave something to a device") + } + + @Test + fun `the README's feature table points at the device checklist`() { + assertTrue( + notes.lines().any { it == "## Device checklist" }, + "docs/android.md has no `## Device checklist` heading, which the README's #device-checklist link needs" + ) + val section = section(readme, "## What it does") + val table = section.indexOf("\n|") + assertTrue(table >= 0, "android/README.md's \"What it does\" has no table") + assertTrue( + section.substring(0, table).contains("../docs/android.md#device-checklist"), + "android/README.md's feature table must say, before its first row, that no row has been checked on " + + "a device, and link the device checklist (audit A65):\n$section" + ) + } + + @Test + fun `while no release build is signed, the README says the debug APK is debuggable`() { + val gradle = read(File(InteropHarness.repoRoot, "android/app/build.gradle.kts")).lines().joinToString("\n") { it.substringBefore("//") } + val release = AppSourcePins.blockAfter(gradle, "release {") + Assumptions.assumeFalse( + Regex("""\bsigningConfig\b""").containsMatchIn(release), + "the release build type is signed now: point the README at the release instead of this warning" + ) + val security = section(readme, "## Security") + assertTrue( + Regex("""debuggable""").containsMatchIn(security) && "run-as" in security, + "android/README.md's Security section must warn that the debug APK is debuggable, so adb access " + + "(`run-as`, a debugger) yields the phone's pairing credentials (audit A50):\n$security" + ) + } + + /** (number, text) for each item of docs/android.md's device checklist, continuation lines included. */ + private fun checklistItems(): List> { + val items = mutableListOf>() + var open = false + for (line in checklistSection().lines()) { + val start = itemStart.find(line) + when { + start != null -> { + items += start.groupValues[1].toInt() to StringBuilder(line) + open = true + } + open && line.isNotBlank() && line.first().isWhitespace() -> items.last().second.append('\n').append(line) + else -> open = false + } + } + if (items.isEmpty()) fail("docs/android.md's device checklist has no numbered items") + return items.map { it.first to it.second.toString() } + } + + private fun checklistSection(): String = section(notes, "## Device checklist") + + /** From the line [heading] to the next `## ` heading (subsections included). */ + private fun section(text: String, heading: String): String { + val start = text.lines().indexOf(heading) + if (start < 0) fail("no `$heading` heading") + val lines = text.lines() + val end = (start + 1 until lines.size).firstOrNull { lines[it].startsWith("## ") } ?: lines.size + return lines.subList(start, end).joinToString("\n") + } + + private fun auditIds(): Set = + read(File(InteropHarness.repoRoot, "docs/android-audit-2026-09.md")).lines() + .mapNotNull { Regex("""^## (A\d{2})$""").find(it)?.groupValues?.get(1) } + .toSet() + .also { assertTrue(it.size >= 70, "found only ${it.size} findings in the audit; has its heading format changed?") } + + private fun findingIds(text: String): Set = Regex("""\bA\d{2}\b""").findAll(text).map { it.value }.toSet() + + private fun read(file: File) = file.readText().replace("\r\n", "\n") + + private companion object { + val itemStart = Regex("""^(\d+)\.\s""") + + /** How docs/android.md says that only a phone can settle something. */ + val leftToDevice = Regex("""device check|type-checked|on a device""", RegexOption.IGNORE_CASE) + } +} diff --git a/docs/android.md b/docs/android.md index 130d48b73a..3abc3941cc 100644 --- a/docs/android.md +++ b/docs/android.md @@ -140,9 +140,11 @@ nothing, so a new reflection or name-dependent target (a new `Class.forName`, a from a string) builds green without its keep. It needs its own keep and a line in `tools/check-r8-output.sh`. `R8RulesTest` re-derives the classes Android lacks from the jars the protocol module ships to the app, and requires a keep for every WorkManager worker in the app sources. -The debug APK stays unminified and is the one distributed. The app has no instrumented tests and has **not been run on a device**, minified or not. An audit of the code found release blockers; the fixed ones are marked in its -index, and the rest are open: [`android-audit-2026-09.md`](android-audit-2026-09.md). The plan, the device checklist and the -decisions still open are in [`android-handover.md`](android-handover.md). +The debug APK stays unminified and is the one distributed. The app has no instrumented tests and has +**not been run on a device**, minified or not; the [device checklist](#device-checklist) below is what +a first device pass has to run. An audit of the code found release blockers; the fixed ones are +marked in its index, and the rest are open: [`android-audit-2026-09.md`](android-audit-2026-09.md). +The plan and the decisions still open are in [`android-handover.md`](android-handover.md). A test caveat (audit `A64`): the relay leg's `projects.list` blob and mirror now come from the desktop's code, but the SSH leg still hand-copies desktop shapes — the v3 index and project files, @@ -350,6 +352,201 @@ windows (read from Android 15's `PhoneWindow` classes, not measured). The test p nothing else asks for the keyboard's inset, and that every Scaffold body with a text field uses it; how the screens and the dialogs look with the keyboard up is a device check. +## Device checklist + +Nothing in this section has been run. CI builds the APK, but no row of the README's feature table and +no fix above has been checked on a phone: the tests stop at the JVM, and the app's screens are only +type-checked. Run these on a real phone against a real desktop and record, for each item, pass or +fail, the phone model, its Android and WebView versions, the desktop's OS and nodeterm version, and +the route (network or relay). Write the results into "What is verified, and how" above, and turn each +failure into a new finding. Every item names the audit finding it checks; `A65` marks the baseline +checks that finding asked for, where the feature table is the only claim. An item that needs +something a tester may not have (a Windows desktop, Android 15, a second phone, a release key) says +so; skip it and record why. The list starts from the 23 items the handover drew up and adds what each +later fix left to a device. + +### Install, update and what stays on the phone + +1. Install the CI debug APK (artifact `nodeterm-android-debug`) and pair a computer. Install the next + CI APK over it without uninstalling: it installs as an update and the pairing is still there. + *(A10)* +2. Pairings survive a restart: force-stop the app, reboot the phone, reopen the app. The computer is + still listed and connects on both routes without pairing again, the desktop's Settings → Phone + still shows one entry for this phone, and no new relay approval is asked for. *(A24, A65)* +3. Uninstall, then install again: the app starts with no computers (its Keystore key and data went + with it). Pairing the same computer again works, relay included, also on a desktop without Pro. + Revoke the stale entry on the desktop. *(A51, A65)* +4. Android 12 or later, with a second phone: a device-to-device transfer ("copy apps and data" in the + new phone's setup) leaves the app there with no computers and no pins. Pair it too, then revoke + one of the two phones on an entitled (Pro) desktop: the other keeps working. A cloud backup + restored onto a fresh install brings back nothing of the app either. *(A51)* +5. Once a release signing key exists: install the signed, minified release APK and run the pairing, + SSH, relay, OSC 52 copy and background-notification items on it, since that is where code R8 could + have broken runs (BouncyCastle's provider tables on the first connect, the WebView bridge, the + WorkManager worker). An error message names a real exception class, not an obfuscated one. + `adb shell run-as dev.nodeterm.android` is refused on it, while on the debug APK it opens a shell + in the app's data, which is what the README's debuggable warning says. *(A37, A50)* + +### Pairing + +6. Pair by QR from the Pair screen's scanner with a macOS desktop, then a Linux desktop, then a + Windows desktop. The Windows QR carries `"ssh":false`, so that pairing is relay-only, and a failed + relay mint pairs nothing. *(A65)* +7. Pair by pasting the code's text, and by scanning the desktop's QR with the phone's own camera app, + which hands the `nodeterm://pair?code=…` link to the app: once with the app closed, once with it + open on another screen. Deny the camera permission: pasting still pairs. *(A65)* +8. With remote access on, against a current desktop: the Pair screen says "Remote access is on", the + pairing ends with "Paired, and approved for remote access.", and the first relay connect later + raises no SAS dialog on the desktop. Revoke the phone there (Settings → Phone → Revoke): SSH is + refused, and a relay connect needs the SAS approval again. Against an older desktop the toast says + an approval is still owed, and the first relay connect shows the code. *(A07)* +9. A pairing code whose computer does not answer (the desktop quit after showing the QR, or the phone + is on another network) ends within about 45 s with a sentence, not an exception name, and Back + during the wait works without a hang. An expired or already-used code shows the desktop's one-line + refusal. *(A54)* + +### Connecting + +10. On the LAN (route Automatic): the Sessions tab shows the desktop's projects and sessions, grouped + Needs you / Running / Sleeping, with activity and context %. *(A02, A65)* +11. The first connect over the network pins the computer's SSH host key, and later connects use it + silently. Then present a different key at that address (another SSH-running machine takes the + desktop's LAN address, or the desktop's host keys are regenerated): on Automatic the phone refuses + SSH, connects through the relay and keeps a warning on the host screen that names "Only through + the relay"; on "Only on my network (SSH)" it stops with the warning. *(A49, A74)* +12. On cellular, off the LAN: connect through the relay. The desktop shows the SAS dialog and the phone + shows the same code; approve. Reconnect later: no second prompt. On another pairing press Deny: the + phone says it was not approved and does not dial again until Try again. *(A65, A30)* +13. Leave the phone in the background for 15 minutes or more with a paired computer that has never + approved it over the relay: no SAS dialog appears on the desktop. *(A05, A17, A23)* +14. Put the desktop to sleep (or pull its network) while the phone is connected over SSH: within about + 45 s the phone notices, and on Automatic it moves to the relay or says the computer is offline. + Waking the desktop reconnects. *(A31)* +15. Open a computer and go Back before it has connected, several times in a row: no connection error + is recorded for it, and the next open connects normally. *(A20)* +16. Change a computer's route in Settings → How to reach each computer and check that the next connect + follows it; forget a computer; pair two computers and move between them. *(A65)* +17. Pair while the desktop's remote access is off, then turn it on and open the computer over the + network: the host list gains "From anywhere" without the app being restarted, and a relay connect + then works (after one approval, since this path does not pin). The host list stays smooth while a + connection is being made. *(A47, A65)* + +### Terminal + +18. Open a terminal over SSH and type with the soft keyboard. Rotate the phone. Use A−/A+ and every + key chip (Esc, Tab, ⇧Tab, the arrows, ⏎, ⇧⏎, ^C, ^D, ^R, ^L, Home, End, PgUp, PgDn): the + connection survives all of it. *(A01, A04)* +19. Non-ASCII renders over SSH: Claude's rounded borders, accented letters, CJK, emoji. *(A03)* +20. Swipe to scroll the tmux history. Select text in tmux: the copy reaches Android's clipboard (OSC 52) + with a "Copied N lines" toast. *(A65)* +21. A large OSC 52 copy. In the pane, run + `printf '\033]52;c;%s\a' "$(head -c 150000 /dev/zero | tr '\0' x | base64 | tr -d '\n')"` + (the desktop's tmux passes an application's OSC 52 on): the phone says it is too large to copy and + nothing crashes. With 450000 in place of 150000 the page refuses it before it crosses the bridge, + with the same message. With 90000 it is copied, or, if the system refuses a clip that size, "Could + not copy: too large for the clipboard." shows; never a crash. *(A53)* +22. Invalid OSC 52 is ignored silently: a payload that is not base64, one with no `;`, a `?` read + query, and a selection field longer than 16 characters copy nothing, show nothing and leave the + clipboard as it was. *(A53)* +23. "Sized to another screen · Fit this screen" appears when the desktop's view of the session is + larger, and Fit works. *(A65)* +24. The ⌨ chip raises the soft keyboard, and it stays up, in three states: right after the terminal + opens, before the page was ever touched; after tapping the terminal and then dismissing the + keyboard (the page's input already has focus); and while the input bar has focus. The keys then + go to the pane, not to the input bar. *(A46)* +25. With a terminal open, turn on airplane mode: the input bar's draft stays (Send, the sending key + chips and Resume are disabled, and the keyboard's Send leaves the text in place). Turn it off: the + terminal reattaches by itself ("Disconnected. Reconnecting…" clears) and the draft then sends. Arm Ctrl and send "c" from the input bar: the + pane gets ^C, with no Enter after it. *(A41, A34, A36)* +26. The terminal WebView's renderer goes away. A kill while the terminal is on screen: the app stays + open and the terminal is rebuilt and reattached by itself; a third kill within a minute offers + "Reopen terminal" instead. A crash offers "Reopen terminal", never an automatic reattach. A kill + while the app is in the background: the terminal is back when the app returns. A kill while the + screen shows "The session ended", "Open through the relay" or "Reopen terminal": that answer stays + and nothing attaches. One way to provoke them, not tried: on an emulator with `adb root`, `kill -9` + the WebView's renderer process for a kill; a crash needs the renderer itself to crash (for + example `chrome://crash` from DevTools). *(A45)* +27. Send the app to the background with a terminal attached through the relay, for a few minutes: the + desktop's view of that session is no longer held to the phone's size, and the 8 s refresh stops. + Coming back reattaches. *(A18)* +28. On the desktop, open the project of a session the phone is attached to, so the desktop mounts that + node: through the relay the phone stays attached; over direct SSH it may be detached, and then + reattaches by itself rather than saying the session ended. *(A13)* +29. A Sleeping (Eco) session opened over direct SSH offers "Wake " while a shell owns its pane. + The tap wakes the conversation, and opening it again (the CLI now running) offers nothing. Through + the relay, opening it wakes it with no offer. *(A76)* +30. Reboot the desktop, then open a session through the relay: the resume offer appears, and Resume + continues the right conversation, in the node's folder, under its Claude account and with the + project's permission mode. Drop the connection before tapping it: the offer comes back with the + reattach and can be tapped once the reattach has settled. Over direct SSH such a session is not + created: the phone offers "Open through the relay". *(A15, A16, A41, A08)* +31. A session of one of the desktop's SSH projects: over direct SSH the phone offers the relay + instead, and through the relay it opens on that project's host. *(A09, A28)* + +### Sessions, the board and new sessions + +32. New session from the phone (Claude, then a shell), through the relay: the node appears on the + canvas, the agent runs in the project's folder, and its status badges update on both sides; a + Claude permission prompt reaches the phone's Inbox as an approval. Managed Claude accounts are + named by their label or email in the picker, the session row and the Usage card, never by an id. + Repeat against a Windows desktop: the session starts in the project's folder under the chosen + account there too. *(A72, A33, A14, A39, A75)* +33. New session, then Back within a second of Start (before the launch line is typed), and once more + by sending the app to the background right after Start: both times the node still appears on the + canvas with its agent running, not a bare shell. *(A40)* +34. With the New-session dialog open, close the selected project on the desktop (or remove the + selected account): within a refresh the dialog moves to a project it still offers, or disables + Start with a line saying why; nothing crashes. *(A42)* +35. Wake, refresh, rename and end a session from the phone. *(A65)* +36. Board: move a card, add and remove a label, create a new one; the desktop's board updates without a + reload. *(A65)* +37. On the Board tab, pick a project and scroll; open a terminal and come back: the same tab, scroll + position and project. Switching tabs and back keeps them too. *(A43)* +38. Kill the app's process in the background (Developer options → "Don't keep activities", or + `adb shell am kill dev.nodeterm.android`) and reopen it: the screen and the back stack are + sensible, and the tab is kept. *(A22, A43)* + +### Inbox, notifications and usage + +39. Approve and deny a held Claude permission from the Inbox within 45 s. Answer one after its hold + has expired: the phone must not report success. *(A06, A35)* +40. A subagent's approval while its parent waits on a question: Approve from the Inbox answers it, + rather than saying "Already handled." *(A38)* +41. Answer a single-select AskUserQuestion from the Inbox, through the relay and over the network: the + answer lands in the live session in one tap. A multi-select question lists its options read-only + beside "Open session". *(A12, A57, A65)* +42. Open a finished session on the phone: the desktop's unread dot clears. *(A65)* +43. A background notification arrives within about 15 minutes; tapping it opens that computer's Inbox, + also when the app is already open on another computer. *(A11, A19)* +44. Notification permission on Android 13 or later: deny it at first launch; Settings → Notifications + then reads Off; switching it on asks again or opens the app's notification settings; nothing is + posted while it is denied. *(A21)* +45. Live notifications for the computer on screen: with its Sessions tab open, a turn finishing on the + desktop raises a notification within about 8 s; with its Inbox tab open, none, and none later; + with a session's terminal attached, none for that session, except a held hook-reply approval. + Another paired computer's events arrive only from the background check. *(A73)* +46. Each event notifies once: an event announced once is not announced again by later refreshes, by + the background check, or after the next APK is installed over this one. *(A48)* +47. The lock screen: with "Show details in notifications" off (the default), a notification shows its + title ("Needs you — " or "Completed — "), the kind and the computer, but no + command, question or last message; turned on, the shade shows those too. With the lock screen set + to hide sensitive content, only the title and the computer's name show there, either way. *(A52)* +48. The Usage tab shows a pace line ("5h usage pace faster", "slower" or "on pace") when a limit's reset + time is known, and none when it is not. Approval, question and done cards show the node's context + ring and "N% context", matching the desktop's meter. *(A58)* + +### Settings and system UI + +49. Settings: edit the phone's name and the relay API address, then leave with the system back gesture: + both are stored (reopen Settings to see). An address that is not a full `https://` URL shows the + field's error and a toast on leaving, and is not stored. "Reset to default" forgets a stored + address. Leaving without an edit stores nothing. *(A44)* +50. Android 15 (edge-to-edge) with the soft keyboard up: the terminal sits right above the keyboard + with no extra gap the height of the navigation bar, and the Pair and Settings text fields scroll + into view above it; the label and rename dialogs look right. On Android 8 to 14 the same screens + are unchanged. *(A77)* +51. Light and dark system theme; a tablet or a foldable if one is available. *(A65)* + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's @@ -406,4 +603,12 @@ how the screens and the dialogs look with the keyboard up is a device check. `PairedHost.from`; it would serve iOS too). Nothing refreshes `PairedHost.host` either: the desktop could publish its current LAN address over the relay, letting the phone update it after a relay connect. +- **No signed release build** (audit `A50`). The only APK there is to install is the debug build, + and AGP marks every debug build debuggable: anyone with adb access to the unlocked phone while USB + debugging is on can read the app's files (`run-as`) and attach a debugger to the running app, whose + code can use the Keystore key those files are sealed under. That is the phone's pairing + credentials: the SSH key its computers accept, the relay box secret and the relay device token. + android/README.md says so under Security. A signed, non-debuggable release needs a release + `signingConfig` fed from CI secrets, published artifacts, and the README and `ANDROID_APP_URL` + pointed at them. - **Instrumented UI tests** and a store listing do not exist yet. From 61e218bfd6695ded94ab6ccdd9649f50710046b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:39:16 +0000 Subject: [PATCH 084/284] fix(renderer): label the Android link as source and derive it from REPO_URL (A66) The desktop's Android link sits beside "Get the nodeterm iOS app" in the quick-pair popover and after "Don't have the app yet?" in Settings -> Phone, but it opens the repo's android/ Gradle source tree: there is no store listing and no published APK (the only APK is an expiring CI run artifact behind a GitHub login). Labelled "nodeterm for Android", it read as a download. - Both call sites now show "nodeterm for Android (build from source)", from one shared ANDROID_APP_LABEL in lib/links.ts so the two surfaces cannot disagree about what the link opens. - ANDROID_APP_URL is derived from bugReport's REPO_URL (`${REPO_URL}/tree/main/android`) instead of repeating the repository. Same URL as before; on a fork it still points upstream because REPO_URL is hardcoded there too. - docs/android.md's "No signed release build" gap now says the label goes when the link points at a release asset. Departures from the audit: none from the verifier's version. The dead-link half of the finding is a timing artifact (the folder exists once this branch lands on eneskirca/nodeterm main) and is not something code can fix. The label became a shared constant rather than two literals. Verified: new src/renderer/components/androidAppLink.test.tsx renders both surfaces, finds the one button naming Android, checks it opens `${REPO_URL}/tree/main/android` and says "build from source", and scans links.ts for a hardcoded github.com. Mutation check: with the three source files reverted to HEAD all three tests fail. Passing: that file, both PhoneSection suites, bugReport.test.ts, every *.guard.test.* (machine-name and line-endings included), npm run typecheck, android/protocol gradle test --offline (338 tests), and the offline app type-check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android.md | 4 +- src/renderer/components/PhonePairPopover.tsx | 4 +- .../components/androidAppLink.test.tsx | 97 +++++++++++++++++++ .../settings/sections/PhoneSection.tsx | 4 +- src/renderer/lib/links.ts | 18 +++- 5 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 src/renderer/components/androidAppLink.test.tsx diff --git a/docs/android.md b/docs/android.md index 3abc3941cc..f0869cdb7e 100644 --- a/docs/android.md +++ b/docs/android.md @@ -610,5 +610,7 @@ later fix left to a device. credentials: the SSH key its computers accept, the relay box secret and the relay device token. android/README.md says so under Security. A signed, non-debuggable release needs a release `signingConfig` fed from CI secrets, published artifacts, and the README and `ANDROID_APP_URL` - pointed at them. + pointed at them. Until then the desktop's Android link opens the `android/` source folder and both + phone surfaces label it "nodeterm for Android (build from source)" (`ANDROID_APP_LABEL` in + `src/renderer/lib/links.ts`, audit `A66`); drop that label when the link points at a release. - **Instrumented UI tests** and a store listing do not exist yet. diff --git a/src/renderer/components/PhonePairPopover.tsx b/src/renderer/components/PhonePairPopover.tsx index bc5ba682c7..67fd44f456 100644 --- a/src/renderer/components/PhonePairPopover.tsx +++ b/src/renderer/components/PhonePairPopover.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { Switch } from '@renderer/ui/Switch' import { useSettings } from '@renderer/state/settings' import { usePhonePairing } from './settings/usePhonePairing' -import { ANDROID_APP_URL, IOS_APP_STORE_URL } from '@renderer/lib/links' +import { ANDROID_APP_LABEL, ANDROID_APP_URL, IOS_APP_STORE_URL } from '@renderer/lib/links' import { hostOsFromNavigator, sshServerCopy } from '@shared/ssh-server' import { pairingEndedMessage, @@ -198,7 +198,7 @@ export function PhonePairPopover({ className="phone-pair__link phone-pair__footer" onClick={() => window.nodeTerminal.shell.openExternal(ANDROID_APP_URL)} > - nodeterm for Android ↗ + {ANDROID_APP_LABEL} ↗

diff --git a/src/renderer/lib/links.ts b/src/renderer/lib/links.ts index 63dcb4f4fc..c2e1e2b277 100644 --- a/src/renderer/lib/links.ts +++ b/src/renderer/lib/links.ts @@ -1,7 +1,19 @@ +import { REPO_URL } from './bugReport' + /** nodeterm mobile on the App Store (live since 1.0). One home for the link so the welcome * flow, Settings → Phone and the quick-pair popover can never drift apart. */ export const IOS_APP_STORE_URL = 'https://apps.apple.com/app/nodeterm/id6790581233' -/** nodeterm for Android. Not in a store yet: the app lives in the repo's `android/` folder (build - * instructions in its README), so the link goes there rather than to a listing that does not exist. */ -export const ANDROID_APP_URL = 'https://github.com/eneskirca/nodeterm/tree/main/android' +/** nodeterm for Android: SOURCE, not an installable. There is no store listing and no published + * APK yet (the only APK is an expiring CI run artifact behind a GitHub login), so the link opens + * the repo's `android/` folder, whose README says how to build it. Both call sites label it + * "(build from source)" for that reason — it sits beside an App Store link, and without the label + * it reads as a download. Once a signed release APK is published, point this at that release + * asset and drop the label. + * + * Derived from `REPO_URL` rather than spelled out, so the repository has one home. */ +export const ANDROID_APP_URL = `${REPO_URL}/tree/main/android` + +/** The visible label of that link, shared by Settings → Phone and the quick-pair popover so the + * two can never disagree about what it opens. */ +export const ANDROID_APP_LABEL = 'nodeterm for Android (build from source)' From bf24cfc525b26c67e17457576da9136959a91bdb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:47:22 +0000 Subject: [PATCH 085/284] fix(android): address review of A62 The two ShortTmuxRoot tests in SshTestHostTest had no gate, so on a non-POSIX machine (Windows) they failed instead of skipping, the same fail-not-skip shape A62 fixed for SshTransportTest on a Mac. ShortTmuxRoot.create reads the owning uid through the "unix" attribute view, which Windows' default filesystem does not have (the read throws UnsupportedOperationException, which the second test's assertFailsWith does not accept), and the first test also creates its stand-in under /tmp and makes a symlink. Both tests now start with assumePosixTemp(): the "unix" attribute view must be supported and /tmp must be a directory. That is the exact precondition of ShortTmuxRoot, rather than the reviewer's suggested /bin/sh proxy from SshScriptsTest; on POSIX the two agree. SshTransportTest is unchanged: its tmux and script(1) gates skip it before it reaches ShortTmuxRoot.create. The PtyScript tests already pass or skip without POSIX (detect answers null when the probe cannot run). Verified: full protocol suite green (338 tests, 0 skipped, so the gate is true on Linux and both tests still run). Gate check: with the view name temporarily changed to one that does not exist, both tests report as skipped and the rest of the class passes; restored before committing. Not run on Windows, which is not available here. Offline app type-check green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .../dev/nodeterm/protocol/SshTestHostTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt index d7ea3850e7..a6c38d56d4 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt @@ -2,6 +2,7 @@ package dev.nodeterm.protocol import org.junit.jupiter.api.Assumptions.assumeTrue import java.io.File +import java.nio.file.FileSystems import java.nio.file.Files import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -21,12 +22,25 @@ import kotlin.test.assertTrue class SshTestHostTest { private val socket = "node-terminal" + /** + * [ShortTmuxRoot] is POSIX-only by construction: it reads the owning uid through the `unix` + * attribute view (absent on Windows, where the read throws UnsupportedOperationException) and its + * first base is `/tmp`. So its tests skip where that is missing, the way [SshScriptsTest] gates on + * `/bin/sh`, instead of failing, which is the shape A62 was about. [SshTransportTest] never gets + * that far there: its tmux and `script(1)` gates skip it first. + */ + private fun assumePosixTemp() = assumeTrue( + "unix" in FileSystems.getDefault().supportedFileAttributeViews() && File("/tmp").isDirectory, + "ShortTmuxRoot needs a POSIX filesystem (the unix attribute view) and /tmp" + ) + /** A real directory whose socket path cannot fit, whatever temp root it sits under. */ private fun longDir(parent: File): File = File(parent, "x".repeat(maxOf(1, 100 - parent.path.length))).apply { mkdirs() }.toPath().toRealPath().toFile() @Test fun `a base that resolves to a long path is passed over, however short its own spelling`() { + assumePosixTemp() // macOS's shape: the JVM's temp dir is spelled /var/folders/…, but tmux binds under // /private/var/folders/…, 8 characters longer. Measuring the spelling said it fit when it did not. val scratch = Files.createTempDirectory("nt-a62").toFile() @@ -57,6 +71,7 @@ class SshTestHostTest { @Test fun `no base with room is a failure naming the lengths, never a path that fails at bind time`() { + assumePosixTemp() val scratch = Files.createTempDirectory("nt-a62").toFile() try { val long = longDir(scratch) From 4806b9295a506e3ab6b912e13707ade35fda2652 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:57:50 +0000 Subject: [PATCH 086/284] fix(android): address review of A69 The Gradle Dependabot entry had one group, patterns ["*"] for minor and patch. So the protocol's network and crypto stack could only be bumped in the same PR as every androidx, Compose, AGP and Kotlin bump. androidx releases raise the compileSdk their AAR metadata demands, and :app:checkDebugAarMetadata enforces that against the app's compileSdk 35. Such a grouped PR stays red until someone fixes it by hand, and okhttp (the relay socket), sshj (the direct-SSH transport), eddsa (the phone's Ed25519 identity) and bcprov (the provider sshj needs on Android) would not land in the meantime. Security updates do not reliably fill that gap: the A69 commit left Gradle dependency submission as a follow-up. - dependabot.yml: a `protocol-net-crypto` group (com.squareup.okhttp3:*, com.hierynomus:*, net.i2p.crypto:*, org.bouncycastle:*; minor and patch), declared before the catch-all. Read from dependabot-core's source (DependencyGroupEngine, PatternSpecificityCalculator, GroupUpdateCreation): a dependency several groups match goes only to the strictly most specific one (these patterns score about 100, "*" scores 1); on a tie it goes to all of them, and a run then skips what an earlier group, or a group whose PR is still open, already handled. Listing the group first keeps both rules agreeing. The file validates against SchemaStore's dependabot-2.0 schema. - GradleCiCoverageTest: a new test reads the dependencies the watched builds declare (the app, the root plugins and the protocol, found through the Dependabot directories and includeBuild; typecheck is excluded as before). It models Dependabot's group matching (WildcardMatcher, exclude-patterns, update-types and the specificity score, each ported from dependabot-core) and asserts two things. For each of okhttp, sshj, eddsa and bcprov and each update type, the first group that takes it is strictly the most specific. And that group takes no other declared dependency. The reader refuses group keys it does not model (dependency-type, applies-to, group-by), as the existing readers do. The reader test covers the parsing and the matching. - protocol build: android/build.gradle.kts and the protocol's own build script are test inputs now, since the test reads them. - android/README.md says the stack comes in a PR of its own. Departure from the review's suggestion: none in the config. The test enforces the ordering the review proposed ("declared before the catch-all") in addition to specificity, so a Dependabot that went by file order alone would agree. Verified: the full protocol suite (339 tests, 0 skipped, 0 failed, run with --rerun-tasks) and the offline app type-check. Mutation checks, each failing the new test: dependabot.yml at HEAD (okhttp shares android-minor-patch with androidx and 20 more); the bouncycastle pattern removed (bcprov falls into the catch-all); the catch-all listed first; the okhttp pattern widened to "*". Not verified: no Dependabot run here, so the grouping shows only on the first weekly run on GitHub. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/dependabot.yml | 21 +- android/README.md | 10 +- android/protocol/build.gradle.kts | 9 +- .../nodeterm/protocol/GradleCiCoverageTest.kt | 284 +++++++++++++++++- 4 files changed, 309 insertions(+), 15 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b82c6c7722..fc8900ddf8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -40,8 +40,9 @@ updates: day: monday open-pull-requests-limit: 5 - # The Android companion's Gradle build (audit A69), weekly and grouped like npm: one PR carries the - # routine minor/patch bumps, a major (AGP, Kotlin, Gradle itself) comes on its own. /android covers + # The Android companion's Gradle build (audit A69), weekly and grouped like npm: two PRs carry the + # routine minor/patch bumps (the protocol's network and crypto stack, then everything else; see + # `groups`), a major (AGP, Kotlin, okhttp, Gradle itself) comes on its own. /android covers # the whole build: the app (include(":app")), the protocol module, which is an included build # (includeBuild("protocol") in android/settings.gradle.kts; Dependabot follows includeBuild, so it # needs no entry of its own, and a second entry for /android/protocol would open each of its bumps @@ -57,6 +58,22 @@ updates: day: monday open-pull-requests-limit: 5 groups: + # The protocol's network and crypto stack (the relay socket, the direct-SSH transport, the phone's + # Ed25519 identity and the BouncyCastle provider sshj needs on Android) gets a PR of its own. In the + # catch-all below it would only ever move together with every androidx/Compose/AGP/Kotlin bump, and + # that PR stays red whenever one of them needs something the build lacks: androidx releases raise + # the compileSdk their AAR metadata demands, which :app:checkDebugAarMetadata enforces against the + # app's compileSdk. Nothing of this group would land until someone fixed that by hand. Dependabot + # gives a dependency matching two groups to the more specific one (these patterns beat "*"), and + # within a run a group listed earlier handles it first, so keep this group above the catch-all. + # GradleCiCoverageTest (android/protocol) pins that no other dependency shares its PR. + protocol-net-crypto: + patterns: + - "com.squareup.okhttp3:*" + - "com.hierynomus:*" + - "net.i2p.crypto:*" + - "org.bouncycastle:*" + update-types: [minor, patch] android-minor-patch: patterns: ["*"] update-types: [minor, patch] diff --git a/android/README.md b/android/README.md index 244258ef88..5582fb0218 100644 --- a/android/README.md +++ b/android/README.md @@ -64,10 +64,12 @@ a minified APK works on a phone; none has been run on one. Every CI job that runs `./gradlew` first checks `gradle/wrapper/gradle-wrapper.jar` against Gradle's published checksums (`gradle/actions/setup-gradle`, which also caches `~/.gradle`). Dependabot opens weekly Gradle update PRs for `android/`, the protocol build and the wrapper included (not for -`tools/typecheck`, whose pins follow the app's by hand), and CodeQL analyses the app's and the protocol -module's Kotlin on every pull request (`.github/workflows/security.yml`, job `CodeQL (Kotlin)`). -`GradleCiCoverageTest` pins all three. The wrapper properties carry no `distributionSha256Sum` yet, so -the Gradle distribution itself is not pinned. +`tools/typecheck`, whose pins follow the app's by hand). The protocol's network and crypto libraries +(okhttp, sshj, eddsa, BouncyCastle) come in a PR of their own, so an androidx bump the app cannot take +yet (one that demands a higher `compileSdk`, say) does not hold them back. CodeQL analyses the app's +and the protocol module's Kotlin on every pull request (`.github/workflows/security.yml`, job +`CodeQL (Kotlin)`). `GradleCiCoverageTest` pins all of this. The wrapper properties carry no +`distributionSha256Sum` yet, so the Gradle distribution itself is not pinned. **Debug builds are signed with a public key that is committed on purpose** (`app/debug.keystore`, password `android`). That is what lets you install a newer CI or local diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index c456aa4345..ec6d14529c 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -48,9 +48,10 @@ tasks.test { // terminal page (TerminalJs*Test), the app's sources (the source pins in TerminalKeyboardChipTest, // SettingsLeaveTest and R8RulesTest), the release build's R8 rules, its build type and the CI // workflow that runs R8 (R8RulesTest), the CI config GradleCiCoverageTest checks: the workflows - // that run Gradle, the Dependabot config and the root build's settings, and the contributor docs - // and the wrapper's Gradle version ContributorDocsTest checks against each other, and the audit - // whose finding ids DeviceChecklistDocsTest checks the device checklist against. + // that run Gradle, the Dependabot config, the root build's settings and the build scripts whose + // dependencies it matches against the Dependabot groups (the app's is named above), and the + // contributor docs and the wrapper's Gradle version ContributorDocsTest checks against each other, + // and the audit whose finding ids DeviceChecklistDocsTest checks the device checklist against. // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) // The desktop code the interop fixture bundles (the same dirs as android.yml's path filter). Without @@ -68,6 +69,8 @@ tasks.test { inputs.file(rootDir.resolve("../../.github/workflows/security.yml")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../../.github/dependabot.yml")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../settings.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../gradle/wrapper/gradle-wrapper.properties")).withPathSensitivity(PathSensitivity.RELATIVE) for (doc in listOf( "CONTRIBUTING.md", "docs/android.md", "android/README.md", "android/tools/typecheck/README.md", diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt index 8931ffb13f..710f98b18f 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt @@ -20,6 +20,10 @@ import kotlin.test.assertTrue * 3. Dependabot watches every Gradle build under android/ exactly once: a build another build * includes is covered by that build's entry (Dependabot follows `includeBuild`), and a second * entry would open each of its bumps twice. Except the builds in [notWatched]. + * 4. The protocol's network and crypto stack ([networkAndCrypto]) comes in a Dependabot PR that + * carries nothing else. In one group with every androidx/Compose/AGP/Kotlin bump it could only land + * when all of them build, and an androidx release that demands a higher compileSdk than the app's + * keeps that PR red until someone fixes it by hand (review of A69). * * The YAML is read as text, in the block style this repo's workflows use, as [WorkflowPathFilterTest] * does; what these readers do not understand fails rather than being guessed. @@ -39,6 +43,17 @@ class GradleCiCoverageTest { "for the app's, so they move by hand with the app's (see .github/dependabot.yml)", ) + /** + * The libraries the phone's connections and keys rest on, as Dependabot names them, with what each + * is for. Their bumps must not wait on an unrelated one (item 4 above). + */ + private val networkAndCrypto = mapOf( + "com.squareup.okhttp3:okhttp" to "the relay socket", + "com.hierynomus:sshj" to "the direct-SSH transport", + "net.i2p.crypto:eddsa" to "the phone's Ed25519 identity", + "org.bouncycastle:bcprov-jdk18on" to "the full BouncyCastle provider sshj needs on Android", + ) + @Test fun `every job that runs gradlew validates the wrapper first`() { var gradleJobs = 0 @@ -159,6 +174,59 @@ class GradleCiCoverageTest { } } + @Test + fun `the protocol's network and crypto stack comes in a Dependabot PR of its own`() { + val root = InteropHarness.repoRoot + val entries = gradleEntries(read(dependabot)) + val scripts = gradleDirectories(read(dependabot)).flatMap { dir -> + coveredBuilds(File(root, dir.removePrefix("/"))).flatMap(::buildScripts) + } + assertTrue(scripts.size >= 3, "found only these build scripts in the watched builds: $scripts") + val declared = scripts.flatMap { declaredDependencies(read(it)) }.toSet() + for ((dependency, why) in networkAndCrypto) { + assertTrue( + dependency in declared, + "$dependency ($why) is no longer declared in a Gradle build Dependabot watches; update networkAndCrypto" + ) + } + val others = declared - networkAndCrypto.keys + assertTrue( + others.any { it.startsWith("androidx.") } && others.any { it.startsWith("com.android.") }, + "found no androidx library or Android Gradle plugin among $declared; has the reader stopped seeing them?" + ) + + for (entry in entries) { + val groups = dependabotGroups(entry) + for ((dependency, why) in networkAndCrypto) { + for (type in listOf("major", "minor", "patch")) { + val taking = groups.filter { takes(it, dependency, type) } + // No group takes it: it gets PRs of its own, which nothing else can hold back. + val group = taking.firstOrNull() ?: continue + // dependabot-core (DependencyGroupEngine, read from its source) gives a dependency several + // groups take only to the strictly most specific one, and on a tie to all of them; a run + // then skips what an earlier group, or a group whose PR is still open, already handled. + // A first group that is also strictly the most specific gets it under both rules. + val rivals = taking.drop(1).filter { specificity(it, dependency) >= specificity(group, dependency) } + assertTrue( + rivals.isEmpty(), + "a $type bump of $dependency ($why) is taken first by Dependabot group `${group.name}`, but " + + "${rivals.map { it.name }} name it at least as specifically, so which PR carries it depends " + + "on the rule Dependabot applies; keep the stack's own group first and more specific than " + + "any catch-all" + ) + val shared = others.filter { takes(group, it, type) } + assertTrue( + shared.isEmpty(), + "a $type bump of $dependency ($why) lands in Dependabot group `${group.name}` together with " + + "$shared, so it only lands when every one of those builds (an androidx release that " + + "demands a higher compileSdk than the app's keeps such a PR red); give the network and " + + "crypto stack a group of its own, listed before the catch-all" + ) + } + } + } + } + @Test fun `the readers find what they are asked for and refuse what they do not understand`() { val workflow = """ @@ -212,6 +280,75 @@ class GradleCiCoverageTest { listOf("protocol", "../x"), includedBuilds("// includeBuild(\"commented\")\nincludeBuild(\"protocol\")\n includeBuild('../x')\ninclude(\":app\")\n") ) + + val grouped = """ + updates: + - package-ecosystem: gradle + directory: /android + schedule: + interval: weekly + groups: + # a comment + net: + patterns: + - "com.squareup.okhttp3:*" + - 'org.bouncycastle:*' + update-types: [minor, patch] + rest: + patterns: ["*"] + exclude-patterns: [androidx.*] + majors: + update-types: [major] + - package-ecosystem: npm + directory: / + """.trimIndent() + val entries = gradleEntries(grouped) + assertEquals(listOf("/android"), gradleDirectories(grouped)) + val (net, rest, majors) = dependabotGroups(entries.single()) + assertEquals(DependabotGroup("net", listOf("com.squareup.okhttp3:*", "org.bouncycastle:*"), emptyList(), listOf("minor", "patch")), net) + assertEquals(DependabotGroup("rest", listOf("*"), listOf("androidx.*"), null), rest) + assertEquals(DependabotGroup("majors", null, emptyList(), listOf("major")), majors) + assertTrue(takes(net, "com.squareup.okhttp3:okhttp", "minor")) + assertTrue(!takes(net, "com.squareup.okhttp3:okhttp", "major")) + assertTrue(!takes(rest, "androidx.core:core-ktx", "minor"), "an exclude pattern wins") + assertTrue(takes(majors, "androidx.core:core-ktx", "major"), "a group without patterns takes every dependency") + assertTrue(wildcardMatches("com.squareup.okhttp3:*", "Com.Squareup.OkHttp3:okhttp"), "matching ignores case") + assertTrue(!wildcardMatches("com.squareup.okhttp3:*", "com.squareup.okhttp3x:okhttp")) + assertTrue(!wildcardMatches("a.b", "aXb"), "only * is a wildcard") + assertEquals(1, specificity(rest, "com.squareup.okhttp3:okhttp")) + assertEquals(107, specificity(net, "com.squareup.okhttp3:okhttp")) + assertEquals(0, specificity(net, "androidx.core:core-ktx")) + assertEquals(500, specificity(majors, "androidx.core:core-ktx")) + assertEquals(1000, specificity(DependabotGroup("x", listOf("a:b"), emptyList(), null), "a:b")) + assertFailsWith { + dependabotGroups(gradleEntries(grouped.replace("update-types: [major]", "dependency-type: production")).single()) + } + assertFailsWith { + dependabotGroups(gradleEntries(grouped.replace("patterns: [\"*\"]", "patterns: \"*\"")).single()) + } + + val script = """ + plugins { + id("com.android.application") version "8.9.1" apply false + kotlin("jvm") version "2.2.0" + id("org.jetbrains.kotlin.plugin.compose") + } + dependencies { + // implementation("commented:out:1.0") + implementation(platform("androidx.compose:compose-bom:2025.06.00")) + implementation("androidx.compose.ui:ui") + api("com.squareup.okhttp3:okhttp:4.12.0") + systemProperty("x:y", "z") + inputs.file(rootDir.resolve("../app/build.gradle.kts")) + } + """.trimIndent() + assertEquals( + setOf( + "androidx.compose:compose-bom", "androidx.compose.ui:ui", "com.squareup.okhttp3:okhttp", + "com.android.application", "org.jetbrains.kotlin.jvm", + ), + declaredDependencies(script).toSet() + ) } companion object { @@ -284,8 +421,11 @@ class GradleCiCoverageTest { return run >= 0 && step.drop(run).any { "gradlew" in it } } - /** The `directory:` of each `package-ecosystem: gradle` entry of a dependabot.yml. */ - internal fun gradleDirectories(yaml: String): List { + /** + * The `updates:` entries of a dependabot.yml, each as its non-comment lines: the first trimmed and + * without its `- `, the others with their indentation (the nesting of `groups:` needs it). + */ + private fun updateEntries(yaml: String): List> { val lines = yaml.lines().filterNot(::skip) val start = lines.indexOfFirst { it.trimEnd() == "updates:" } if (start < 0) throw AssertionError("no top-level `updates:`") @@ -299,16 +439,148 @@ class GradleCiCoverageTest { if (ind == itemIndent) { if (!text.startsWith("- ")) throw AssertionError("unexpected line in updates: $line") entries.add(arrayListOf(text.removePrefix("- ").trim())) + } else if (ind > itemIndent) { + entries.last().add(line) } else { - entries.last().add(text) + throw AssertionError("unexpected indentation in updates: $line") } } - return entries.filter { value(it, "package-ecosystem") == "gradle" }.map { entry -> - if (entry.any { it.startsWith("directories:") }) { + return entries + } + + /** The `package-ecosystem: gradle` entries of a dependabot.yml, as [updateEntries] gives them. */ + internal fun gradleEntries(yaml: String): List> = + updateEntries(yaml).filter { value(it.map(String::trim), "package-ecosystem") == "gradle" }.onEach { entry -> + if (entry.any { it.trim().startsWith("directories:") }) { throw AssertionError("`directories:` is not understood by this reader; extend it") } - value(entry, "directory") ?: throw AssertionError("a gradle entry without a directory: $entry") } + + /** The `directory:` of each `package-ecosystem: gradle` entry of a dependabot.yml. */ + internal fun gradleDirectories(yaml: String): List = gradleEntries(yaml).map { entry -> + value(entry.map(String::trim), "directory") ?: throw AssertionError("a gradle entry without a directory: $entry") + } + + /** + * One Dependabot group, as far as it decides what it takes: [patterns] null when the group has none + * (it takes every dependency), [updateTypes] null when it takes every update type. + */ + internal data class DependabotGroup( + val name: String, + val patterns: List?, + val excludePatterns: List, + val updateTypes: List?, + ) + + /** The `groups:` of one update entry (from [gradleEntries]), in file order. */ + internal fun dependabotGroups(entry: List): List { + val at = entry.indexOfFirst { it.trim() == "groups:" } + if (at < 0) return emptyList() + if (at == 0) throw AssertionError("an update entry that starts with `groups:` is not understood by this reader") + val block = entry.drop(at + 1).takeWhile { indent(it) > indent(entry[at]) } + val groups = ArrayList>>() + var groupIndent = -1 + for (line in block) { + val ind = indent(line) + if (groupIndent < 0) groupIndent = ind + if (ind == groupIndent) { + val name = Regex("""([A-Za-z0-9_.-]+):""").matchEntire(line.trim())?.groupValues?.get(1) + ?: throw AssertionError("unexpected line in groups: $line") + groups.add(name to ArrayList()) + } else if (ind > groupIndent) { + groups.last().second.add(line) + } else { + throw AssertionError("unexpected indentation in groups: $line") + } + } + return groups.map { (name, body) -> group(name, body) } + } + + private fun group(name: String, body: List): DependabotGroup { + val rules = HashMap>() + var keyIndent = -1 + var i = 0 + while (i < body.size) { + val line = body[i++] + if (keyIndent < 0) keyIndent = indent(line) + if (indent(line) != keyIndent) throw AssertionError("unexpected indentation in group $name: $line") + val (key, rest) = Regex("""([a-z-]+):\s*(.*)""").matchEntire(line.trim())?.destructured + ?: throw AssertionError("unexpected line in group $name: $line") + if (key !in setOf("patterns", "exclude-patterns", "update-types")) { + // dependency-type, applies-to, group-by... change what a group takes; extend this reader. + throw AssertionError("`$key` in Dependabot group $name is not understood by this reader; extend it") + } + val items = if (rest.isNotEmpty()) { + if (!rest.startsWith("[") || !rest.endsWith("]")) throw AssertionError("group $name: `$key` is not a list: $rest") + rest.removeSurrounding("[", "]").split(",").map(String::trim).filter(String::isNotEmpty) + } else { + val block = ArrayList() + while (i < body.size && indent(body[i]) > keyIndent) { + val item = body[i++].trim() + if (!item.startsWith("- ")) throw AssertionError("unexpected line in group $name: $item") + block.add(item.removePrefix("- ").trim()) + } + block + } + rules[key] = items.map { item -> + if ('#' in item) throw AssertionError("group $name: a comment inside `$key` is not understood: $item") + item.removeSurrounding("'").removeSurrounding("\"") + } + } + return DependabotGroup(name, rules["patterns"], rules["exclude-patterns"].orEmpty(), rules["update-types"]) + } + + /** dependabot-core's WildcardMatcher: `*` matches any run of characters, the rest itself, ignoring case. */ + internal fun wildcardMatches(pattern: String, name: String): Boolean = + Regex(pattern.lowercase().split("*").joinToString(".*") { Regex.escape(it) }).matches(name.lowercase()) + + /** Whether [group] takes an [updateType] update of [name] (dependabot-core's DependencyGroup#contains?). */ + internal fun takes(group: DependabotGroup, name: String, updateType: String): Boolean = + (group.updateTypes == null || updateType in group.updateTypes) && + group.excludePatterns.none { wildcardMatches(it, name) } && + (group.patterns == null || group.patterns.any { wildcardMatches(it, name) }) + + /** + * How specifically [group] names [name], by dependabot-core's PatternSpecificityCalculator: an exact + * pattern 1000, one without `*` 500, no patterns 500, `*` alone 1, other wildcards + * 100 - 10 per `*` + one per character past five. + */ + internal fun specificity(group: DependabotGroup, name: String): Int { + val patterns = group.patterns ?: return 500 + return patterns.filter { wildcardMatches(it, name) }.maxOfOrNull { p -> + when { + p == name -> 1000 + p == "*" -> 1 + '*' !in p -> 500 + else -> maxOf(100 - 10 * p.count { it == '*' } + maxOf(p.length - 5, 0), 1) + } + } ?: 0 + } + + /** The build scripts of one Gradle build: its own and its subprojects', not those of builds inside it. */ + private fun buildScripts(build: File): List = build.canonicalFile.let { dir -> + dir.walkTopDown() + .onEnter { + it == dir || (it.name !in setOf("build", ".gradle", "node_modules") && + listOf("settings.gradle.kts", "settings.gradle").none { s -> File(it, s).isFile }) + } + .filter { it.isFile && (it.name == "build.gradle.kts" || it.name == "build.gradle") } + .toList() + } + + /** + * The dependencies a build script declares, named as Dependabot names them: `group:artifact` for a + * library (from any call with one "group:artifact[:version]" argument, `platform(...)` included) + * and the plugin id for a plugin with a version (`kotlin("jvm")` is org.jetbrains.kotlin.jvm). + */ + internal fun declaredDependencies(script: String): List { + val text = stripComments(script) + val libraries = Regex("""\b\w+\(\s*"([A-Za-z0-9_.-]+):([A-Za-z0-9_.-]+)(?::[A-Za-z0-9_.+-]*)?"\s*\)""") + .findAll(text).map { "${it.groupValues[1]}:${it.groupValues[2]}" } + val plugins = Regex("""\bid\(\s*"([^"]+)"\s*\)\s+version\s+"""").findAll(text).map { it.groupValues[1] } + val kotlinPlugins = Regex("""\bkotlin\(\s*"([^"]+)"\s*\)\s+version\s+"""").findAll(text) + .map { "org.jetbrains.kotlin.${it.groupValues[1]}" } + return (libraries + plugins + kotlinPlugins).toList() } private fun stripComments(kts: String) = From 9d19bd1ac01916e70440625e3f61e9a2ff54e912 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:04:52 +0000 Subject: [PATCH 087/284] fix(android): address review of A61+A71 ContributorDocsTest (39e7995) reads CONTRIBUTING.md, but CONTRIBUTING.md was not in android.yml's push/pull_request path filters, and ci.yml runs no Gradle. A PR editing only CONTRIBUTING.md (the likeliest place for `npm ci --ignore-scripts` or `JDK 17+` to come back) ran no Android job, and the regression would surface as a red job on a later, unrelated PR. WorkflowPathFilterTest did not catch it: its scan only saw the literal form `File(InteropHarness.repoRoot, "...")`, and ContributorDocsTest read its docs as `File(root, path)` over a list of strings. docs/android.md was already added to the filter by 33af5e7 for DeviceChecklistDocsTest. - android.yml: CONTRIBUTING.md in both path lists; the header comment names ContributorDocsTest for it and says what the path test now checks. - ContributorDocsTest: the docs list is literal File(root, "...") reads. - WorkflowPathFilterTest: a read scanner (repoReads) that also sees reads through a val/var bound to InteropHarness.repoRoot and `.resolve("...")`, and skips comments. Every literal read must be matched by both filters (as before); a read by a computed path now fails the test unless the reading file is in computedReads with why its paths are covered (WorkflowPathFilterTest itself, GradleCiCoverageTest's dependabot directories, InteropHarnessTest's bundler script). A stale computedReads entry fails too. A unit test pins the scanner (literal, aliased, resolve, computed, comments, a property named like an alias). - CONTRIBUTING.md: says a protocol test reading a repo file outside android/ also needs the filter, and to name it by a literal path. Verified: with the new scanner and ContributorDocsTest but without the filter entry, `the files the workflow uses...` fails naming CONTRIBUTING.md; with ContributorDocsTest reverted to HEAD (File(root, path)), it fails naming ContributorDocsTest.kt as an unexplained computed read; with alias detection disabled, the scanner test and the stale-entry check fail. Full protocol suite (gradle test --offline --rerun-tasks, 340 tests, 0 failures, 0 skipped) and the offline app type-check pass. Departure from the review's suggestion: both of its options were taken (literal reads and a wider scan), and the scan additionally refuses unexplained computed reads, since a literal-only scan cannot see the next list-driven read either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 8 +- CONTRIBUTING.md | 4 +- .../nodeterm/protocol/ContributorDocsTest.kt | 28 +++--- .../protocol/WorkflowPathFilterTest.kt | 86 +++++++++++++++++-- 4 files changed, 109 insertions(+), 17 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a109203155..2b71f93797 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -26,11 +26,15 @@ name: Android # reads them: the Kotlin CodeQL job and the Gradle Dependabot entry (audit A69) # docs/android.md, docs/android-audit-2026-09.md DeviceChecklistDocsTest reads them: the device # checklist and the audit ids its items name (audit A65) +# CONTRIBUTING.md ContributorDocsTest reads it (with docs/android.md and the two READMEs under +# android/): the JDK range and the `npm ci --ignore-scripts` warning (A61, A71) # To re-derive: `node android/protocol/src/test/interop/bundle-fixture.cjs # android/protocol/src/test/interop/electron-stub.ts` from the repo root writes .meta.json, # whose `inputs` are the bundled files. WorkflowPathFilterTest (android/protocol) fails when one is not # matched by both filters. A change that adds an input edits a file that is already one (or the bundler -# or tsconfig.json), so it runs this workflow and that test. +# or tsconfig.json), so it runs this workflow and that test. The same test fails on a repo file a +# protocol test reads by a literal path (from InteropHarness.repoRoot or a val bound to it) that the +# filters miss, and on a read by a computed path it has not been told about. on: push: paths: @@ -47,6 +51,7 @@ on: - '.github/dependabot.yml' - 'docs/android.md' - 'docs/android-audit-2026-09.md' + - 'CONTRIBUTING.md' pull_request: paths: - 'android/**' @@ -62,6 +67,7 @@ on: - '.github/dependabot.yml' - 'docs/android.md' - 'docs/android-audit-2026-09.md' + - 'CONTRIBUTING.md' workflow_dispatch: concurrency: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e716eac26a..fdfc73a559 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,7 +122,9 @@ applicable here": `npm run typecheck`, so changing one of them fails the typecheck there until the fixture follows. The Android workflow runs on every file that fixture bundles (`src/core`, `src/shared`, `src/main/*.ts`, `src/main/remote`); an import that pulls a file from elsewhere into it fails - `WorkflowPathFilterTest` until the workflow's `paths` list it. Do not copy CI's + `WorkflowPathFilterTest` until the workflow's `paths` list it, and so does a protocol test that + reads a repo file outside `android/` (name it by a literal path from `InteropHarness.repoRoot`; a + computed path fails that test unless its `computedReads` says why it is covered). Do not copy CI's `npm ci --ignore-scripts` onto a desktop checkout; it is for a machine without the native toolchain. `npm ci` deletes `node_modules` first and the flag skips the node-pty patch and build, so node-pty stays unpatched (unbuilt on Linux) until `npm install` or `npm run rebuild` runs again. diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt index ca3daa95e4..32eeb1d41e 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt @@ -26,12 +26,16 @@ import kotlin.test.fail class ContributorDocsTest { private val root = InteropHarness.repoRoot - /** Contributor-facing docs that say how to build or test the Android side. */ + /** + * Contributor-facing docs that say how to build or test the Android side. Each is named by a literal + * path so that WorkflowPathFilterTest sees the read and fails while an edit to the doc would not run + * the Android workflow (CONTRIBUTING.md was missing from its filter while this was a list of strings). + */ private val docs = listOf( - "CONTRIBUTING.md", - "docs/android.md", - "android/README.md", - "android/tools/typecheck/README.md", + File(root, "CONTRIBUTING.md"), + File(root, "docs/android.md"), + File(root, "android/README.md"), + File(root, "android/tools/typecheck/README.md"), ) /** @@ -52,8 +56,9 @@ class ContributorDocsTest { val target = jvmTarget() val ci = ciJavaVersions() var ranges = 0 - for (path in docs) { - val text = read(File(root, path)) + for (doc in docs) { + val path = doc.repoPath() + val text = read(doc) openEnded.findAll(text).forEach { fail( "$path says `${it.value}`, but the wrapper's Gradle $gradle runs on JDK $newestJdk at most. " + @@ -76,14 +81,15 @@ class ContributorDocsTest { } } assertTrue(range.containsMatchIn(read(File(root, "android/README.md"))), "android/README.md no longer states a JDK range") - assertTrue(ranges >= 2, "found only $ranges JDK ranges in $docs; has the reader stopped seeing them?") + assertTrue(ranges >= 2, "found only $ranges JDK ranges in ${docs.map { it.repoPath() }}; has the reader stopped seeing them?") } @Test fun `a paragraph that mentions npm ci --ignore-scripts says how a desktop checkout recovers`() { var mentions = 0 - for (path in docs) { - for (paragraph in read(File(root, path)).split(Regex("""\n\s*\n"""))) { + for (doc in docs) { + val path = doc.repoPath() + for (paragraph in read(doc).split(Regex("""\n\s*\n"""))) { if (!paragraph.contains("--ignore-scripts")) continue mentions++ assertTrue( @@ -105,6 +111,8 @@ class ContributorDocsTest { assertEquals(0, compareVersions("9.1", "9.1.0")) } + private fun File.repoPath() = relativeTo(root).invariantSeparatorsPath + private fun wrapperGradleVersion(): String { val props = read(File(root, "android/gradle/wrapper/gradle-wrapper.properties")) return Regex("""distributionUrl=.*gradle-([0-9][0-9.]*)-(?:bin|all)\.zip""").find(props)?.groupValues?.get(1) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt index 1a0f6f204a..f3cb5470cb 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt @@ -25,6 +25,9 @@ import kotlin.test.assertTrue * file already in it (an import), the bundler's aliases (under android/) or tsconfig.json, so it runs * the workflow, and this test then fails until the new path is listed. * + * The second test does the same for the repo files the protocol tests read, found by [repoReads]; a + * read it cannot resolve to a path has to be explained in `computedReads`. + * * The filter semantics are GitHub's (`*` stays within one path segment, `**` crosses them); syntax this * reader does not implement is refused rather than guessed. */ @@ -44,6 +47,19 @@ class WorkflowPathFilterTest { */ private val unbundledInputs = listOf("tsconfig.json", "package.json", "package-lock.json", workflowPath) + /** + * Test files that read a repo file through a computed path, which [repoReads] cannot resolve, with + * why the paths they read run the workflow anyway. Any other computed read fails the test: it is how + * ContributorDocsTest came to read CONTRIBUTING.md with that file missing from the filter (review + * of A61/A71), because it read its docs as `File(root, path)` over a list of strings. + */ + private val computedReads = mapOf( + "WorkflowPathFilterTest.kt" to "the bundle's metafile inputs and the paths this test requires, which it checks itself", + "GradleCiCoverageTest.kt" to "the gradle directories .github/dependabot.yml names (in the filter); each must " + + "hold a Gradle build, and this repo's are all under android/", + "InteropHarnessTest.kt" to "the bundler script InteropHarness.bundleCommand names, under android/", + ) + @Test fun `every file the fixture bundle is built from runs the workflow`() { assumeTrue(InteropHarness.available(), "node + repo node_modules (npm ci) are needed for interop tests") @@ -64,17 +80,60 @@ class WorkflowPathFilterTest { @Test fun `the files the workflow uses without bundling them run it`() { - // Repo files the protocol tests read by a literal path (ResumeOfferTest reads a src/shared file). + // Repo files the protocol tests read (ResumeOfferTest reads a src/shared file, ContributorDocsTest + // reads CONTRIBUTING.md): each read by a literal path must run the workflow, and a read by a + // computed path must be explained in computedReads. val testSources = File(root, "android/protocol/src/test/kotlin") - val literalReads = testSources.walkTopDown().filter { it.extension == "kt" }.flatMap { file -> - Regex("""File\(\s*InteropHarness\.repoRoot\s*,\s*"([^"$]+)"\s*\)""").findAll(file.readText()).map { it.groupValues[1] } - }.toSortedSet() - assertTrue(literalReads.isNotEmpty(), "found no File(InteropHarness.repoRoot, \"…\") read in $testSources") + val reads = testSources.walkTopDown().filter { it.extension == "kt" }.flatMap { file -> + repoReads(file.readText()).map { file.name to it } + }.toList() + val literalReads = reads.mapNotNull { it.second }.toSortedSet() + assertTrue(literalReads.isNotEmpty(), "found no repo file read by a literal path in $testSources") + val computed = reads.filter { it.second == null }.map { it.first }.toSortedSet() + val unexplained = computed - computedReads.keys + assertTrue( + unexplained.isEmpty(), + "these test files read a repo file through a computed path, so this test cannot check that editing " + + "the file runs the workflow: $unexplained. Name each file by a literal path from " + + "InteropHarness.repoRoot (or from a val bound to it), or list the test file in computedReads " + + "with why its paths are in the filter anyway." + ) + val stale = computedReads.keys - computed + assertTrue(stale.isEmpty(), "computedReads lists $stale, which read no repo file through a computed path now; remove it") val required = (unbundledInputs + literalReads).toSortedSet() for (path in required) assertTrue(File(root, path).exists(), "$path does not exist; update this test") assertCovered(required, "read by the workflow or its tests") } + @Test + fun `the read scanner sees literal, aliased and computed reads`() { + // Built from a template so that this file's own source names no repo file by a literal path. + val h = "InteropHarness" + ".repoRoot" + val source = """ + /** File($h, "in/a/kdoc.md") is not a read. */ + class X { + // File($h, "in/a/comment.md") is not either + private val base = $h + var dir: File = $h + private val other = File("/tmp") + val a = File($h, "CONTRIBUTING.md") + val b = File( base , "docs/android.md" ) + val c = base.resolve("android/README.md") + val d = File(dir, "src/shared/types.ts") + val e = $h.resolve("docs/SERVER.md") + val f = File(base, path) + val g = File(base, "docs/" + name) + val i = File($h, "x/${'$'}name.md") + val j = File(other, "not/the/repo.md") + val k = other.base.resolve("not/an/alias.md") + } + """.trimIndent() + assertEquals( + listOf("CONTRIBUTING.md", "docs/android.md", "android/README.md", "src/shared/types.ts", "docs/SERVER.md", null, null, null), + repoReads(source) + ) + } + @Test fun `the glob matcher follows GitHub's path filter syntax`() { assertTrue(globRegex("android/**").matches("android/protocol/src/test/interop/host-fixture.ts")) @@ -130,6 +189,23 @@ class WorkflowPathFilterTest { } companion object { + /** + * The repo files [source] (a Kotlin test file) reads, one entry per read in source order: the path + * when it is a string literal, null when it is computed. A read is `File(, …)` or + * `.resolve(…)`, where is `InteropHarness.repoRoot` or a `val`/`var` the same file binds + * to it. Whole-line and block comments are skipped. The scan is textual: a root handed in through a + * parameter is not seen, and a string that quotes a read counts as a computed one (the safe side). + */ + internal fun repoReads(source: String): List { + val code = source.replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), " ") + .lines().filterNot { it.trimStart().startsWith("//") }.joinToString("\n") + val aliases = Regex("""\b(?:val|var)\s+(\w+)\s*(?::\s*File\s*)?=\s*InteropHarness\.repoRoot(?![\w.])""") + .findAll(code).map { Regex.escape(it.groupValues[1]) } + val root = (sequenceOf("""InteropHarness\.repoRoot""") + aliases).joinToString("|") + val read = Regex("""(?:\bFile\(\s*(?:$root)\s*,|(? Date: Sat, 26 Sep 2026 21:11:23 +0000 Subject: [PATCH 088/284] fix(android): address review of A65+A50 Two review findings on 33af5e7, both verified against the code. A50 (major): android/README.md's Security section told a user whose phone may have been exposed over adb to "revoke the phone on each computer and pair it again". That re-trusts the very keys the warning says were taken. The SSH seed (NodetermApp: getOrCreate32(SecureStore.SSH_SEED)) and the relay box secret (PhoneIdentity.boxSecret) are get-or-create, the relay deviceId goes with the box secret, and nothing in the app removes them (Forget drops only the relay token). On the desktop a pairing appends whatever key the phone sends (pairing-service.ts appendAuthorizedKey) and re-pins its box key. So the Security section now has its own bullet: revoke every entry for the phone on each computer, then uninstall the app or clear its storage (both delete the sealed secrets and the deviceId in the app's preferences, so the app makes a new SSH key, box key and deviceId), then pair again. It also says that a desktop without Pro cannot revoke the old relay device token at the relay (revokeRelayDevice is 'skipped' without an entitlement), so the old box key may still reach it through the relay; the revoke unpinned it, so the desktop asks first, and a phone should be approved only while the user's own phone shows the same code. Checklist item 3 now also runs Clear storage and checks that the re-paired phone's authorized_keys key and pinned box key are new. A65 (minor): checklist item 7 had the tester scan the desktop's QR with the camera app "which hands the nodeterm://pair link to the app". The QR is raw JSON by default (DEFAULT_PAIR_QR_FORM = 'json'), which a camera app shows as text; the link form appears only after "Scan with the phone's Camera app instead" is chosen in Settings -> Phone (the quick-pair popover has no such switch). Item 7 now says so and also checks that the default QR opens nothing. Tests (DeviceChecklistDocsTest): one pins that the README's recovery puts an uninstall or storage clear after the revoke and before the re-pair, while the SSH seed is get-or-create and no app or protocol source removes the seed or the box secret; one pins that, while DEFAULT_PAIR_QR_FORM is 'json', every checklist item naming nodeterm://pair names the switch label read from PhoneSection.tsx and Settings -> Phone. Mutation check: with the old README and docs/android.md restored, both tests fail for the stated reason; with the fix they pass. PhoneSection.tsx is now read by a protocol test, so it joins both path filters of .github/workflows/android.yml (WorkflowPathFilterTest failed until it did). Full protocol suite (342 tests) and the offline app type-check pass. No device run. Departure from the review: the README does not claim that clearing storage deletes the Keystore key itself (not verified here); it says the stored keys and pairings go, which is what makes the new identity. The suggested check that the old SSH key is refused after the re-pair became a comparison of the old and new keys (the tester no longer has the old key to present). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 4 ++ android/README.md | 18 +++++- .../protocol/DeviceChecklistDocsTest.kt | 63 +++++++++++++++++++ docs/android.md | 16 +++-- 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2b71f93797..55a95f3885 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -28,6 +28,8 @@ name: Android # checklist and the audit ids its items name (audit A65) # CONTRIBUTING.md ContributorDocsTest reads it (with docs/android.md and the two READMEs under # android/): the JDK range and the `npm ci --ignore-scripts` warning (A61, A71) +# src/renderer/components/settings/sections/PhoneSection.tsx DeviceChecklistDocsTest reads the +# label of its JSON -> link QR switch, which the camera-app pairing check names # To re-derive: `node android/protocol/src/test/interop/bundle-fixture.cjs # android/protocol/src/test/interop/electron-stub.ts` from the repo root writes .meta.json, # whose `inputs` are the bundled files. WorkflowPathFilterTest (android/protocol) fails when one is not @@ -52,6 +54,7 @@ on: - 'docs/android.md' - 'docs/android-audit-2026-09.md' - 'CONTRIBUTING.md' + - 'src/renderer/components/settings/sections/PhoneSection.tsx' pull_request: paths: - 'android/**' @@ -68,6 +71,7 @@ on: - 'docs/android.md' - 'docs/android-audit-2026-09.md' - 'CONTRIBUTING.md' + - 'src/renderer/components/settings/sections/PhoneSection.tsx' workflow_dispatch: concurrency: diff --git a/android/README.md b/android/README.md index 5582fb0218..9eb20443d0 100644 --- a/android/README.md +++ b/android/README.md @@ -148,9 +148,21 @@ iOS app does receive the detail, in the push the desktop sends. Keystore never hands out the key those files are sealed under, but it lets any code running as the app use it, so that is enough to pull the phone's pairing credentials: the SSH private key your computers accept, the relay box secret and the relay device token. A signed, non-debuggable release build does - not exist yet. Until it does, keep USB and wireless debugging off when you are not using them; if - someone else may have had adb access, revoke the phone on each computer (nodeterm → Settings → - Phone → Revoke) and pair it again. + not exist yet. Until it does, keep USB and wireless debugging off when you are not using them. +- **If someone else may have had adb access, pairing again is not enough.** The phone keeps its SSH + key, its relay box key and its relay device id through a re-pair, so each computer would trust the + same keys again. Give the phone a new identity before it pairs: + 1. Revoke every entry for this phone on each computer (nodeterm → Settings → Phone → Revoke). + 2. Uninstall the app, or clear its storage (Android Settings → Apps → nodeterm → Storage → Clear + storage; the names vary by phone). Either one deletes the app's stored keys and every pairing, + and the app then makes a new SSH key, relay box key and relay device id. + 3. Pair each computer again. + + A computer without Pro cannot revoke the old relay device token at the relay (that request is + signed with the Pro entitlement), so whoever took it may still reach that computer through the + relay with the old box key. The revoke unpinned that key, so the computer shows its approval dialog + with a code before it lets it in: approve a phone there only while your own phone is showing the + same code. - Nothing of the app's goes into a backup or a phone-to-phone transfer. `allowBackup="false"` stops cloud backup, and the manifest's data extraction rules stop the Android 12+ device-to-device transfer, which ignores `allowBackup`. A new phone starts unpaired; pair it again. diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt index e75c162947..4de7d15486 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt @@ -19,6 +19,10 @@ import kotlin.test.fail * - Audit A50: the only APK to install is the debug build, which AGP marks debuggable, so adb access * to the phone yields its pairing credentials. While the release build type has no signing config * (so there is no release to install instead), the README must say so. + * Its recovery must give the phone a new identity (uninstall, or clear its storage) before it pairs + * again, since a re-pair keeps the same keys (review of A50). + * - The camera-app pairing check must name the desktop's switch to the link QR while the QR defaults + * to raw JSON (review of A65). * * The audit and the handover are logs, so only the audit's section headings are read (for the ids). */ @@ -95,6 +99,65 @@ class DeviceChecklistDocsTest { ) } + @Test + fun `the README's recovery after adb access gives the phone a new identity before it pairs again`() { + // Review of A50: the first text said "revoke the phone on each computer and pair it again". That + // gives back what the warning says was taken: the SSH seed and the relay box secret are + // get-or-create (NodetermApp, PhoneIdentity), nothing in the app removes them (Forget drops a + // relay token only), and the desktop appends whatever key a pairing sends and re-pins the box + // key. Only an uninstall or clearing the app's storage makes new ones. + val protocolMain = File(InteropHarness.repoRoot, "android/protocol/src/main/kotlin") + val app = (AppSourcePins.appSrc.walkTopDown() + protocolMain.walkTopDown()) + .filter { it.extension == "kt" } + .joinToString("\n") { it.readText() } + assertTrue( + "getOrCreate32(SecureStore.SSH_SEED)" in app, + "the app no longer gets-or-creates its SSH seed; re-check the README's recovery after adb access" + ) + Assumptions.assumeFalse( + Regex("""remove\(\s*(SecureStore\.)?SSH_SEED\b|remove\(\s*(PhoneIdentity\.)?BOX_SECRET\b""").containsMatchIn(app), + "the app can drop its own identity now: point the README's recovery at that instead of an uninstall" + ) + val security = section(readme, "## Security") + // Markdown wraps anywhere, so the phrases are looked for with the line breaks folded away. + val flat = security.replace(Regex("""\s+"""), " ") + val revoke = flat.indexOf("Settings → Phone → Revoke") + assertTrue(revoke >= 0, "android/README.md's Security section no longer tells the user to revoke the phone:\n$security") + val after = flat.substring(revoke) + val renew = Regex("""\b[Uu]ninstall\b.*?\bclear its storage\b""").find(after) + val pair = Regex("""\b[Pp]air\b""").find(after) + assertTrue( + renew != null && pair != null && renew.range.first < pair.range.first, + "android/README.md's recovery after adb access must have the phone uninstall the app or clear its " + + "storage (a new SSH key, box key and device id) after the revoke and before it pairs again: a " + + "re-pair alone trusts the same keys again:\n$security" + ) + } + + @Test + fun `while the pairing QR defaults to JSON, the camera-app check names the desktop's switch`() { + // Review of A65: item 7 had the tester scan the desktop's QR with the camera app, "which hands the + // nodeterm://pair link to the app". The QR is raw JSON unless the user picks the URL form on the + // desktop, so a tester following it would record a false failure. + val pairQr = read(File(InteropHarness.repoRoot, "src/shared/pair-qr.ts")) + val default = Regex("""DEFAULT_PAIR_QR_FORM\s*:\s*PairQrForm\s*=\s*'(\w+)'""").find(pairQr)?.groupValues?.get(1) + ?: fail("src/shared/pair-qr.ts no longer declares DEFAULT_PAIR_QR_FORM as a literal") + Assumptions.assumeTrue(default == "json", "the pairing QR is a link by default now: item 7 can drop the switch") + val phone = read(File(InteropHarness.repoRoot, "src/renderer/components/settings/sections/PhoneSection.tsx")) + val switch = Regex("""qrForm === 'url'\s*\?\s*'[^']*'\s*:\s*"([^"]+)"""").find(phone)?.groupValues?.get(1) + ?: fail("PhoneSection.tsx no longer has the JSON → URL QR switch this test reads its label from") + val items = checklistItems().filter { "nodeterm://pair" in it.second } + assertTrue(items.isNotEmpty(), "no checklist item checks the `nodeterm://pair` link from the camera app") + for ((n, text) in items) { + val flat = text.replace(Regex("""\s+"""), " ") + assertTrue( + "\"$switch\"" in flat && "Settings → Phone" in flat, + "checklist item $n has the camera app open the `nodeterm://pair` link, but the desktop's QR is raw " + + "JSON unless \"$switch\" is chosen in its Settings → Phone; the item must say so:\n$text" + ) + } + } + /** (number, text) for each item of docs/android.md's device checklist, continuation lines included. */ private fun checklistItems(): List> { val items = mutableListOf>() diff --git a/docs/android.md b/docs/android.md index f0869cdb7e..cdefe577ef 100644 --- a/docs/android.md +++ b/docs/android.md @@ -375,7 +375,12 @@ later fix left to a device. still shows one entry for this phone, and no new relay approval is asked for. *(A24, A65)* 3. Uninstall, then install again: the app starts with no computers (its Keystore key and data went with it). Pairing the same computer again works, relay included, also on a desktop without Pro. - Revoke the stale entry on the desktop. *(A51, A65)* + Revoke the stale entry on the desktop. Then do it once more with Clear storage (Android Settings → + Apps → nodeterm → Storage) in place of the uninstall. After either one the phone has a new + identity, which the README's recovery after adb access depends on: on a macOS or Linux desktop the + key the new pairing adds to `~/.ssh/authorized_keys` differs from the stale entry's, and the box key + the desktop pins (`remote-approved-devices.json` in its app data) differs from the old one. + *(A51, A65, A50)* 4. Android 12 or later, with a second phone: a device-to-device transfer ("copy apps and data" in the new phone's setup) leaves the app there with no computers and no pins. Pair it too, then revoke one of the two phones on an entitled (Pro) desktop: the other keeps working. A cloud backup @@ -392,9 +397,12 @@ later fix left to a device. 6. Pair by QR from the Pair screen's scanner with a macOS desktop, then a Linux desktop, then a Windows desktop. The Windows QR carries `"ssh":false`, so that pairing is relay-only, and a failed relay mint pairs nothing. *(A65)* -7. Pair by pasting the code's text, and by scanning the desktop's QR with the phone's own camera app, - which hands the `nodeterm://pair?code=…` link to the app: once with the app closed, once with it - open on another screen. Deny the camera permission: pasting still pairs. *(A65)* +7. Pair by pasting the code's text, and by scanning the desktop's QR with the phone's own camera app. + The desktop's QR is raw JSON by default, which a camera app shows only as text: check that nothing + opens. For the camera, first choose "Scan with the phone's Camera app instead" under the QR in the + desktop's Settings → Phone (the quick-pair popover has no such switch); that QR carries the + `nodeterm://pair?code=…` link, which the camera hands to the app. Scan it once with the app closed, + once with it open on another screen. Deny the camera permission: pasting still pairs. *(A65)* 8. With remote access on, against a current desktop: the Pair screen says "Remote access is on", the pairing ends with "Paired, and approved for remote access.", and the first relay connect later raises no SAS dialog on the desktop. Revoke the phone there (Settings → Phone → Revoke): SSH is From 612bcf4e42aaff85dd8b5a57cf8e42fe101ad116 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:18:16 +0000 Subject: [PATCH 089/284] docs(android): batch C progress; the device checklist lives in docs/android.md Handover: a progress-log table for the CI/harness/docs batch; the test caveat now says what is real desktop code since A64 and what is still hand-copied; the node_modules note no longer tells a desktop contributor to run npm ci --ignore-scripts; the device checklist points at docs/android.md (A65). Audit index: the batch's findings marked, A50 marked documented only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-audit-2026-09.md | 24 +++++------ docs/android-handover.md | 79 +++++++++++++++-------------------- 2 files changed, 46 insertions(+), 57 deletions(-) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md index 5b4e99cfd0..3c5efe7994 100644 --- a/docs/android-audit-2026-09.md +++ b/docs/android-audit-2026-09.md @@ -64,7 +64,7 @@ the audit's proposal, the handover's progress log says how and why. | [A47](#a47) | low | | small | runtime/bug | ✅ fixed in `52df0a3` · The Keystore decrypt runs on the main thread in the host list's composition, once per row per recomposition | | [A48](#a48) | low | | small | runtime/bug | ✅ fixed in `d383e76` · The seen-events set is trimmed in hash order and updated without synchronization, which can produce duplicate notifications | | [A49](#a49) | low | | small | security/risk | ✅ fixed in `a40d11b` · SSH host-key TOFU pin is saved during key exchange (before auth) and is not tied to the pairing | -| [A50](#a50) | low | | medium | security/risk | The only distributable build is a debuggable APK, so Keystore-protected secrets can be pulled over adb/JDWP | +| [A50](#a50) | low | | medium | security/risk | 📝 documented in `33af5e7` (README warning); the signed non-debuggable release is still open · The only distributable build is a debuggable APK, so Keystore-protected secrets can be pulled over adb/JDWP | | [A51](#a51) | low | | small | security/gap | ✅ fixed in `9b4af70` · allowBackup=false does not stop device-to-device migration at targetSdk 35: hosts, pins and deviceId are cloned | | [A52](#a52) | low | | small | security/gap | ✅ fixed in `3780f5a` · Approval and finish notifications put command text and the agent's last message on the lock screen | | [A53](#a53) | low | | small | security/bug | ✅ fixed in `af587ac` · OSC 52 handler has no size cap (the desktop caps at 1,000,000) and setPrimaryClip is unguarded | @@ -74,18 +74,18 @@ the audit's proposal, the handover's progress log says how and why. | [A57](#a57) | low | | small | parity/gap | ✅ fixed in `8dbeb48` · Multi-select AskUserQuestion cards fall back to "Open session" | | [A58](#a58) | low | | small | parity/gap | ✅ fixed in `a44f16c` · Usage and feed cards omit iOS's pace line and the context indicator on event cards | | [A59](#a59) | low | | small | parity/gap | No built-in dictation (iOS has on-device Whisper plus a Cloud engine) | -| [A60](#a60) | low | | small | ci-docs/bug | Interop fixture needs the Electron binary, which is downloaded at test time inside the 20 s ready window, despite the workflow saying 'not Electron' | -| [A61](#a61) | low | | small | ci-docs/bug | Following CONTRIBUTING / android/README (`npm ci --ignore-scripts`) wipes a desktop developer's patched node_modules | -| [A62](#a62) | low | | small | ci-docs/bug | SSH transport tests fail, not skip, on macOS: the gate checks only that /usr/bin/script exists, then runs util-linux-only flags | -| [A63](#a63) | low | | small | ci-docs/gap | Android workflow path filters miss files that change the tested wire behavior, contrary to CLAUDE.md | -| [A64](#a64) | low | | small | ci-docs/gap | Docs say the protocol tests check the mirror, the projects.list blob and the ~/.nodeterm files against desktop code, but those shapes are hand-copied in the tests | -| [A65](#a65) | low | | small | ci-docs/gap | User-facing docs and desktop UI present the Android app as working, but it has never been built by AGP or run on a device, and no device checklist exists | -| [A66](#a66) | low | | small | ci-docs/gap | ANDROID_APP_URL points at a folder that exists on neither upstream nor fork main yet, and it points at source code rather than an installable | -| [A67](#a67) | low | | small | ci-docs/gap | The interop fixture is excluded from every tsconfig, so `npm run typecheck` never checks it against the desktop interfaces it implements | +| [A60](#a60) | low | | small | ci-docs/bug | ✅ fixed in `67e6297` · Interop fixture needs the Electron binary, which is downloaded at test time inside the 20 s ready window, despite the workflow saying 'not Electron' | +| [A61](#a61) | low | | small | ci-docs/bug | ✅ fixed in `39e7995` · Following CONTRIBUTING / android/README (`npm ci --ignore-scripts`) wipes a desktop developer's patched node_modules | +| [A62](#a62) | low | | small | ci-docs/bug | ✅ fixed in `77c760d` · SSH transport tests fail, not skip, on macOS: the gate checks only that /usr/bin/script exists, then runs util-linux-only flags | +| [A63](#a63) | low | | small | ci-docs/gap | ✅ fixed in `b3d4c6e` · Android workflow path filters miss files that change the tested wire behavior, contrary to CLAUDE.md | +| [A64](#a64) | low | | small | ci-docs/gap | ✅ fixed in `f9782da` · Docs say the protocol tests check the mirror, the projects.list blob and the ~/.nodeterm files against desktop code, but those shapes are hand-copied in the tests | +| [A65](#a65) | low | | small | ci-docs/gap | ✅ fixed in `33af5e7` · User-facing docs and desktop UI present the Android app as working, but it has never been built by AGP or run on a device, and no device checklist exists | +| [A66](#a66) | low | | small | ci-docs/gap | ✅ fixed in `61e218b` · ANDROID_APP_URL points at a folder that exists on neither upstream nor fork main yet, and it points at source code rather than an installable | +| [A67](#a67) | low | | small | ci-docs/gap | ✅ fixed in `696fd10` · The interop fixture is excluded from every tsconfig, so `npm run typecheck` never checks it against the desktop interfaces it implements | | [A68](#a68) | low | | small | ci-docs/risk | Workflow triggers break repo conventions: every branch push plus pull_request doubles runs, and there is no merge_group | -| [A69](#a69) | low | | small | ci-docs/gap | The Gradle/Kotlin code has no dependency-update, CodeQL or wrapper-validation coverage | -| [A70](#a70) | low | | small | ci-docs/bug | On Windows the interop tests fail with CreateProcess instead of skipping | -| [A71](#a71) | low | | small | ci-docs/gap | android/README says 'JDK 17+', but the pinned Gradle 8.14.3 cannot run on JDK 25 | +| [A69](#a69) | low | | small | ci-docs/gap | ✅ fixed in `6f3a8da` · The Gradle/Kotlin code has no dependency-update, CodeQL or wrapper-validation coverage | +| [A70](#a70) | low | | small | ci-docs/bug | ✅ fixed in `68d5da6` · On Windows the interop tests fail with CreateProcess instead of skipping | +| [A71](#a71) | low | | small | ci-docs/gap | ✅ fixed in `39e7995` · android/README says 'JDK 17+', but the pinned Gradle 8.14.3 cannot run on JDK 25 | | [A72](#a72) | low | | medium | critic/gap | ✅ fixed in `71290de` · Phone-started sessions are created without the agent-specific env, so for their whole life they get no hook-reply approvals, no canvas control and no pane ownership | | [A73](#a73) | low | | small | critic/bug | ✅ fixed in `e055f37` · Notifications are documented as 'live every 8 seconds while a computer is open', but the in-app poll never posts a notification | | [A74](#a74) | low | | small | critic/bug | ✅ fixed in `a40d11b` · The LAN leg dials a DHCP IPv4 frozen at pairing time; when another SSH host answers at that address, the host-key 'hard stop' also blocks the relay fallback | diff --git a/docs/android-handover.md b/docs/android-handover.md index cd3d840021..9ef496506b 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -25,6 +25,21 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr Newest first. Each entry says what landed, how it was checked, and where the fix differs from the audit's proposal. +### WP6 batch C (CI, test harness and docs): done on the branch + +| Finding | Commits | What changed | Checked by | +|---|---|---|---| +| A60 | `67e6297` | The interop bundle aliases `electron` to a throwing stub instead of loading the real package (whose binary was downloaded at test time); a failed fixture start kills its node process. | `InteropHarnessTest` (no `require("electron")` in the bundle; a never-ready start leaves no process). | +| A62 | `77c760d`, `bf24cfc` | The SSH tests make a short, realpath'd tmux socket root (macOS's 103-character limit) and drive either util-linux or BSD `script`; POSIX-only helper tests skip elsewhere. | `SshTestHostTest`; not run on a Mac. | +| A70 | `68d5da6` | The fixture is bundled through esbuild's JS API by `node` (no `.bin` shim for CreateProcess); the pairing fixture pins its platform and the test isolates `USERPROFILE`. | Bundle byte-identical to the old CLI output; not run on Windows. | +| A67 | `696fd10` | `tsconfig.node.json` includes the interop fixture, so `npm run typecheck` checks it; `HostSessionOptions.pty` narrowed to `HostPtyManager`, removing the fixture's cast. | `npm run typecheck`; a guard test pins the include and bans the casts. | +| A63 | `b3d4c6e`, `9d19bd1` | `android.yml` runs on every file the fixture bundles (`src/core/**`, `src/shared/**`, `src/main/*.ts`, `src/main/remote/**`, `package*.json`, `tsconfig.json`) and on the docs the tests read; the list is derived from esbuild's metafile. | `WorkflowPathFilterTest` re-derives it. | +| A64 | `f9782da` | The relay `projects.list` blob comes from `buildProjectsListBlob` (one assembly for `index.ts` and the fixture); mirror entries from the real writer. Docs now say which contracts are still hand-copied. | vitest `projects-list-blob.test.ts`; a guard test refuses a hand-written blob. | +| A69 | `6f3a8da`, `4806b92` | `setup-gradle` (wrapper validation, a read-mostly cache) in every Gradle job; Dependabot `gradle` for `/android` with the network/crypto stack in its own group; a Kotlin CodeQL job built under CodeQL (`build-mode: manual`). The gradle-8.14.3 distribution checksum is not pinned yet (no network here). | `GradleCiCoverageTest`; the CodeQL job's first CI run is below. | +| A61 / A71 | `39e7995`, `9d19bd1` | Docs: an existing `npm install` is enough for the interop tests; `npm ci --ignore-scripts` is CI's and replaces `node_modules`. JDK 17–24 (Gradle 8.14.3 cannot start on 25). | `ContributorDocsTest`. | +| A65 / A50 | `33af5e7`, `d092639` | `docs/android.md` gets the numbered device checklist; the READMEs say the debug APK is debuggable, and that a phone exposed over adb needs a new identity (uninstall or clear storage) before re-pairing, not just re-pairing. A signed non-debuggable release is still open (`A50`). | `DeviceChecklistDocsTest`. | +| A66 | `61e218b` | The desktop's Android link reads "nodeterm for Android (build from source)" and is derived from `REPO_URL`. The link resolves once `android/` is on upstream `main`. | vitest `androidAppLink.test.tsx`. | + ### WP6 batch B (app-only low-severity items): done on the branch, not device-verified Most of the logic moved into pure, JVM-tested classes in `android/protocol` (the Compose wiring is @@ -185,9 +200,13 @@ Verified: and accepts `pairingToken | token | joinToken` in the reply, but it has not been checked against the live backend (the backend repo is not here). - Release/minified builds on a device. R8 runs for release in CI (`A37`: `assembleRelease` plus `tools/check-r8-output.sh`), but no minified APK has been installed or run, and no release signing key exists. -- **Caveat on the tests:** their fixtures hand-copy some desktop shapes: the mirror, the - `projects.list` blob and the `~/.nodeterm` files (`A64`). That is how `A02` passed its test while - being wrong on every real desktop. +- **Caveat on the tests:** since `A64` the relay leg's `projects.list` blob comes from the + desktop's own assembly (`src/core/projects-list-blob.ts`, shared with `src/main/index.ts`) and its + mirror entries from the real mirror writer; the session list inside the blob and the mirror's + `settings` provider are still fixture-authored. The SSH leg's shapes (the v3 index, project files, + `agent-status.json`, the pending/acks files, `relay.json`) are still hand-copied in + `SshTransportTest`; `docs/android.md` names them. That hand-copying is how `A02` passed its test + while being wrong on every real desktop. ## Environment notes for a cloud session @@ -199,8 +218,10 @@ Verified: - Rely on CI for the real AGP build: push, then read the Android workflow run. - Maven Central sometimes answers 429. Retrying works. - System `gradle` is 8.14.3 on JDK 21 in the sandbox; the wrapper pins 8.14.3 and CI uses JDK 17. -- The protocol tests need `npm ci --ignore-scripts` at the repo root first (for the esbuild-bundled - interop fixture) and a `tmux` on PATH. Run them with +- The protocol tests need the repo's `node_modules` (an existing `npm install` is enough; on a + machine without the native toolchain use `npm ci --ignore-scripts`, as CI does — it replaces + `node_modules`, so a desktop checkout then needs `npm install` or `npm run rebuild`) and a `tmux` + on PATH. Run them with `cd android/protocol && gradle test --offline`, or `cd android && ./gradlew -p protocol test`. Without node the interop tests skip. - Desktop checks for any change to `src/main/remote/*`: @@ -240,7 +261,9 @@ below use the audit IDs. ### WP3: device test pass (after WP1 + WP2) -Install the CI APK (from the new stable debug key) on a real phone and walk the checklist below. +Install the CI APK (from the new stable debug key) on a real phone and walk the numbered checklist in +[`android.md` → Device checklist](android.md#device-checklist) (51 items, each naming the finding it +checks). Record results in `docs/android.md` → "What is verified". Anything that fails becomes a new finding. ### WP4: medium bugs (after the device pass) @@ -288,45 +311,11 @@ template. Mention @eneskirca for the mobile implications: the pairing-key pin fr 4. **Distribution**: a committed public debug key for sideloading (`A10`). Done. A real release signing key and a store listing are still to come. -## Device checklist (owed; nothing here has been run) - -Run each on a real phone against a real desktop, and note OS versions. - -1. Install the CI APK. Install the next CI APK over it without uninstalling (checks `A10`). -2. Pair by QR with a macOS desktop, then with a Linux desktop, then with a Windows desktop (the - Windows QR carries `"ssh":false`, so pairing is relay-only). -3. Pair by pasting the code, and by opening a `nodeterm://pair?code=…` link. -4. On the LAN (Auto route): the session list shows the desktop's projects and sessions (checks - `A02`). -5. Open a terminal and type with the soft keyboard. Rotate. Use A−/A+ and each key chip. The - connection must survive (checks `A01`). -6. Non-ASCII renders correctly: Claude's rounded borders, accented letters, CJK, emoji (checks - `A03`). -7. Swipe to scroll tmux history. Copy via tmux selection (OSC 52 reaches the Android clipboard). -8. "Sized to another screen · Fit this screen" appears when the desktop is larger, and Fit works. -9. On cellular (not the LAN): connect through the relay. The desktop shows the SAS; the phone shows - the same code; approve. Reconnect later: no second prompt. -10. With the phone backgrounded for 15+ minutes, no unexpected SAS dialog appears on the desktop - (checks `A05`). -11. Approve and deny a held Claude permission from the Inbox within 45 s. Retry after the hold - expired: the phone must not report success (checks `A06`). -12. Answer an AskUserQuestion from the Inbox. -13. Open a finished session on the phone: the desktop's unread dot clears. -14. Board: move a card, add/remove/create a label; the desktop board updates without a reload. -15. New session (Claude, shell) from the phone: the node appears on the canvas, the agent runs, and - status badges update. -16. Wake / refresh / rename / end session from the phone. -17. Reboot the desktop, then open a session from the phone: the resume offer appears and resumes - the right conversation under the right account (checks `A15`). -18. A background notification arrives (within ~15 min); tapping it opens that computer (checks - `A11`). -19. Kill the app process in the background (via developer options) and reopen it: the screen and - back stack are sensible (checks `A22`). -20. Forget a host; change a host's route in Settings; pair two computers at once. -21. Put the desktop to sleep while the phone is connected over SSH: the phone notices and falls back - (checks `A31`). -22. Android 15 edge-to-edge: the keyboard does not double-pad the terminal (checks `A77`). -23. Light and dark system theme; a tablet or foldable if available. +## Device checklist + +Moved to [`android.md` → Device checklist](android.md#device-checklist) (`A65`): one numbered list, +grouped by area, each item naming the finding it checks, including every device check the fix +commits asked for. Nothing on it has been run. ## Conventions for whoever continues From 86d059d4f25756273485ec41bc993d718e2d8021 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:23:50 +0000 Subject: [PATCH 090/284] ci(android): run the Kotlin CodeQL job from android.yml, where a branch can verify it (A69) 6f3a8da put the Kotlin CodeQL job in security.yml. That workflow runs only on pull requests into main, pushes to main, the merge queue and a weekly schedule, and has no workflow_dispatch, so nothing could run the job before its first run on somebody's PR, where a broken job would show red on every PR into main. The job moves unchanged (build-mode manual, security-extended, the traced :app:compileDebugKotlin, category /language:java-kotlin) into android.yml, so it runs on this branch's pushes and on pull requests that touch Android-relevant files. security.yml is back to exactly its state before 6f3a8da. Trade-off, written in the job's comment: the Kotlin is analysed when Android-relevant files change, and no longer on the weekly re-scan; code scanning compares a PR with the latest main analysis of the same category, so a PR that touches none of them needs none. It is not a required check. GradleCiCoverageTest now reads the job from android.yml (and security.yml left the test inputs and the path filter, since nothing reads it any more); README and CONTRIBUTING say where it runs. Checks: both workflows parse; full protocol suite (342 tests) and the offline type-check pass; deleting the job from android.yml fails 2 of GradleCiCoverageTest's 6 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- .github/workflows/android.yml | 77 +++++++++++++++++-- .github/workflows/security.yml | 53 +------------ CONTRIBUTING.md | 5 +- android/README.md | 7 +- android/protocol/build.gradle.kts | 12 +-- .../nodeterm/protocol/GradleCiCoverageTest.kt | 24 +++--- 6 files changed, 103 insertions(+), 75 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 55a95f3885..fe808ff4ab 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,8 +22,8 @@ name: Android # the fixture's type check runs in ci.yml, which has no path filter) # package.json, package-lock.json `npm ci` installs esbuild, ws and tweetnacl from them # .github/workflows/android.yml this file -# .github/workflows/security.yml, .github/dependabot.yml GradleCiCoverageTest (android/protocol) -# reads them: the Kotlin CodeQL job and the Gradle Dependabot entry (audit A69) +# .github/dependabot.yml GradleCiCoverageTest (android/protocol) reads it: the Gradle Dependabot +# entry (audit A69); the Kotlin CodeQL job it pins is in this file # docs/android.md, docs/android-audit-2026-09.md DeviceChecklistDocsTest reads them: the device # checklist and the audit ids its items name (audit A65) # CONTRIBUTING.md ContributorDocsTest reads it (with docs/android.md and the two READMEs under @@ -49,7 +49,6 @@ on: - 'package.json' - 'package-lock.json' - '.github/workflows/android.yml' - - '.github/workflows/security.yml' - '.github/dependabot.yml' - 'docs/android.md' - 'docs/android-audit-2026-09.md' @@ -66,7 +65,6 @@ on: - 'package.json' - 'package-lock.json' - '.github/workflows/android.yml' - - '.github/workflows/security.yml' - '.github/dependabot.yml' - 'docs/android.md' - 'docs/android-audit-2026-09.md' @@ -74,6 +72,10 @@ on: - 'src/renderer/components/settings/sections/PhoneSection.tsx' workflow_dispatch: +# One group per ref for every job here, the Kotlin CodeQL job included. Only pull request runs are +# cancelled in progress: a push run never is, so a push to main (which carries the Kotlin CodeQL +# baseline pull requests are compared with) runs to the end. As anywhere in Actions, a run still pending +# behind it is replaced by a newer one for the same ref, which analyses the newer commit. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -89,8 +91,8 @@ permissions: # closed-source gradle-actions-caching library under the Gradle Technologies Terms of Use, and using it # accepts those terms for this repository, which is the owner's call (switching is this one input). # The basic provider uses ONE key for every job, setup-java---gradle-, shared with security.yml's CodeQL (Kotlin) job, and the first job to -# save a key keeps it. So exactly one job writes it: `app`, which resolves the most (AGP, the app's and +# and gradle-wrapper.properties>, the CodeQL (Kotlin) job below included, and the first job to save a +# key keeps it. So exactly one job writes it: `app`, which resolves the most (AGP, the app's and # the protocol module's dependencies, the Gradle distribution), and only on the default branch (the # action's own default; a pull request never writes). The others read it with cache-read-only and # download what only they use. GradleCiCoverageTest (android/protocol) pins the validation step and the @@ -203,3 +205,66 @@ jobs: name: r8-release-outputs path: android/app/build/outputs/mapping/release if-no-files-found: ignore + + # CodeQL for the Android companion's Kotlin (audit A69): the protocol module (the NaCl box, the relay + # client, pairing, SSH host-key pinning) and the app (the Keystore-held secrets, the terminal WebView + # bridge). It is here rather than in security.yml beside the JS/TS `CodeQL` job (the required check, + # left as it was): security.yml runs only on pull requests into main, pushes to main, the merge queue + # and a weekly schedule, with no workflow_dispatch, so a job there cannot run from a feature branch and + # its first run ever would be on somebody's pull request, where a broken job shows red on every PR into + # main. Here it runs on this workflow's triggers: a push to any branch or a pull request that changes a + # file the path filter above lists, and workflow_dispatch. + # The trade-off: it follows that path filter. The Kotlin (all of it under android/) is analysed when + # Android-relevant files change: on the branch that changes them, on a pull request that touches them, + # and on main when such a pull request lands. Code scanning compares a pull request with the latest + # analysis on main of the same category, so a pull request that touches no Android file needs none of + # its own; its Kotlin is main's. Lost against security.yml's triggers: the weekly re-scan (a new CodeQL + # query reaches unchanged Kotlin only with the next Android change on main) and a merge-queue run. It + # is not a required check and must not become one: a pull request this workflow skips reports nothing + # for it, so a required one would wait forever. On a pull request from a branch of this repo it runs + # twice, like every job here (push and pull_request); each run uploads for its own ref. Not verified: + # whether GitHub's code-scanning check on a pull request that skips it reports main's java-kotlin + # configuration as not found (that is a warning on that check, not a failure). + # build-mode none would analyse nothing: for java-kotlin it reads Java sources only, and android/ is all + # Kotlin. So CodeQL traces a build of it (manual). `:app:compileDebugKotlin` compiles the app and, + # through settings.gradle.kts' includeBuild("protocol"), the protocol module's main sources (tests are + # not analysed); it needs the Android SDK that ubuntu-latest ships, as the app job above does. + # --no-daemon keeps the build in a process CodeQL traces; the in-process strategy keeps the Kotlin + # compiler inside it rather than in a separate Kotlin daemon (the property reaches an included build + # too: checked locally on tools/typecheck, which includes the protocol build the same way; with it no + # KotlinCompileDaemon starts, without it one does); + # --no-build-cache keeps a cache hit from skipping the compile CodeQL extracts from. + # setup-gradle validates the wrapper jar before ./gradlew runs and reads the Gradle cache the app job + # writes (see the note above `jobs:`). GradleCiCoverageTest (android/protocol) pins this job. + codeql-kotlin: + name: CodeQL (Kotlin) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + cache-read-only: true + + - uses: github/codeql-action/init@v4 + with: + languages: java-kotlin + build-mode: manual + queries: security-extended + + - name: Build the Kotlin (app + protocol) under CodeQL + working-directory: android + run: ./gradlew --no-daemon --no-build-cache -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugKotlin --stacktrace + + - uses: github/codeql-action/analyze@v4 + with: + category: '/language:java-kotlin' diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f514900823..25e72c66fb 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,11 +1,9 @@ name: Security -# Independent security gates on every PR into main (and a weekly baseline scan): +# Two independent security gates on every PR into main (and a weekly baseline scan): # - CodeQL static analysis of the JS/TS sources (main + preload + renderer + server). -# - CodeQL static analysis of the Android companion's Kotlin (android/: the app and the protocol -# module), in its own job; see the note above it. # - Dependency review: blocks a PR that adds a vulnerable or disallowed-license dep. -# All are free on public repos and add no secrets to the PR runner. +# Both are free on public repos and add no secrets to the PR runner. on: # Merge-queue runs: required checks must also report on the queue's synthetic # merge commits, or every queued PR times out waiting for them. @@ -24,7 +22,7 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -# Least privilege: only the CodeQL jobs need security-events:write to upload results. +# Least privilege: only CodeQL needs security-events:write to upload results. permissions: contents: read @@ -46,51 +44,6 @@ jobs: with: category: '/language:javascript-typescript' - # The Android companion's Kotlin (audit A69): the protocol module (the NaCl box, the relay client, - # pairing, SSH host-key pinning) and the app (the Keystore-held secrets, the terminal WebView bridge). - # A job of its own rather than a second language in the job above, so that job's name and required - # check (`CodeQL`) stay exactly as they were; this one is not a required check. - # build-mode none would analyse nothing: for java-kotlin it reads Java sources only, and android/ is all - # Kotlin. So CodeQL traces a build of it (manual). `:app:compileDebugKotlin` compiles the app and, - # through settings.gradle.kts' includeBuild("protocol"), the protocol module's main sources (tests are - # not analysed); it needs the Android SDK that ubuntu-latest ships, as android.yml's app job does. - # --no-daemon keeps the build in a process CodeQL traces; the in-process strategy keeps the Kotlin - # compiler inside it rather than in a separate Kotlin daemon (the property reaches an included build - # too: checked locally on tools/typecheck, which includes the protocol build the same way; with it no - # KotlinCompileDaemon starts, without it one does); - # --no-build-cache keeps a cache hit from skipping the compile CodeQL extracts from. - # It runs on this workflow's triggers, not android.yml's path filter, so main's baseline, the merge - # queue and the weekly re-scan all carry the analysis and every pull request is compared with it. - # setup-gradle validates the wrapper jar before ./gradlew runs and reads the Gradle cache android.yml's - # app job writes (see the note there). GradleCiCoverageTest (android/protocol) pins this job. - codeql-kotlin: - name: CodeQL (Kotlin) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '17' - - uses: gradle/actions/setup-gradle@v6 - with: - cache-provider: basic - cache-read-only: true - - uses: github/codeql-action/init@v4 - with: - languages: java-kotlin - build-mode: manual - queries: security-extended - - name: Build the Kotlin (app + protocol) under CodeQL - working-directory: android - run: ./gradlew --no-daemon --no-build-cache -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugKotlin --stacktrace - - uses: github/codeql-action/analyze@v4 - with: - category: '/language:java-kotlin' - dependency-review: name: Dependency review # Only meaningful on a PR (it diffs the PR's manifest against the base). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fdfc73a559..e5b6efaf99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -858,8 +858,9 @@ in CLAUDE.md § Idle energy. ## Pull requests - Branch from `main`. CI runs `quality`, `CodeQL` and `Dependency review`; all three are required. - `CodeQL (Kotlin)` (the Android companion's Kotlin, built under CodeQL) runs on every PR too, but is - not one of them. + `CodeQL (Kotlin)` (the Android companion's Kotlin, built under CodeQL, in + `.github/workflows/android.yml`) runs on a PR that changes an Android-relevant file, but is not one + of them. - Explain **why**, not just what. If a decision has a trade-off, name it and say what you rejected. - If you measured something, put the numbers in — they save the next person the same afternoon. - Say what you did **not** verify. That is more useful than a confident summary. diff --git a/android/README.md b/android/README.md index 9eb20443d0..c3307bf221 100644 --- a/android/README.md +++ b/android/README.md @@ -67,8 +67,11 @@ weekly Gradle update PRs for `android/`, the protocol build and the wrapper incl `tools/typecheck`, whose pins follow the app's by hand). The protocol's network and crypto libraries (okhttp, sshj, eddsa, BouncyCastle) come in a PR of their own, so an androidx bump the app cannot take yet (one that demands a higher `compileSdk`, say) does not hold them back. CodeQL analyses the app's -and the protocol module's Kotlin on every pull request (`.github/workflows/security.yml`, job -`CodeQL (Kotlin)`). `GradleCiCoverageTest` pins all of this. The wrapper properties carry no +and the protocol module's Kotlin (`.github/workflows/android.yml`, job `CodeQL (Kotlin)`) whenever +that workflow runs: on a branch push or a pull request that changes an Android-relevant file, on +`main` when such a change lands, and by hand (workflow_dispatch). A pull request that changes none is +compared with `main`'s analysis. There is no weekly re-scan of unchanged Kotlin, and the job is not a +required check. `GradleCiCoverageTest` pins all of this. The wrapper properties carry no `distributionSha256Sum` yet, so the Gradle distribution itself is not pinned. **Debug builds are signed with a public key that is committed on purpose** diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts index ec6d14529c..dfafb78d36 100644 --- a/android/protocol/build.gradle.kts +++ b/android/protocol/build.gradle.kts @@ -47,11 +47,12 @@ tasks.test { // Files some tests run or read that are not on the test classpath: the node drivers, the app's // terminal page (TerminalJs*Test), the app's sources (the source pins in TerminalKeyboardChipTest, // SettingsLeaveTest and R8RulesTest), the release build's R8 rules, its build type and the CI - // workflow that runs R8 (R8RulesTest), the CI config GradleCiCoverageTest checks: the workflows - // that run Gradle, the Dependabot config, the root build's settings and the build scripts whose - // dependencies it matches against the Dependabot groups (the app's is named above), and the - // contributor docs and the wrapper's Gradle version ContributorDocsTest checks against each other, - // and the audit whose finding ids DeviceChecklistDocsTest checks the device checklist against. + // workflow that runs R8 (R8RulesTest), the CI config GradleCiCoverageTest checks: that workflow + // (every job that runs Gradle is in it), the Dependabot config, the root build's settings and the + // build scripts whose dependencies it matches against the Dependabot groups (the app's is named + // above), and the contributor docs and the wrapper's Gradle version ContributorDocsTest checks + // against each other, and the audit whose finding ids DeviceChecklistDocsTest checks the device + // checklist against. // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) // The desktop code the interop fixture bundles (the same dirs as android.yml's path filter). Without @@ -66,7 +67,6 @@ tasks.test { inputs.file(rootDir.resolve("../app/proguard-rules.pro")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../app/build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../../.github/workflows/android.yml")).withPathSensitivity(PathSensitivity.RELATIVE) - inputs.file(rootDir.resolve("../../.github/workflows/security.yml")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../../.github/dependabot.yml")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../settings.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) inputs.file(rootDir.resolve("../build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt index 710f98b18f..7f5c07a7d5 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt @@ -16,7 +16,10 @@ import kotlin.test.assertTrue * the jobs share (android.yml says why). * 2. CodeQL analyses the Kotlin. A `java-kotlin` analysis with build mode `none` reads Java sources * only, and everything under android/ is Kotlin, so it would scan nothing while looking like - * coverage; the Kotlin has to be compiled between CodeQL's `init` and `analyze`. + * coverage; the Kotlin has to be compiled between CodeQL's `init` and `analyze`. The job is in + * android.yml, not security.yml: security.yml has no workflow_dispatch and runs on no feature + * branch, so a job there could first run on somebody's pull request into main (android.yml says why + * and what its path filter costs). * 3. Dependabot watches every Gradle build under android/ exactly once: a build another build * includes is covered by that build's entry (Dependabot follows `includeBuild`), and a second * entry would open each of its bumps twice. Except the builds in [notWatched]. @@ -29,11 +32,10 @@ import kotlin.test.assertTrue * does; what these readers do not understand fails rather than being guessed. */ class GradleCiCoverageTest { - private val workflows = listOf( - File(InteropHarness.repoRoot, ".github/workflows/android.yml"), - File(InteropHarness.repoRoot, ".github/workflows/security.yml"), - ) - private val securityWorkflow = File(InteropHarness.repoRoot, ".github/workflows/security.yml") + private val androidWorkflow = File(InteropHarness.repoRoot, ".github/workflows/android.yml") + + /** The workflows whose jobs run `./gradlew`: android.yml alone, the Kotlin CodeQL job included. */ + private val workflows = listOf(androidWorkflow) private val dependabot = File(InteropHarness.repoRoot, ".github/dependabot.yml") private val androidSettings = File(InteropHarness.repoRoot, "android/settings.gradle.kts") @@ -76,7 +78,7 @@ class GradleCiCoverageTest { ) } } - // android.yml's protocol, app and app-release jobs and security.yml's CodeQL (Kotlin) job. + // android.yml's protocol, app, app-release and CodeQL (Kotlin) jobs. assertTrue(gradleJobs >= 4, "found only $gradleJobs jobs running ./gradlew; has the reader stopped seeing them?") } @@ -101,10 +103,14 @@ class GradleCiCoverageTest { @Test fun `CodeQL compiles and analyses the app's and the protocol module's Kotlin`() { - val kotlinJobs = jobs(read(securityWorkflow)).filter { (_, job) -> + val kotlinJobs = jobs(read(androidWorkflow)).filter { (_, job) -> steps(job).any { isCodeqlInit(it) && "java-kotlin" in value(it, "languages").orEmpty() } } - assertEquals(1, kotlinJobs.size, "security.yml must have one CodeQL job for java-kotlin (audit A69)") + assertEquals( + 1, kotlinJobs.size, + "android.yml must have one CodeQL job for java-kotlin (audit A69); it lives there, where a feature " + + "branch can run it, not in security.yml" + ) val (id, job) = kotlinJobs.entries.single() val steps = steps(job) val init = steps.indexOfFirst(::isCodeqlInit) From 2af8e6f2532ca4aa52d36a9a411cd6d0f9a7f75c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:24:18 +0000 Subject: [PATCH 091/284] docs(android): handover TL;DR, next steps (batch D, batch E, A68 last) and follow-up prompt Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- docs/android-handover.md | 87 +++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/docs/android-handover.md b/docs/android-handover.md index 9ef496506b..8e0d7507be 100644 --- a/docs/android-handover.md +++ b/docs/android-handover.md @@ -8,17 +8,19 @@ findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](andr ## TL;DR -- The Android app exists (`android/`), and **CI built its debug APK successfully with AGP** (run - [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730), artifact - `nodeterm-android-debug`, which expires 2026-12-24). The protocol tests pass in CI too. -- **It has never been run on a phone.** A 170-agent audit found **9 distinct release blockers**. - All nine (WP1 + WP2) and every WP4 medium bug are fixed on the branch (A13 on the phone side - only); see the progress log below. Everything is unit/interop-tested where the layer allows and - type-checked, and CI builds the APK — but **nothing has run on a device**. Do not hand the APK to - anyone until the device pass (WP3) has run. -- The next session should run the **device test pass** (the checklist below), then the open items - in "What is still open", then WP5/WP6. No PR is open, and none should be opened unless the user - asks. +- The Android app exists (`android/`), and **CI builds it with AGP**: a debug APK and, since `A37`, + a minified release build that runs R8 (unsigned; no release key exists). The protocol tests pass in + CI too. +- **It has never been run on a phone.** Of the audit's 77 findings, all 9 release blockers (WP1 + + WP2), every WP4 medium bug and nearly all of WP6 are fixed on the branch; `A56` was deliberately + not built and `A50` is documented only. The WP5 parity gaps and a handful of follow-ups remain (see + "What is still open"). Everything is unit/interop-tested where the layer allows and type-checked, + but **nothing has run on a device**. Do not hand the APK to anyone until the device pass (WP3) has + run. +- The next session should run the **device checklist** in + [`android.md`](android.md#device-checklist) if a phone is available, then batch D (WP5), batch E + (follow-ups) and last `A68`, as listed under "What is still open". No PR is open, and none should be + opened unless the user asks. ## Progress log @@ -35,7 +37,7 @@ audit's proposal. | A67 | `696fd10` | `tsconfig.node.json` includes the interop fixture, so `npm run typecheck` checks it; `HostSessionOptions.pty` narrowed to `HostPtyManager`, removing the fixture's cast. | `npm run typecheck`; a guard test pins the include and bans the casts. | | A63 | `b3d4c6e`, `9d19bd1` | `android.yml` runs on every file the fixture bundles (`src/core/**`, `src/shared/**`, `src/main/*.ts`, `src/main/remote/**`, `package*.json`, `tsconfig.json`) and on the docs the tests read; the list is derived from esbuild's metafile. | `WorkflowPathFilterTest` re-derives it. | | A64 | `f9782da` | The relay `projects.list` blob comes from `buildProjectsListBlob` (one assembly for `index.ts` and the fixture); mirror entries from the real writer. Docs now say which contracts are still hand-copied. | vitest `projects-list-blob.test.ts`; a guard test refuses a hand-written blob. | -| A69 | `6f3a8da`, `4806b92` | `setup-gradle` (wrapper validation, a read-mostly cache) in every Gradle job; Dependabot `gradle` for `/android` with the network/crypto stack in its own group; a Kotlin CodeQL job built under CodeQL (`build-mode: manual`). The gradle-8.14.3 distribution checksum is not pinned yet (no network here). | `GradleCiCoverageTest`; the CodeQL job's first CI run is below. | +| A69 | `6f3a8da`, `4806b92`, `86d059d` | `setup-gradle` (wrapper validation, a read-mostly cache) in every Gradle job; Dependabot `gradle` for `/android` with the network/crypto stack in its own group; a Kotlin CodeQL job built under CodeQL (`build-mode: manual`), moved into `android.yml` by `86d059d` so a branch push runs it (`security.yml` has no `workflow_dispatch` and runs only on `main`/PRs/the queue). The gradle-8.14.3 distribution checksum is not pinned yet (no network here). | `GradleCiCoverageTest`. | | A61 / A71 | `39e7995`, `9d19bd1` | Docs: an existing `npm install` is enough for the interop tests; `npm ci --ignore-scripts` is CI's and replaces `node_modules`. JDK 17–24 (Gradle 8.14.3 cannot start on 25). | `ContributorDocsTest`. | | A65 / A50 | `33af5e7`, `d092639` | `docs/android.md` gets the numbered device checklist; the READMEs say the debug APK is debuggable, and that a phone exposed over adb needs a new identity (uninstall or clear storage) before re-pairing, not just re-pairing. A signed non-debuggable release is still open (`A50`). | `DeviceChecklistDocsTest`. | | A66 | `61e218b` | The desktop's Android link reads "nodeterm for Android (build from source)" and is derived from `REPO_URL`. The link resolves once `android/` is on upstream `main`. | vitest `androidAppLink.test.tsx`. | @@ -98,6 +100,36 @@ are listed with it. ### What is still open +**Next work, in order** (item lists were written for this session's workflows; re-read each audit +section before starting, since the verifier corrections take precedence): + +1. **Batch D — WP5 parity features.** + - `A26`: while connected over direct SSH, open the relay leg on demand for the relay-only verbs + (register node, board writes, `node.*`, git), reusing HostSession's side relay and the + `RelayApprovalGate` (a user action is `USER`; a background path never makes a first handshake); + show New session and board controls disabled with the reason when there is no relay leg. + - `A29`: a Source Control screen over `conn.git(...)` (relay, gated on the git capability), with a + relay interop test through the desktop's real git bridge. + - `A32`: port the desktop's wrapped-row URL link provider into terminal.js (not addon-web-links), an + OSC 8 `linkHandler`, `openUrl` for http(s) only, and a "Copy lines" sheet from `term.buffer`. + - `A25` (in-app part): Approve/Deny and option actions on notifications (never a first relay + handshake from an action), and a tap that opens the node's terminal. FCM stays a backend gap. + - `A55`: a needs-you count per computer, and a merged "All computers" Inbox + Usage screen. + - `A59`: a SpeechRecognizer mic in the input bar that fills the draft and never submits. + - `A27`: (a) direct-SSH browse of a Server Edition host (`~/.nodeterm-server`, both tmux sockets + tracked per session, the per-project status slices); (b) a manual "Add SSH server" flow (show the + phone's public key; TOFU-after-auth pin; SSH only). +2. **Batch E — follow-ups.** `node.sendKeys` for SSH-project nodes over their ControlMaster (`A12`); + a device revoke cuts its live relay session (`A07`); late relay adoption pins the box key (`A07`, + probably a new SSH-visible file, so iOS and the fixture are owed); the SSH host keys in the sealed + `/pair` answer anchor the pin (`A49`); a relay-authenticated refresh of the LAN address (`A74`); + the notification seen log keyed per computer. +3. **`A68` last.** `push: branches: [main]` + `pull_request`, no `merge_group` (see the verifier). + After it, pushes to this branch no longer run the Android workflow until a PR exists, which is why + it waits until everything else is verified. + +**Known gaps and caveats:** + - **A13 for direct-SSH phones.** The desktop fix covers phones attached through the relay. A phone attached over direct SSH (Android or iOS) is still detached by the app's `-D`, because its tmux client is not spawned by this process; the Android exit-0 reattach (`68d0925`) covers it. @@ -345,20 +377,19 @@ Paste this into the next session. ```text Continue the Android companion work on branch claude/android-ios-parity-75kfem of CPlusPlus17/nodeterm. -Start by reading docs/android-handover.md (progress log, what is still open, environment notes, -conventions) and docs/android-audit-2026-09.md (fixed findings are marked in the index). WP1, WP2 -and WP4 are fixed on the branch but nothing has run on a device. - -1. If a phone is available: run the device checklist in the handover (WP3) with the latest CI APK, - record results in docs/android.md → "What is verified", and turn every failure into a finding. -2. The desktop root cause of A13 (a renderer client attaching with -D over a live relay-served - client), with tests in pty-manager. -3. WP5 parity gaps and WP6 low-severity items, starting with A37 (R8 rules), A52 (lock-screen - content), A53/A54 (size caps), A63/A68 (CI triggers and path filters). - -Before each push run the protocol tests (cd android/protocol && gradle test --offline, after npm ci ---ignore-scripts at the repo root), the offline type-check (cd android/tools/typecheck && gradle -compileKotlin), and for desktop changes the affected vitest files plus npm run typecheck; after each -push confirm the Android workflow is green. Keep docs/android.md, android/README.md, the handover and -the audit index in sync. Do not open a PR unless asked. +Start by reading docs/android-handover.md (progress log, "What is still open", environment notes, +conventions) and docs/android-audit-2026-09.md (fixed findings are marked in the index). WP1, WP2, +WP4 and nearly all of WP6 are fixed on the branch, but nothing has run on a device. + +1. If a phone is available: run the device checklist in docs/android.md (#device-checklist) with the + latest CI APK, record results in docs/android.md → "What is verified", and turn every failure into + a finding. +2. Batch D (WP5 parity: A26, A29, A32, A25, A55, A59, A27), then batch E (the follow-ups listed under + "What is still open"), then A68 last. + +Before each push run the protocol tests (cd android/protocol && gradle test --offline; an existing +npm install at the repo root is enough), the offline type-check (cd android/tools/typecheck && gradle +compileKotlin --offline), and for desktop changes the affected vitest files plus npm run typecheck; +after each push confirm the Android workflow is green. Keep docs/android.md, android/README.md, the +handover and the audit index in sync. Do not open a PR unless asked. ``` From 9cdc4cfa232e7c6bb5f28d5282da2a0b38537eca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:20:31 +0000 Subject: [PATCH 092/284] fix(android): open the relay leg next to SSH for the app's own verbs (A26) On the LAN connection Auto picks first (direct SSH), New session was hidden, the board was read-only and wake/refresh/rename were missing, although the same computer's relay leg could do them; the SSH refusal text also told users to turn on remote access when it was already on. What changed: - android/protocol: new LegRouting (pure). Capability names each verb group; LegRouting.route(cap, primaryKind, primaryCaps, relayLeg) answers Primary, Relay (open the side relay) or Unavailable(reason). RelayLeg distinguishes "no relay leg" from "route set to Only on my network", so each reason names the right fix. RelayHostConnection takes RELAY_CAPABILITIES from it, and SshHostConnection's relay-only verbs now say they go through the relay instead of "turn on remote access". - HostSession: route(cap), relayLeg(), connectionFor(cap, trigger = USER) (primary when it can, else viaRelay, else HostException with the reason). viaRelay now takes the caller's trigger through RelayApprovalGate (and its requireApproved), so a background path never makes a first handshake, and publishes the approval code (relayApproval) that the host screen shows. - UI: New session FAB, board card actions/labels and the session menu's wake/refresh/rename decide from session.route and write through connectionFor; unavailable ones are shown disabled with the reason instead of hidden. A phone-started session attaches (and registers) through connectionFor(REGISTER_NODE). - docs/android.md (relay-leg paragraph, mapping table, Known gaps, device checklist item 52) and android/README.md updated. Verified: LegRoutingTest (routing matrix, reasons, app-source pins) and a new SshTransportTest case over real MINA sshd + tmux (routing from the real SSH capabilities; every relay-only verb's refusal text). Mutation checks: making AVAILABLE route to Unavailable fails 3 tests; restoring the old refusal text fails the SSH test; restoring ensureConnected() for a board move fails the pin. Full protocol suite 352/352, offline app type-check passes. Departures from the audit: git is routed too but the app has no git UI yet; node.kill and sendKeys stay on SSH (SSH already does them for local nodes, and SSH-project nodes keep the A09 NeedsRelay path). The relay interop tests are unchanged because the relay verb path is unchanged. No SSH write fallback (the verifiers' alternative) was built. Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 6 +- .../android/conn/ConnectionManager.kt | 79 +++++++++-- .../dev/nodeterm/android/ui/BoardTab.kt | 39 +++-- .../dev/nodeterm/android/ui/HostScreen.kt | 87 ++++++++---- .../dev/nodeterm/android/ui/SessionsTab.kt | 43 +++--- .../nodeterm/android/ui/TerminalController.kt | 19 ++- .../dev/nodeterm/protocol/host/LegRouting.kt | 111 +++++++++++++++ .../protocol/host/RelayHostConnection.kt | 4 +- .../protocol/ssh/SshHostConnection.kt | 5 +- .../dev/nodeterm/protocol/LegRoutingTest.kt | 134 ++++++++++++++++++ .../dev/nodeterm/protocol/SshTransportTest.kt | 34 +++++ docs/android.md | 42 +++++- 12 files changed, 512 insertions(+), 91 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/LegRouting.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegRoutingTest.kt diff --git a/android/README.md b/android/README.md index c3307bf221..7fe24fcd1c 100644 --- a/android/README.md +++ b/android/README.md @@ -29,9 +29,9 @@ written for it and tested where the layer allows, and the numbered | Sleeping (Eco) session opened | ✓ | Through the relay the computer wakes it on open. Over SSH the phone offers the desktop's wake line (`--resume `, + permission mode for Claude only), while a shell owns the pane, typed only on a tap | | Cold-start resume offer (the computer rebooted) | relay | Offers the agent's own `--resume `; never types it unasked. Over SSH a session that is not running is never created (it would lack its hook environment): the phone offers to open it through the relay | | Sessions of the computer's SSH projects | relay | They run on another host; the computer attaches them over its SSH connection. Over direct SSH the phone offers the relay instead | -| New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches | -| Wake / refresh / rename / end session | relay (end: both) | `node.*` verbs; over SSH "end" stops the tmux session only | -| Kanban board, move cards, labels | relay (read-only on SSH) | `projects.ensureBoard/setCardColumn/editCardLabels` | +| New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches. On your network the phone opens the relay leg next to SSH for it; with no relay leg (remote access off) the button is disabled and says why | +| Wake / refresh / rename / end session | relay (end: both) | `node.*` verbs, through the relay leg next to SSH when on your network; over SSH "end" stops the tmux session only | +| Kanban board, move cards, labels | relay (reads: both) | `projects.ensureBoard/setCardColumn/editCardLabels`; on your network the writes go through the relay leg next to SSH, and are disabled with the reason when there is none | | Inbox: approvals, questions, finished turns | ✓ | Held hook approvals are answered deterministically (never with keystrokes); a multi-select question lists its options but is answered in the session; each card shows its node's context % when known | | Read-ack (reading a finished session clears it on the computer) | ✓ | `inbox.ack` over the relay, `~/.nodeterm/acks` over SSH | | Usage (rate limits per account) | ✓ | From the agent-status mirror, with a pace line ("5h usage pace faster") when the reset time is known | diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index 1a8d220319..8ac2c4bd45 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -3,8 +3,10 @@ package dev.nodeterm.android.conn import dev.nodeterm.android.AppGraph import dev.nodeterm.android.data.RoutePreference import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.protocol.host.Capability import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.LegRouting import dev.nodeterm.protocol.host.RelayApprovalGate import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger import dev.nodeterm.protocol.host.RelayConnectStatus @@ -205,24 +207,66 @@ class HostSession(val hostId: String, private val graph: AppGraph) { private val sideMutex = Mutex() /** This computer can be reached through the relay at all (a relay leg and its device token). */ - val hasRelay: Boolean - get() { - val host = graph.hosts.get(hostId) ?: return false - return host.relay != null && host.relayHostKeyB64 != null && - graph.secure.getString(SecureStore.relayTokenKey(host.id)) != null && - graph.hosts.route(hostId) != RoutePreference.SSH_ONLY + val hasRelay: Boolean get() = relayLeg() == LegRouting.RelayLeg.AVAILABLE + + /** + * Whether the relay leg can be opened next to the primary connection. Read fresh each time: a + * late adoption ([adoptRelayIfAdvertised]) mints the token while connected over SSH. + */ + fun relayLeg(): LegRouting.RelayLeg { + val host = graph.hosts.get(hostId) ?: return LegRouting.RelayLeg.NOT_SET_UP + return LegRouting.relayLeg( + relayConfigured = relayConfigured(host), + sshOnlyRoute = graph.hosts.route(hostId) == RoutePreference.SSH_ONLY + ) + } + + /** Which leg answers [cap] right now ([LegRouting.route]): the one decision the UI and [connectionFor] share. */ + fun route(cap: Capability): LegRouting.Leg { + val c = conn + return LegRouting.route(cap, c?.kind, c?.capabilities, relayLeg()) + } + + /** + * A connection that can do [cap] (audit A26): the primary one when it can, else the relay leg + * opened next to it. For a USER's action — the relay dial goes through [RelayApprovalGate] with + * [trigger], so a background caller never makes a first relay handshake. Throws a + * [HostException] carrying the reason when neither leg can. + */ + suspend fun connectionFor( + cap: Capability, + trigger: Trigger = Trigger.USER, + onStatus: (RelayConnectStatus) -> Unit = {} + ): HostConnection { + val primary = ensureConnected(trigger) + return when (val leg = route(cap)) { + LegRouting.Leg.Primary -> primary + LegRouting.Leg.Relay -> viaRelay(trigger, onStatus) + is LegRouting.Leg.Unavailable -> throw HostException(leg.reason) } + } + + private val _relayApproval = MutableStateFlow(null) + + /** + * The approval code while the relay leg next to an SSH connection waits for the desktop's + * dialog (its first dial on a desktop that has not pinned this phone): the host screen shows it, + * since the board or New session that asked has no screen of its own for it. + */ + val relayApproval: StateFlow = _relayApproval.asStateFlow() /** * A relay connection for one action the direct-SSH transport refuses * ([dev.nodeterm.protocol.host.NeedsRelayException]): a session that is not running (creating it - * over SSH would leave it without its hook environment — audit A08), or a node of the desktop's - * SSH projects, which the desktop reaches on its host (audit A09). The primary connection when - * it already IS the relay; otherwise one opened next to it and kept until [disconnect]. The user - * asked (they tapped "Open through the relay"), so a held approval is released and, the first - * time, the desktop's approval code is reported through [onStatus]. + * over SSH would leave it without its hook environment — audit A08), a node of the desktop's + * SSH projects, which the desktop reaches on its host (audit A09), or a verb only nodeterm the + * app serves (board writes, a new session, node actions, git — audit A26, via [connectionFor]). + * The primary connection when it already IS the relay; otherwise one opened next to it and kept + * until [disconnect]. [trigger] goes to [RelayApprovalGate]: a user's tap releases a held + * approval and, the first time, the desktop's approval code is reported through [onStatus] (and + * [relayApproval]); a background caller never makes a first handshake. */ - suspend fun viaRelay(onStatus: (RelayConnectStatus) -> Unit = {}): HostConnection { + suspend fun viaRelay(trigger: Trigger = Trigger.USER, onStatus: (RelayConnectStatus) -> Unit = {}): HostConnection { conn?.takeIf { it.kind == TransportKind.RELAY }?.let { return it } sideRelay?.let { return it } return sideMutex.withLock { @@ -234,17 +278,22 @@ class HostSession(val hostId: String, private val graph: AppGraph) { if (relay == null || token == null || hostKey == null || graph.hosts.route(hostId) == RoutePreference.SSH_ONLY) { throw HostException("Remote access isn't set up for this computer. Open the session in nodeterm on the computer instead.") } - when (val d = graph.relayGate.decide(hostId, Trigger.USER)) { + val requireApproved = when (val d = graph.relayGate.decide(hostId, trigger)) { is RelayApprovalGate.Decision.Skip -> throw HostException(d.reason) - is RelayApprovalGate.Decision.Dial -> Unit + is RelayApprovalGate.Decision.Dial -> d.requireApproved } val connected = try { - dialRelay(relay, token, hostKey, requireApproved = false, onStatus = onStatus) + dialRelay(relay, token, hostKey, requireApproved = requireApproved) { st -> + _relayApproval.value = (st as? RelayConnectStatus.AwaitingApproval)?.sas + onStatus(st) + } } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { graph.relayGate.onFailed(hostId, e) throw e + } finally { + _relayApproval.value = null } graph.relayGate.onConnected(hostId) val c = connected.connection diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt index ad8b3a2380..b3da753de8 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt @@ -43,6 +43,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.LegRouting import dev.nodeterm.android.Navigator import dev.nodeterm.android.Route import dev.nodeterm.android.conn.HostSession @@ -90,13 +92,20 @@ fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro var projectMenu by remember { mutableStateOf(false) } var moving by remember { mutableStateOf(null) } var labeling by remember { mutableStateOf(null) } - val writable = session.connection?.capabilities?.boardWrites == true + // Board writes are nodeterm the app's (the relay's `projects.*` verbs): on the LAN that is the + // relay leg opened next to the SSH connection, on a tap (audit A26). Where this phone has no + // relay leg the controls stay, disabled, and say why. + val boardRoute = session.route(Capability.BOARD_WRITES) + val readOnlyReason = (boardRoute as? LegRouting.Leg.Unavailable)?.reason if (project == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No projects yet.") } return } + /** The leg that writes the board: the open connection, or the relay leg next to SSH (a tap). */ + suspend fun boardConnection() = session.connectionFor(Capability.BOARD_WRITES) + fun write(label: String, block: suspend () -> Unit) { scope.launch { try { @@ -126,9 +135,9 @@ fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro } } Spacer(Modifier.width(8.dp)) - if (!writable) { + if (readOnlyReason != null) { Text( - "Read-only here — board edits need the relay connection.", + "Read-only here. $readOnlyReason", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -173,7 +182,9 @@ fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro onClick = { if (card.kind == NodeKind.TERMINAL) nav.push(Route.Terminal(hostId, card.id, displayTitle(card, snapshot))) }, - onLongClick = { if (writable) moving = card } + // Long-press opens the card's actions even when they cannot run, + // so the disabled controls can say why (audit A26). + onLongClick = { moving = card } ) } } @@ -188,19 +199,23 @@ fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro title = { Text(displayTitle(card, snapshot)) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { + readOnlyReason?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + val writable = readOnlyReason == null Text("Move to", style = MaterialTheme.typography.labelLarge) val cols = project.board?.columns?.map { it.id to it.title } - TextButton(onClick = { + TextButton(enabled = writable, onClick = { moving = null - write("Move") { session.ensureConnected().setCardColumn(project.id, card.id, null) } + write("Move") { boardConnection().setCardColumn(project.id, card.id, null) } }) { Text("Ungrouped") } if (cols == null) { // No board yet: ask the host to seed the default one, then move into its column. dev.nodeterm.protocol.model.KanbanBoard.DEFAULT_COLUMN_TITLES.forEachIndexed { i, t -> - TextButton(onClick = { + TextButton(enabled = writable, onClick = { moving = null write("Move") { - val conn = session.ensureConnected() + val conn = boardConnection() val seeded = conn.ensureBoard(project.id) ?: error("this project can't have a board written") val target = seeded.firstOrNull { it.title == t } ?: seeded.getOrNull(i) ?: error("no such column") conn.setCardColumn(project.id, card.id, target.id) @@ -209,13 +224,13 @@ fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro } } else { cols.forEach { (id, t) -> - TextButton(onClick = { + TextButton(enabled = writable, onClick = { moving = null - write("Move") { session.ensureConnected().setCardColumn(project.id, card.id, id) } + write("Move") { boardConnection().setCardColumn(project.id, card.id, id) } }) { Text(t) } } } - TextButton(onClick = { + TextButton(enabled = writable, onClick = { labeling = card moving = null }) { Text("Labels…") } @@ -232,7 +247,7 @@ fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro onDismiss = { labeling = null }, onApply = { edit -> labeling = null - write("Labels") { session.ensureConnected().editCardLabels(project.id, card.id, edit) } + write("Labels") { boardConnection().editCardLabels(project.id, card.id, edit) } } ) } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index d2282bc852..a38f5ea1f2 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -45,6 +46,8 @@ import dev.nodeterm.android.Navigator import dev.nodeterm.android.NodetermApp import dev.nodeterm.android.conn.ConnState import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.LegRouting import dev.nodeterm.protocol.host.TransportKind import dev.nodeterm.protocol.model.NewSessionChoice @@ -77,8 +80,12 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { } val needsYou = snapshot.status?.inbox?.events?.count { it.actionable } ?: 0 - val canCreate = (state as? ConnState.Connected)?.kind == TransportKind.RELAY && - NewSessionChoice.offeredProjects(snapshot).isNotEmpty() + // New session goes through nodeterm the app (the relay's `projects.registerNode`): on the LAN + // (direct SSH) that is the relay leg opened next to it, and where this phone has none the button + // stays, disabled, with the reason (audit A26) — it used to vanish without a word. + val offersNew = state is ConnState.Connected && NewSessionChoice.offeredProjects(snapshot).isNotEmpty() + val newRoute = session.route(Capability.REGISTER_NODE) + val relayApproval by session.relayApproval.collectAsState() Scaffold( topBar = { @@ -104,17 +111,36 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { ) }, floatingActionButton = { - if (tab == 0 && canCreate) { - ExtendedFloatingActionButton( - onClick = { newSession = true }, - icon = { Icon(Icons.Filled.Add, null) }, - text = { Text("New session") } - ) + if (tab == 0 && offersNew) { + val blocked = newRoute as? LegRouting.Leg.Unavailable + if (blocked == null) { + ExtendedFloatingActionButton( + onClick = { newSession = true }, + icon = { Icon(Icons.Filled.Add, null) }, + text = { Text("New session") } + ) + } else { + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + blocked.reason, + Modifier.widthIn(max = 300.dp).background(NtColors.panel2).padding(10.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button(onClick = {}, enabled = false) { + Icon(Icons.Filled.Add, null) + Text("New session") + } + } + } } } ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { ConnectionBanner(state, session) + // The relay leg a board edit or New session opened next to the SSH connection is waiting + // for the computer's approval dialog (its first dial on a desktop that has not pinned us). + relayApproval?.let { if (state !is ConnState.AwaitingApproval) ApprovalCode(it) } // Connected through the relay because the server at the paired address presented a // different SSH host key (audit A49/A74): the relay proves the computer, but the change // itself must stay visible. @@ -172,26 +198,7 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { @Composable private fun ConnectionBanner(state: ConnState, session: HostSession) { when (state) { - is ConnState.AwaitingApproval -> Column( - Modifier.fillMaxWidth().background(NtColors.panel2).padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text("Approve this phone on your computer", fontWeight = FontWeight.SemiBold) - Text( - state.sas, - fontSize = 34.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = NtColors.accent - ) - Text( - "nodeterm on your computer is showing a code. Approve only if it matches this one — a different code " + - "means someone is in the middle. You only do this once per phone.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + is ConnState.AwaitingApproval -> ApprovalCode(state.sas) is ConnState.Failed -> Column( Modifier.fillMaxWidth().background(NtColors.attention.copy(alpha = 0.12f)).padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp) @@ -202,3 +209,27 @@ private fun ConnectionBanner(state: ConnState, session: HostSession) { else -> Unit } } + +@Composable +private fun ApprovalCode(sas: String) { + Column( + Modifier.fillMaxWidth().background(NtColors.panel2).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text("Approve this phone on your computer", fontWeight = FontWeight.SemiBold) + Text( + sas, + fontSize = 34.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = NtColors.accent + ) + Text( + "nodeterm on your computer is showing a code. Approve only if it matches this one — a different code " + + "means someone is in the middle. You only do this once per phone.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt index 84f5e41b49..b79c85ff0d 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog @@ -38,6 +39,8 @@ import androidx.compose.ui.unit.dp import dev.nodeterm.android.Navigator import dev.nodeterm.android.Route import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.LegRouting import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TransportKind @@ -149,27 +152,36 @@ fun SessionsTab(nav: Navigator, hostId: String, session: HostSession, snapshot: onLongClick = { menuFor = node } ) DropdownMenu(expanded = menuFor?.id == node.id, onDismissRequest = { menuFor = null }) { - val conn = session.connection DropdownMenuItem(text = { Text("Open") }, onClick = { menuFor = null nav.push(Route.Terminal(hostId, node.id, displayTitle(node, snapshot))) }) - if (conn?.capabilities?.nodeActions == true) { - if (snapshot.statusOf(node.id)?.hibernated == true) { - DropdownMenuItem(text = { Text("Wake") }, onClick = { - menuFor = null - act("Wake") { conn.wake(node.id) } - }) - } - DropdownMenuItem(text = { Text("Refresh view on computer") }, onClick = { + // Wake/refresh/rename are nodeterm the app's (`node.*`): on the LAN that is + // the relay leg opened next to SSH, on a tap. Where this phone has none they + // stay listed, disabled, with the reason (audit A26). + val blocked = session.route(Capability.NODE_ACTIONS) as? LegRouting.Leg.Unavailable + if (snapshot.statusOf(node.id)?.hibernated == true) { + DropdownMenuItem(text = { Text("Wake") }, enabled = blocked == null, onClick = { menuFor = null - act("Refresh") { conn.refresh(node.id) } - }) - DropdownMenuItem(text = { Text("Rename…") }, onClick = { - menuFor = null - renaming = node + act("Wake") { session.connectionFor(Capability.NODE_ACTIONS).wake(node.id) } }) } + DropdownMenuItem(text = { Text("Refresh view on computer") }, enabled = blocked == null, onClick = { + menuFor = null + act("Refresh") { session.connectionFor(Capability.NODE_ACTIONS).refresh(node.id) } + }) + DropdownMenuItem(text = { Text("Rename…") }, enabled = blocked == null, onClick = { + menuFor = null + renaming = node + }) + if (blocked != null) { + Text( + blocked.reason, + Modifier.widthIn(max = 280.dp).padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } DropdownMenuItem(text = { Text("End session…", color = NtColors.attention) }, onClick = { menuFor = null ending = node @@ -190,8 +202,7 @@ fun SessionsTab(nav: Navigator, hostId: String, session: HostSession, snapshot: confirmButton = { TextButton(enabled = title.isNotBlank(), onClick = { renaming = null - val conn = session.connection ?: return@TextButton - act("Rename") { conn.rename(node.id, title.trim()) } + act("Rename") { session.connectionFor(Capability.NODE_ACTIONS).rename(node.id, title.trim()) } }) { Text("Rename") } }, dismissButton = { TextButton(onClick = { renaming = null }) { Text("Cancel") } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 1dec3297d7..0c0e3f657e 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import dev.nodeterm.android.AppGraph import dev.nodeterm.android.conn.HostSession import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.Capability import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.RelayConnectStatus import dev.nodeterm.protocol.host.RendererRecovery @@ -550,13 +551,17 @@ class TerminalController( attachJob = graph.scope.launch { val job = coroutineContext[Job] try { - val conn = if (useRelay) { - session.viaRelay { st -> - // A superseded attach's dial must not put its code over the current screen. - if (st is RelayConnectStatus.AwaitingApproval) main.post { if (slot.isCurrent(ticket)) state = TermState.AwaitingApproval(st.sas) } - } - } else { - session.ensureConnected() + val onStatus: (RelayConnectStatus) -> Unit = { st -> + // A superseded attach's dial must not put its code over the current screen. + if (st is RelayConnectStatus.AwaitingApproval) main.post { if (slot.isCurrent(ticket)) state = TermState.AwaitingApproval(st.sas) } + } + val conn = when { + useRelay -> session.viaRelay(onStatus = onStatus) + // A session this phone starts is created and registered on the canvas by nodeterm + // the app: over direct SSH that is the relay leg, opened next to it (audit A26). + // The user tapped New session, so this may make the relay's first handshake. + PendingLaunches.peek(nodeId) != null -> session.connectionFor(Capability.REGISTER_NODE, onStatus = onStatus) + else -> session.ensureConnected() } val c = if (cols > 0) cols else 80 val r = if (rows > 0) rows else 24 diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/LegRouting.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/LegRouting.kt new file mode 100644 index 0000000000..d9c1005b8a --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/LegRouting.kt @@ -0,0 +1,111 @@ +package dev.nodeterm.protocol.host + +/** + * One thing a connection can do beyond browse + attach, as [HostCapabilities] names it. The verbs + * under each are the host-service ones the relay serves. + */ +enum class Capability(val what: String) { + /** `projects.ensureBoard`, `projects.setCardColumn`, `projects.editCardLabels`. */ + BOARD_WRITES("Editing the board"), + /** `git.*`. */ + GIT("Source control"), + /** `node.wake`, `node.refresh`, `node.rename`. */ + NODE_ACTIONS("Waking, refreshing and renaming a session"), + /** `projects.registerNode` (a session the phone starts, put on the canvas). */ + REGISTER_NODE("Starting a new session"), + /** `approvals.answer` / the `.answer` file. */ + ANSWER_APPROVALS("Answering an approval"); + + fun of(c: HostCapabilities): Boolean = when (this) { + BOARD_WRITES -> c.boardWrites + GIT -> c.git + NODE_ACTIONS -> c.nodeActions + REGISTER_NODE -> c.registerNode + ANSWER_APPROVALS -> c.answerApprovals + } +} + +/** + * Which leg of a paired computer answers a verb (audit A26), decided in ONE place so the UI and the + * call agree. The direct-SSH leg (the LAN one `Auto` picks first) is POSIX sh + tmux on the machine; + * what needs nodeterm THE APP — board writes, registering a new session, node actions, git — is the + * relay's. Before this, a phone on SSH simply had none of it (the New-session button vanished, the + * board was read-only), although the same computer's relay leg was one tap away. + * + * The rule: the primary connection when it can; else the relay leg opened NEXT to it on demand, when + * this phone holds one; else unavailable, with the reason the UI shows on a disabled control. + * + * The relay is opened only for a user's action (a tap): [dev.nodeterm.protocol.host.RelayApprovalGate] + * still decides, so a background path never makes a first relay handshake. + */ +object LegRouting { + /** What the relay leg serves: everything host-service has a verb for. */ + val RELAY_CAPABILITIES = HostCapabilities( + boardWrites = true, git = true, nodeActions = true, registerNode = true, answerApprovals = true + ) + + /** Whether this phone can reach the computer's relay leg next to its primary connection. */ + enum class RelayLeg { + /** A relay block, its host key and a stored device token, and the route allows the relay. */ + AVAILABLE, + /** The phone holds no relay leg: remote access was off at pairing and has not been adopted since. */ + NOT_SET_UP, + /** The phone has one, but the user set this computer to "Only on my network". */ + ROUTE_SSH_ONLY + } + + sealed interface Leg { + /** The connection already open does it. */ + data object Primary : Leg + /** Open (or reuse) the relay connection held next to the primary one. */ + data object Relay : Leg + /** Neither leg can; [reason] is what a disabled control says. */ + data class Unavailable(val reason: String) : Leg + } + + /** Facts the relay leg's availability comes from. Pure, so the app and its tests agree. */ + fun relayLeg(relayConfigured: Boolean, sshOnlyRoute: Boolean): RelayLeg = when { + !relayConfigured -> RelayLeg.NOT_SET_UP + sshOnlyRoute -> RelayLeg.ROUTE_SSH_ONLY + else -> RelayLeg.AVAILABLE + } + + /** + * Where [cap] goes. [primary] is the open connection's transport (null: not connected yet), with + * its [primaryCaps]. + */ + fun route(cap: Capability, primary: TransportKind?, primaryCaps: HostCapabilities?, relay: RelayLeg): Leg { + if (primary != null && primaryCaps != null && cap.of(primaryCaps)) return Leg.Primary + if (primary == TransportKind.RELAY) { + // The relay itself refused: nothing else to open. + return Leg.Unavailable("${cap.what} isn't available on this computer.") + } + return when (relay) { + RelayLeg.AVAILABLE -> Leg.Relay + RelayLeg.NOT_SET_UP -> Leg.Unavailable(notSetUp(cap)) + RelayLeg.ROUTE_SSH_ONLY -> Leg.Unavailable(sshOnly(cap)) + } + } + + /** What each capability can reach right now, for a screen deciding several controls at once. */ + fun reach(primary: TransportKind?, primaryCaps: HostCapabilities?, relay: RelayLeg): Map = + Capability.entries.associateWith { route(it, primary, primaryCaps, relay) } + + private fun notSetUp(cap: Capability) = + "${cap.what} goes through nodeterm on the computer, which this phone reaches through the relay, and " + + "this phone has no relay connection to it yet. Turn on remote access in nodeterm → Settings → Phone; " + + "the phone picks it up the next time it connects on your network." + + private fun sshOnly(cap: Capability) = + "${cap.what} goes through the relay, and this computer is set to \"Only on my network (SSH)\". Choose " + + "\"Automatic\" or \"Only through the relay\" in Settings → How to reach each computer." + + /** + * What the direct-SSH transport says when a relay verb is called on it anyway. The app routes + * those to the relay ([route]), so this is the message of a caller that skipped the routing — and + * it must not tell a user whose remote access is ON to turn it on (the phone merely picked SSH). + */ + fun sshRefusal(what: String) = + "$what isn't done over your network: it goes through nodeterm on the computer, which the phone " + + "reaches through the relay." +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt index 276b0bb364..28cf674989 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt @@ -54,9 +54,7 @@ class RelayHostConnection private constructor() : HostConnection, RelaySocketLis @Volatile private var readyListener: ((String) -> Unit)? = null override val kind = TransportKind.RELAY - override val capabilities = HostCapabilities( - boardWrites = true, git = true, nodeActions = true, registerNode = true, answerApprovals = true - ) + override val capabilities = LegRouting.RELAY_CAPABILITIES private inner class Stream(val id: Long, override val fresh: Boolean, val sink: TerminalSink) : TerminalStream { private val snapshot = SnapshotReassembler() diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt index 9a0e22be26..b57c8fb09d 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -7,6 +7,7 @@ import dev.nodeterm.protocol.host.HostCapabilities import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.HostException import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.LegRouting import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.NewNode import dev.nodeterm.protocol.host.NewSessionHint @@ -409,6 +410,8 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos runCatching { client.socket?.close() } } + // The app routes these to the relay leg itself ([LegRouting.route], audit A26); a caller that + // reaches them here skipped that routing, and is told where they go. override suspend fun wake(nodeId: String) { relayOnly("Waking a sleeping session") } override suspend fun refresh(nodeId: String) { relayOnly("Refreshing a terminal view") } override suspend fun rename(nodeId: String, title: String) { relayOnly("Renaming a session") } @@ -419,7 +422,7 @@ class SshHostConnection private constructor(private val client: SSHClient) : Hos override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = relayOnly("Source control") private fun relayOnly(what: String): Nothing = - throw HostException("$what needs the relay connection (turn on remote access in nodeterm → Settings → Phone).") + throw HostException(LegRouting.sshRefusal(what)) override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome = withContext(Dispatchers.IO) { refuseRemoteNode(event.nodeId) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegRoutingTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegRoutingTest.kt new file mode 100644 index 0000000000..42e267e93a --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegRoutingTest.kt @@ -0,0 +1,134 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.LegRouting.Leg +import dev.nodeterm.protocol.host.LegRouting.RelayLeg +import dev.nodeterm.protocol.host.TransportKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Audit A26: on the LAN connection `Auto` picks first, New session, board edits and node actions + * were simply unavailable (the button hidden, the board read-only), although the same computer's + * relay leg could do them. One pure decision says which leg answers each verb. + */ +class LegRoutingTest { + /** What the direct-SSH transport serves (SshHostConnection.capabilities). */ + private val ssh = HostCapabilities( + boardWrites = false, git = false, nodeActions = false, registerNode = false, answerApprovals = true + ) + private val appVerbs = listOf(Capability.BOARD_WRITES, Capability.REGISTER_NODE, Capability.NODE_ACTIONS, Capability.GIT) + + @Test + fun `on SSH the app's own verbs open the relay leg next to it`() { + for (cap in appVerbs) { + assertEquals(Leg.Relay, LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.AVAILABLE), "$cap") + } + } + + @Test + fun `what SSH does itself never opens the relay`() { + assertEquals(Leg.Primary, LegRouting.route(Capability.ANSWER_APPROVALS, TransportKind.SSH, ssh, RelayLeg.AVAILABLE)) + } + + @Test + fun `on the relay everything stays on the relay connection`() { + for (cap in Capability.entries) { + assertEquals( + Leg.Primary, + LegRouting.route(cap, TransportKind.RELAY, LegRouting.RELAY_CAPABILITIES, RelayLeg.NOT_SET_UP), + "$cap" + ) + } + } + + @Test + fun `with no relay leg the verb is unavailable with the reason, and that reason is not 'turn it on' when it is on`() { + for (cap in appVerbs) { + val notSetUp = assertIs(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.NOT_SET_UP)) + assertTrue(notSetUp.reason.startsWith(cap.what), notSetUp.reason) + assertTrue(notSetUp.reason.contains("Turn on remote access"), notSetUp.reason) + + // Remote access is on (the phone holds a relay leg); the user chose SSH only. Sending + // them to turn remote access on would be the A26 error text again. + val sshOnly = assertIs(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.ROUTE_SSH_ONLY)) + assertFalse(sshOnly.reason.contains("remote access", ignoreCase = true), sshOnly.reason) + assertTrue(sshOnly.reason.contains("Only on my network (SSH)"), sshOnly.reason) + assertTrue(sshOnly.reason.contains("Settings → How to reach each computer"), sshOnly.reason) + } + } + + @Test + fun `not connected yet asks the relay leg when there is one`() { + // connectionFor connects first, so this only matters for the UI's state before the first + // listing: a control is not shown as impossible while the phone is still dialing. + assertEquals(Leg.Relay, LegRouting.route(Capability.REGISTER_NODE, null, null, RelayLeg.AVAILABLE)) + assertIs(LegRouting.route(Capability.REGISTER_NODE, null, null, RelayLeg.NOT_SET_UP)) + } + + @Test + fun `the relay leg's availability`() { + assertEquals(RelayLeg.AVAILABLE, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = false)) + assertEquals(RelayLeg.ROUTE_SSH_ONLY, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = true)) + assertEquals(RelayLeg.NOT_SET_UP, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = false)) + assertEquals(RelayLeg.NOT_SET_UP, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = true)) + } + + @Test + fun `reach answers every capability from the same rule`() { + val reach = LegRouting.reach(TransportKind.SSH, ssh, RelayLeg.AVAILABLE) + assertEquals(Capability.entries.toSet(), reach.keys) + for (cap in Capability.entries) assertEquals(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.AVAILABLE), reach[cap]) + } + + @Test + fun `the SSH refusal names the relay, not a setting that may already be on`() { + val msg = LegRouting.sshRefusal("Editing the board") + assertTrue(msg.startsWith("Editing the board")) + assertTrue(msg.contains("through the relay")) + assertFalse(msg.contains("turn on remote access")) + } + + /** + * The app asks this decision instead of the transport kind. What only a device can run (the + * Compose screens) is pinned in the source, like AppSourcePins does elsewhere. + */ + @Test + fun `the app's controls decide from the routing, and write through the leg it names`() { + val host = AppSourcePins.ui("HostScreen.kt") + // The FAB used to be gated on `kind == TransportKind.RELAY` and vanished on the LAN. + assertFalse(host.contains("kind == TransportKind.RELAY"), "HostScreen gates New session on the transport kind") + assertTrue(host.contains("session.route(Capability.REGISTER_NODE)")) + assertTrue(host.contains("enabled = false"), "an unavailable New session is shown disabled, not hidden") + + val board = AppSourcePins.ui("BoardTab.kt") + assertTrue(board.contains("session.route(Capability.BOARD_WRITES)")) + assertTrue(board.contains("session.connectionFor(Capability.BOARD_WRITES)")) + assertFalse(board.contains("ensureConnected().setCardColumn"), "a board write must not go to the SSH leg") + assertFalse(board.contains("ensureConnected().editCardLabels"), "a label edit must not go to the SSH leg") + + val sessions = AppSourcePins.ui("SessionsTab.kt") + assertTrue(sessions.contains("session.route(Capability.NODE_ACTIONS)")) + assertFalse(sessions.contains("capabilities?.nodeActions"), "node actions decided from the primary leg alone") + + // A session the phone starts is created and registered through the leg that can register it. + val term = AppSourcePins.ui("TerminalController.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(term, "private fun attach()"), + "PendingLaunches.peek(nodeId) != null -> session.connectionFor(Capability.REGISTER_NODE" + ) + + // The side relay goes through the approval gate with the caller's trigger (never a first + // handshake from the background), and connectionFor defaults to the user's tap. + val conn = AppSourcePins.app("conn/ConnectionManager.kt") + val viaRelay = AppSourcePins.blockAfter(conn.substring(conn.indexOf("suspend fun viaRelay(")), "return sideMutex.withLock") + assertTrue(viaRelay.contains("graph.relayGate.decide(hostId, trigger)")) + assertTrue(viaRelay.contains("requireApproved = requireApproved")) + assertTrue(Regex("""fun connectionFor\(\s*cap: Capability,\s*trigger: Trigger = Trigger\.USER""").containsMatchIn(conn)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt index 3d78ae22ce..8c4a319c3a 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -1,6 +1,10 @@ package dev.nodeterm.protocol import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.NewNode import dev.nodeterm.protocol.host.HostException import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.ResumeOffer @@ -618,6 +622,36 @@ class SshTransportTest { } } + @Test + fun `on the LAN the app's own verbs route to the relay leg, and SSH says where they go`() = runBlocking { + // A26: Auto keeps the SSH leg when it works, and board writes, a new session and node + // actions need nodeterm the app. They go to the relay leg opened next to it — and a caller + // that reaches the SSH transport anyway is not told to turn on remote access (it may be on). + connect().use { conn -> + for (cap in listOf(Capability.BOARD_WRITES, Capability.REGISTER_NODE, Capability.NODE_ACTIONS, Capability.GIT)) { + assertEquals(LegRouting.Leg.Relay, LegRouting.route(cap, conn.kind, conn.capabilities, LegRouting.RelayLeg.AVAILABLE), "$cap") + } + // What SSH does itself stays on SSH. + assertEquals( + LegRouting.Leg.Primary, + LegRouting.route(Capability.ANSWER_APPROVALS, conn.kind, conn.capabilities, LegRouting.RelayLeg.AVAILABLE) + ) + val refusals = listOf Unit>( + { conn.registerNode("p1", NewNode("term-n-1", "x", null, null)) }, + { conn.ensureBoard("p1") }, + { conn.setCardColumn("p1", "term-a-1", null) }, + { conn.editCardLabels("p1", "term-a-1", CardLabelEdit()) }, + { conn.wake("term-a-1") }, + { conn.rename("term-a-1", "x") } + ) + for (call in refusals) { + val e = assertFailsWith { call() } + assertFalse(e.message!!.contains("turn on remote access"), e.message) + assertTrue(e.message!!.contains("through the relay"), e.message) + } + } + } + @Test fun `send keys types literally, even text that starts with a dash`() = runBlocking { connect().use { conn -> diff --git a/docs/android.md b/docs/android.md index cdefe577ef..de838a5267 100644 --- a/docs/android.md +++ b/docs/android.md @@ -49,6 +49,22 @@ environment at creation, which the phone cannot reproduce) and never touches nod SSH projects (they live on another host). Both surface as `NeedsRelayException`, and the app opens the session through a relay connection held next to the SSH one (`HostSession.viaRelay`). +**The relay leg next to SSH (audit `A26`).** What needs nodeterm *the app* rather than the machine +— a new session (`projects.registerNode`, and the attach that creates it), board writes +(`projects.ensureBoard|setCardColumn|editCardLabels`), node actions (`node.wake|refresh|rename`) and +`git.*` — is the relay's. `Auto` still keeps the SSH leg as the primary connection when it works; +when one of those verbs is needed, `HostSession.connectionFor` opens the computer's relay leg next to +it on that tap and keeps it until the connection is dropped. Which leg answers is ONE pure decision, +`LegRouting.route` (`android/protocol`, `LegRoutingTest`): the primary connection when its +capabilities include the verb, else the relay leg when the phone holds one (a relay block and a +stored device token — often minted while on SSH by late adoption — and a route other than "Only on +my network"), else unavailable with a reason. The screens ask the same function, so an unavailable +control is shown **disabled with that reason** (New session, the board's card actions, the session +menu's wake/refresh/rename), never hidden. The relay dial still goes through `RelayApprovalGate` +with the caller's trigger: these are taps (`Trigger.USER`), so the first one on a desktop that has +not pinned the phone shows the approval code on the host screen, and a background path never makes a +first handshake. Answering approvals, read-acks, typing keys and ending a session stay on SSH. + ## Protocol mapping The standing phone host still speaks the **legacy relay dialect** (`host-service.ts` @@ -63,9 +79,9 @@ describes as the future. The Android client implements what the host actually se | Scroll | `pty.scroll` (host writes SGR wheel events) | the phone writes the same SGR wheel events | | Detach / end | `pty.kill` / `pty.destroy` | close channel / `kill-session` | | Wake on open | the attach itself: host-service reports the viewer (`remoteViewer.attached` → `agent:wake`) and the desktop wakes a Sleeping node it has mounted; the phone offers nothing, so it never types a second `--resume` | nothing reaches the desktop, so opening a Sleeping node offers the desktop's wake line (the agent's `--resume `, plus the permission mode for Claude only, no `cd` or account: the pane's shell already has both), only while a shell owns the pane (`#{pane_current_command}`, read on open and again at the tap), typed only on a tap, after a kill-line | -| Wake, refresh, rename | `node.wake|refresh|rename` | — (needs the desktop app) | -| Board | `projects.ensureBoard|setCardColumn|editCardLabels` | read-only | -| New session | `pty.attach` of a fresh `term-…` id, launch line, then `projects.registerNode` | — | +| Wake, refresh, rename | `node.wake|refresh|rename` | through the relay leg opened next to SSH (`A26`); disabled with the reason when the phone has none | +| Board | `projects.ensureBoard|setCardColumn|editCardLabels` | reads over SSH; writes through the relay leg opened next to it (`A26`), disabled with the reason when the phone has none | +| New session | `pty.attach` of a fresh `term-…` id, launch line, then `projects.registerNode` | the whole launch goes through the relay leg opened next to SSH (`A26`); disabled with the reason when the phone has none | | Answer a held approval | **`approvals.answer`** (new) → `{answered}`, plus `reason: gone\|failed` when not | write `~/.nodeterm/pending/.answer` (prints `gone` when the hold ended) | | Read-ack | **`inbox.ack`** (new) | write `~/.nodeterm/acks/.seen` | | Quick answer keys (question digits, legacy approve/deny) | **`node.sendKeys {nodeId, keys}`** (new) → `{sent}`, typed through the node's existing session; an older desktop gets attach → wait for paint → write → linger | `tmux send-keys -l` | @@ -555,6 +571,16 @@ later fix left to a device. are unchanged. *(A77)* 51. Light and dark system theme; a tablet or a foldable if one is available. *(A65)* +### The relay leg next to SSH + +52. On the same network as the computer, with remote access on and the route Automatic (the host + screen says "On your network"): New session starts a session that appears on the canvas; a card + moved or labelled on the Board moves there; Wake, Refresh and Rename from a session's menu act. + On a desktop that has not pinned this phone, the first of these shows the approval code on the + host screen. With remote access off (re-pair with it off), and again with the route "Only on my + network", the New session button, the card actions and the menu items are shown disabled with a + reason that matches the case, and none of them opens a relay connection. *(A26)* + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's @@ -563,9 +589,13 @@ later fix left to a device. The client sends `{deviceToken, hostId}` and accepts `pairingToken`, `token` or `joinToken` — unverified against the live backend. - **Direct SSH is POSIX-only by design** (like iOS): board writes, node actions and new sessions - need the relay. iOS writes `project.json` over SSH for some of these; Android deliberately does - not (the host verbs exist because that write breaks past `MAX_ARG_STRLEN` and cannot reach an SSH - project's file at all). + go through nodeterm the app, so on the LAN the phone opens the computer's relay leg next to the + SSH connection for them (`A26`, see "The relay leg next to SSH"). iOS writes `project.json` over + SSH for some of these; Android deliberately does not (the host verbs exist because that write + breaks past `MAX_ARG_STRLEN` and cannot reach an SSH project's file at all). The cost: a phone + whose computer has remote access OFF (so it holds no relay leg) cannot do them on the LAN at all, + where iOS can for a local folder project whose file still fits in one argv string. The controls + say so instead of vanishing. - **A desktop mounting a node can still detach a direct-SSH phone.** The desktop leaves `-D` off its own tmux client only while a relay-served client of that node is attached (it spawned that one itself, so it can see it). A phone attached over direct SSH is detached (exit 0), and so is a From 0c5a1e1395a87c7605415ed0226ea59b6a9b4c3c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 07:38:46 +0000 Subject: [PATCH 093/284] feat(android): Source Control screen over the desktop's git bridge (A29) The protocol layer already spoke the desktop's typed git verbs (HostConnection.git, git.status|diff|stage|unstage|commit|push|pull|history), but nothing in the app called them, so a phone had no source control at all. What changed: - android/protocol, new package `git`: GitReplies reads what host-service's handleGit answers from the real GitService (GitStatus, GitResult, the diff text, GitHistoryResult); a reply of another shape is an error, never an empty repository. GitDiff colours a unified diff (a `+++` inside a hunk is an added line, not a file header), capped at 4,000 lines with a count of the rest. SourceControl wraps one connection + the project's folder and sends exactly the params host-service reads. SourceControlGate decides before any request: no folder, an SSH project of the desktop (its folder is on another host and not in the listing), or no leg for git (LegRouting's reason) each get a sentence. - GitVerb carries a timeout: push and pull wait 3 minutes instead of the 30 s RPC default (the desktop sets no limit on them); RelayHostConnection passes it on. - App: a SourceControlScreen route (status split into staged / changed / untracked, a file's diff, stage/unstage per file or section, commit of what is staged, push/pull, the last 50 commits). cwd comes from the project in projects.list; every call goes through session.connectionFor(Capability.GIT), so on the LAN it is the A26 relay leg next to SSH, and the approval code shows on this screen. git's own message (ok:false) and the bridge's refusals ("git is not served on this host." / "cwd is outside the shared project roots.") are shown as returned. Entry: a "Source control" button on each project heading (Sessions) and beside the Board's project picker. - Interop fixture: serves the desktop's real GitService (as hostBridge.git does) behind the production jail (extraRoots = store.localProjectCwds(), production's workspaceRoots), over a repo seeded in the project's folder with a bare origin; FIXTURE_NO_GIT=1 stands for a desktop without the bridge. The fixture guard test now pins that it stays the real class. - Docs: docs/android.md (mapping row, what is verified, A29 paragraph, device checklist item 53, Known gaps), android/README.md, and the CLAUDE.md / CONTRIBUTING.md sentences on which interop bridges are fakes. Verified: RelayInteropTest (2 new: status -> diffs -> stage/unstage -> a failed stage's git message -> empty-message refusal -> commit -> history -> push -> pull through the real GitService; the jail incl. `..`, and a host with no bridge, as the host's sentences), SourceControlTest (17: parsing, degrade, diff kinds/cap/CRLF, blockers, gate, params on the wire, timeouts, source pins of the screen, entry points and the route codec). Mutation checks, each reverted: fixture without git (2 interop tests fail), relay git without cwd (interop fails), timeout not passed on (unit fails), +++ classified as a header (unit fails), status key renamed (3 fail), screen using ensureConnected() (pin fails), fixture git swapped for a fake (vitest guard fails). Full protocol suite 371/371, offline app type-check, vitest android-interop-fixture.guard + host-git-bridge, npm run typecheck. Departures from the audit: no SSH git implementation (the verifier's option; documented in Known gaps) - over SSH the relay leg is used. The entry point is always shown; the screen states why it cannot open rather than a disabled button per heading. The desktop's SSH projects are refused on the phone (the bridge's jail is local project folders and the listing carries no remote path). Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- CLAUDE.md | 3 +- CONTRIBUTING.md | 3 +- android/README.md | 4 +- .../dev/nodeterm/android/MainActivity.kt | 9 +- .../dev/nodeterm/android/ui/BoardTab.kt | 17 +- .../dev/nodeterm/android/ui/HostScreen.kt | 2 +- .../dev/nodeterm/android/ui/SessionsTab.kt | 31 +- .../android/ui/SourceControlScreen.kt | 484 ++++++++++++++++++ .../dev/nodeterm/protocol/git/GitDiff.kt | 65 +++ .../dev/nodeterm/protocol/git/GitReplies.kt | 145 ++++++ .../nodeterm/protocol/git/SourceControl.kt | 131 +++++ .../nodeterm/protocol/host/HostConnection.kt | 14 +- .../protocol/host/RelayHostConnection.kt | 8 +- .../protocol/src/test/interop/host-fixture.ts | 46 +- .../dev/nodeterm/protocol/RelayInteropTest.kt | 99 +++- .../nodeterm/protocol/SourceControlTest.kt | 372 ++++++++++++++ docs/android.md | 62 ++- .../android-interop-fixture.guard.test.ts | 21 + 18 files changed, 1483 insertions(+), 33 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/SourceControlScreen.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitDiff.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitReplies.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/SourceControl.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/SourceControlTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index bcb7a9ca07..ee2895c02d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5404,7 +5404,8 @@ sessions is a hazard whatever kills it; this removes the hazard, not a proven ca and `src/main/index.ts`, which names the blob's sources), and `WorkflowPathFilterTest` fails on a bundle input the filter misses (audit A63). Running is not testing, so know what is real (audit A64): the relay tests run `connectHostSession`'s handshake and verb routing (the pty, - board, inbox and node-action bridges behind the verbs are fakes), `createPairingService`, and + board, inbox and node-action bridges behind the verbs are fakes; `git.*` runs the real + `GitService` over a temp repository in the project's folder, audit A29), `createPairingService`, and the `projects.list` blob as the desktop builds it — `buildProjectsListBlob` (`src/core/projects-list-blob.ts`, the one assembly `listProjectsOutput` also calls) over a real `WorkspaceStore` and an `agent-status.json` the real mirror wrote from hook payloads diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5b6efaf99..4319503c7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,7 +110,8 @@ applicable here": Android client in the same PR. `./gradlew -p protocol test` (from `android/`, on JDK 17–24, after your usual `npm install`) runs the relay leg against this repo's own host code: the handshake and host-service's verb routing (`connectHostSession`; the pty, board, inbox and node-action - bridges behind the verbs are fakes), pairing (`createPairingService`), and the `projects.list` + bridges behind the verbs are fakes, while `git.*` runs the real `GitService` over a temp + repository), pairing (`createPairingService`), and the `projects.list` blob as the desktop builds it (`buildProjectsListBlob` in `src/core/projects-list-blob.ts` over a real `WorkspaceStore` and an `agent-status.json` written by the real mirror). The SSH leg is **hand-copied**: `SshTransportTest` writes the v3 `workspace.json` index, the project files, diff --git a/android/README.md b/android/README.md index 7fe24fcd1c..7099b29a0b 100644 --- a/android/README.md +++ b/android/README.md @@ -32,6 +32,7 @@ written for it and tested where the layer allows, and the numbered | New session (agent / shell) → registered on the canvas | relay | `projects.registerNode`, launched before registration so the desktop never double-launches. On your network the phone opens the relay leg next to SSH for it; with no relay leg (remote access off) the button is disabled and says why | | Wake / refresh / rename / end session | relay (end: both) | `node.*` verbs, through the relay leg next to SSH when on your network; over SSH "end" stops the tmux session only | | Kanban board, move cards, labels | relay (reads: both) | `projects.ensureBoard/setCardColumn/editCardLabels`; on your network the writes go through the relay leg next to SSH, and are disabled with the reason when there is none | +| Source control: status, diffs, stage/unstage, commit, push/pull, recent commits | relay | The desktop's typed `git.*` bridge on the project's folder (no free-form git). On your network it goes through the relay leg next to SSH; not for the computer's SSH projects or a project with no folder, which say why | | Inbox: approvals, questions, finished turns | ✓ | Held hook approvals are answered deterministically (never with keystrokes); a multi-select question lists its options but is answered in the session; each card shows its node's context % when known | | Read-ack (reading a finished session clears it on the computer) | ✓ | `inbox.ack` over the relay, `~/.nodeterm/acks` over SSH | | Usage (rate limits per account) | ✓ | From the agent-status mirror, with a pace line ("5h usage pace faster") when the reset time is known | @@ -100,7 +101,8 @@ are also debuggable, which hands the phone's pairing credentials to anyone with until you run `npm install` or `npm run rebuild` again (`bootstrap-windows.bat` on Windows). The SSH tests need tmux and `script(1)` (util-linux on Linux, BSD on macOS) and skip without them. - **`app/`** — the Compose UI on top: pairing, the computers list, a computer's Sessions / Board / - Inbox / Usage tabs, the terminal screen, settings, background notifications. + Inbox / Usage tabs, the terminal screen, a project's source control, settings, background + notifications. ## Notifications diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt index 19b7ff7bd0..cdfd03cbb7 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -24,16 +24,19 @@ import dev.nodeterm.android.ui.HostsScreen import dev.nodeterm.android.ui.NodetermTheme import dev.nodeterm.android.ui.PairScreen import dev.nodeterm.android.ui.SettingsScreen +import dev.nodeterm.android.ui.SourceControlScreen import dev.nodeterm.android.ui.TerminalScreen import dev.nodeterm.protocol.model.BackStack -/** The screens. A plain back stack: five destinations, one deep link (the pairing URL). */ +/** The screens. A plain back stack: six destinations, one deep link (the pairing URL). */ sealed interface Route { data object Hosts : Route data class PairHost(val code: String? = null) : Route data object Settings : Route data class Host(val hostId: String, val tab: Int = 0) : Route data class Terminal(val hostId: String, val nodeId: String, val title: String) : Route + /** One project's source control on that computer (audit A29). */ + data class SourceControl(val hostId: String, val projectId: String) : Route } /** @@ -95,6 +98,7 @@ class Navigator(initial: BackStack) { Route.Settings -> listOf("settings") is Route.Host -> listOf("host", r.hostId, r.tab.toString()) is Route.Terminal -> listOf("terminal", r.hostId, r.nodeId, r.title) + is Route.SourceControl -> listOf("git", r.hostId, r.projectId) } private fun decode(parts: List): Route? = when (parts.firstOrNull()) { @@ -104,6 +108,7 @@ class Navigator(initial: BackStack) { "settings" -> Route.Settings "host" -> parts.getOrNull(1)?.let { Route.Host(it, parts.getOrNull(2)?.toIntOrNull() ?: 0) } "terminal" -> if (parts.size == 4) Route.Terminal(parts[1], parts[2], parts[3]) else null + "git" -> if (parts.size == 3) Route.SourceControl(parts[1], parts[2]) else null else -> null } } @@ -168,6 +173,7 @@ class MainActivity : ComponentActivity() { when (r) { is Route.Host -> graph.hosts.get(r.hostId) != null is Route.Terminal -> graph.hosts.get(r.hostId) != null + is Route.SourceControl -> graph.hosts.get(r.hostId) != null else -> true } } @@ -219,6 +225,7 @@ private fun AppContent(nav: Navigator) { Route.Settings -> SettingsScreen(nav) is Route.Host -> HostScreen(nav, r.hostId, r.tab) is Route.Terminal -> TerminalScreen(nav, r.hostId, r.nodeId, r.title) + is Route.SourceControl -> SourceControlScreen(nav, r.hostId, r.projectId) } } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt index b3da753de8..c3a2b7864f 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt @@ -135,13 +135,16 @@ fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro } } Spacer(Modifier.width(8.dp)) - if (readOnlyReason != null) { - Text( - "Read-only here. $readOnlyReason", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + // The project's source control (audit A29); the screen says why when it cannot open. + TextButton(onClick = { nav.push(Route.SourceControl(hostId, project.id)) }) { Text("Source control") } + } + if (readOnlyReason != null) { + Text( + "Read-only here. $readOnlyReason", + Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } val cards = project.nodes.filter { it.kind == NodeKind.TERMINAL || it.kind == NodeKind.STICKY || it.kind == NodeKind.BROWSER } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index a38f5ea1f2..595c9e825c 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -211,7 +211,7 @@ private fun ConnectionBanner(state: ConnState, session: HostSession) { } @Composable -private fun ApprovalCode(sas: String) { +internal fun ApprovalCode(sas: String) { Column( Modifier.fillMaxWidth().background(NtColors.panel2).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt index b79c85ff0d..693770e13a 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -131,7 +131,9 @@ fun SessionsTab(nav: Navigator, hostId: String, session: HostSession, snapshot: LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 96.dp)) { for (project in projects) { - item(key = "p-${project.id}") { ProjectHeader(project) } + item(key = "p-${project.id}") { + ProjectHeader(project, onSourceControl = { nav.push(Route.SourceControl(hostId, project.id)) }) + } val grouped = project.sessions.groupBy { snapshot.statusOf(it.id)?.bucket ?: SessionBucket.UNKNOWN } for (bucket in BUCKET_ORDER) { val rows = grouped[bucket].orEmpty().sortedByDescending { snapshot.statusOf(it.id)?.updatedAt ?: 0 } @@ -252,16 +254,31 @@ fun SessionsTab(nav: Navigator, hostId: String, session: HostSession, snapshot: } } +/** + * A project's heading, with its Source control (audit A29). The entry is offered for every project: + * the screen itself says why one cannot have it (no folder on the computer, an SSH project, no relay + * leg), rather than a heading that silently lacks it. + */ @Composable -private fun ProjectHeader(project: ProjectInfo) { - Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 18.dp), verticalAlignment = Alignment.CenterVertically) { +private fun ProjectHeader(project: ProjectInfo, onSourceControl: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 18.dp), verticalAlignment = Alignment.CenterVertically) { ColorDot(parseHex(project.color, NtColors.accent), 12) Spacer(Modifier.width(10.dp)) - Text(project.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - project.sshTarget?.let { - Spacer(Modifier.width(8.dp)) - Text("on $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text( + project.name, + Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + project.sshTarget?.let { + Spacer(Modifier.width(8.dp)) + Text("on $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } } + TextButton(onClick = onSourceControl) { Text("Source control") } } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SourceControlScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SourceControlScreen.kt new file mode 100644 index 0000000000..731dfd28aa --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SourceControlScreen.kt @@ -0,0 +1,484 @@ +package dev.nodeterm.android.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LifecycleStartEffect +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.conn.ConnState +import dev.nodeterm.protocol.git.GitDiff +import dev.nodeterm.protocol.git.GitFileChange +import dev.nodeterm.protocol.git.GitHistory +import dev.nodeterm.protocol.git.GitResult +import dev.nodeterm.protocol.git.GitStatus +import dev.nodeterm.protocol.git.SourceControl +import dev.nodeterm.protocol.git.SourceControlGate +import dev.nodeterm.protocol.host.Capability +import kotlinx.coroutines.launch + +/** A file whose diff is open: which side ([staged]) of it. */ +private data class DiffTarget(val file: GitFileChange, val staged: Boolean) + +/** + * A project's source control (audit A29), over the desktop's typed, jailed git bridge (`git.*`): the + * status split into staged, changed and untracked files, a file's diff, stage and unstage, a commit + * of what is staged, push and pull, and the recent commits. The folder is the project's own, as + * `projects.list` names it; there is no free-form git. + * + * Where the verbs go is the one routing decision the other app-only verbs use (audit A26): the open + * connection when it is the relay, else the relay leg opened next to the SSH connection on the tap + * that opens this screen. When neither can, or the project has no folder on the computer + * ([SourceControlGate]), the screen says why and sends nothing. What the computer answers is shown + * as it is: git's own message for a command that failed there, and the bridge's own sentence for a + * request it refused. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SourceControlScreen(nav: Navigator, hostId: String, projectId: String) { + val graph = NodetermApp.graph(LocalContext.current) + val host = graph.hosts.get(hostId) + if (host == null) { + LaunchedEffect(hostId) { nav.pop() } + return + } + val session = remember(hostId) { graph.connections.session(hostId) } + val state by session.state.collectAsState() + val snapshot by session.snapshot.collectAsState() + val relayApproval by session.relayApproval.collectAsState() + val scope = rememberCoroutineScope() + + // The project and its folder come from the listing, which this keeps current while showing, + // like every screen of a computer (audit A18). + LifecycleStartEffect(hostId) { + session.startWatching() + onStopOrDispose { session.stopWatching() } + } + + val listed = snapshot.fetchedAt != 0L + val project = snapshot.projects.firstOrNull { it.id == projectId } + val gate = SourceControlGate.of(project, session.route(Capability.GIT)) + val cwd = (gate as? SourceControlGate.Availability.Available)?.cwd + + var status by remember { mutableStateOf(null) } + var history by remember { mutableStateOf(null) } + var historyError by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(null) } + var notice by remember { mutableStateOf(null) } + var message by rememberSaveable { mutableStateOf("") } + var diffOf by remember { mutableStateOf(null) } + var diff by remember { mutableStateOf(null) } + var diffError by remember { mutableStateOf(null) } + var reload by remember { mutableIntStateOf(0) } + + /** The leg that serves `git.*` right now (a tap may open the relay leg next to SSH). */ + suspend fun git(dir: String) = SourceControl(session.connectionFor(Capability.GIT), dir) + + suspend fun load(dir: String, withHistory: Boolean) { + val g = git(dir) + val st = g.status() + status = st + if (withHistory) { + history = null + historyError = null + if (st.hasRepo) { + try { + history = g.history() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + historyError = e.message ?: "The history could not be read." + } + } + } + } + + /** + * One operation at a time. [block] answers what the computer said; the status is read again + * afterwards either way (a failed push still tells the truth about ahead/behind). + */ + fun run(label: String, withHistory: Boolean, block: suspend (SourceControl) -> GitResult?) { + val dir = cwd ?: return + if (busy != null) return + busy = label + notice = null + scope.launch { + try { + val result = block(git(dir)) + notice = result?.let(SourceControl::outcome) + load(dir, withHistory) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + notice = SourceControl.Outcome(error = true, text = e.message ?: "$label failed.") + } finally { + busy = null + } + } + } + + LaunchedEffect(cwd, reload) { + val dir = cwd ?: return@LaunchedEffect + busy = "Loading" + notice = null + try { + load(dir, withHistory = true) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + notice = SourceControl.Outcome(error = true, text = e.message ?: "Source control could not be read.") + } finally { + busy = null + } + } + + LaunchedEffect(diffOf) { + val target = diffOf ?: return@LaunchedEffect + val dir = cwd ?: return@LaunchedEffect + diff = null + diffError = null + try { + diff = git(dir).diff(target.file, target.staged) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + diffError = e.message ?: "The diff could not be read." + } + } + + BackHandler(enabled = diffOf != null) { diffOf = null } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(diffOf?.file?.path ?: "Source control", maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + diffOf?.let { if (it.staged) "Staged changes" else if (it.file.untracked) "New file" else "Changes" } + ?: listOfNotNull(project?.name, status?.takeIf { it.hasRepo }?.branch).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = { if (diffOf != null) diffOf = null else nav.pop() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + actions = { + if (diffOf == null) { + IconButton(onClick = { reload++ }, enabled = cwd != null && busy == null) { Icon(Icons.Filled.Refresh, "Refresh") } + } + } + ) + } + ) { padding -> + Column(Modifier.fillMaxSize().aboveKeyboard(padding)) { + if (state is ConnState.AwaitingApproval) ApprovalCode((state as ConnState.AwaitingApproval).sas) + // The relay leg opened next to SSH for this screen waits for the computer's approval + // dialog (its first dial on a desktop that has not pinned this phone). + relayApproval?.let { if (state !is ConnState.AwaitingApproval) ApprovalCode(it) } + (state as? ConnState.Failed)?.let { Banner(it.message, error = true) } + if (busy != null) LinearProgressIndicator(Modifier.fillMaxWidth()) + notice?.let { n -> Banner(n.text, error = n.error, onDismiss = { notice = null }) } + + when { + !listed -> Centered("Loading the project…") + gate is SourceControlGate.Availability.Unavailable -> Centered(gate.reason) + diffOf != null -> DiffView(diff, diffError) + status == null -> Centered(if (busy != null) "Reading the repository…" else "The repository could not be read.") + status?.hasRepo == false -> Centered( + "The project's folder is not a git repository. Create one in nodeterm on the computer (Source Control → Initialize repository)." + ) + else -> StatusList( + status = status!!, + history = history, + historyError = historyError, + message = message, + onMessage = { message = it }, + busy = busy != null, + onOpen = { file, staged -> diffOf = DiffTarget(file, staged) }, + onStage = { paths -> run("Stage", withHistory = false) { it.stage(paths) } }, + onUnstage = { paths -> run("Unstage", withHistory = false) { it.unstage(paths) } }, + onCommit = { + val text = message + run("Commit", withHistory = true) { g -> + g.commit(text).also { if (it.ok) message = "" } + } + }, + onPush = { run("Push", withHistory = true) { it.push() } }, + onPull = { run("Pull", withHistory = true) { it.pull() } } + ) + } + } + } +} + +@Composable +private fun Centered(text: String) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun Banner(text: String, error: Boolean, onDismiss: (() -> Unit)? = null) { + Text( + text, + Modifier + .fillMaxWidth() + .background(if (error) NtColors.attention.copy(alpha = 0.12f) else NtColors.panel2) + .let { if (onDismiss != null) it.clickable(onClick = onDismiss) else it } + .padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) +} + +@Composable +private fun StatusList( + status: GitStatus, + history: GitHistory?, + historyError: String?, + message: String, + onMessage: (String) -> Unit, + busy: Boolean, + onOpen: (GitFileChange, Boolean) -> Unit, + onStage: (List) -> Unit, + onUnstage: (List) -> Unit, + onCommit: () -> Unit, + onPush: () -> Unit, + onPull: () -> Unit +) { + val commitBlocker = SourceControl.commitBlocker(status, message) + val syncBlocker = SourceControl.syncBlocker(status) + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 32.dp)) { + item(key = "branch") { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(status.branch, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(8.dp)) + val sync = buildList { + if (status.ahead > 0) add("↑${status.ahead}") + if (status.behind > 0) add("↓${status.behind}") + if (status.hasRemote && !status.hasUpstream) add("not pushed yet") + }.joinToString(" ") + if (sync.isNotEmpty()) Text(sync, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (status.repoName.isNotBlank()) { + Text(status.repoName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onPull, enabled = !busy && syncBlocker == null) { Text("Pull") } + OutlinedButton(onClick = onPush, enabled = !busy && syncBlocker == null) { Text("Push") } + } + syncBlocker?.let { Hint(it) } + } + } + item(key = "commit") { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = message, + onValueChange = onMessage, + label = { Text("Commit message") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 6 + ) + Button(onClick = onCommit, enabled = !busy && commitBlocker == null) { + Text(if (status.staged.isEmpty()) "Commit" else "Commit ${status.staged.size} staged") + } + // Says why only once there is something to say it about. + if (commitBlocker != null && (message.isNotBlank() || status.staged.isNotEmpty())) Hint(commitBlocker) + } + } + fileSection( + key = "staged", title = "Staged", files = status.staged, staged = true, + action = "Unstage", allAction = "Unstage all", busy = busy, + onOpen = onOpen, onAct = onUnstage + ) + fileSection( + key = "changes", title = "Changes", files = status.unstaged, staged = false, + action = "Stage", allAction = "Stage all", busy = busy, + onOpen = onOpen, onAct = onStage + ) + fileSection( + key = "untracked", title = "Untracked", files = status.untracked, staged = false, + action = "Stage", allAction = "Stage all", busy = busy, + onOpen = onOpen, onAct = onStage + ) + if (status.staged.isEmpty() && status.changes.isEmpty()) { + item(key = "clean") { Hint("No changes.", Modifier.padding(16.dp)) } + } + item(key = "history-title") { SectionTitle("Recent commits", null) } + when { + historyError != null -> item(key = "history-error") { Hint(historyError, Modifier.padding(horizontal = 16.dp)) } + history == null -> item(key = "history-loading") { Hint("Loading…", Modifier.padding(horizontal = 16.dp)) } + history.commits.isEmpty() -> item(key = "history-empty") { Hint("No commits yet.", Modifier.padding(horizontal = 16.dp)) } + else -> { + items(history.commits, key = { "c-${it.id}" }) { c -> + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(c.shortId, fontFamily = FontFamily.Monospace, color = NtColors.accent, fontSize = 12.sp) + Spacer(Modifier.width(8.dp)) + Text(c.subject, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val meta = listOfNotNull( + c.author, + c.timestampMs?.let { relativeAge(it) }, + c.refs.takeIf { it.isNotEmpty() }?.joinToString(", ") + ).joinToString(" · ") + if (meta.isNotEmpty()) Hint(meta) + } + } + if (history.hasMore) item(key = "history-more") { Hint("Older commits are in nodeterm on the computer.", Modifier.padding(16.dp)) } + } + } + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.fileSection( + key: String, + title: String, + files: List, + staged: Boolean, + action: String, + allAction: String, + busy: Boolean, + onOpen: (GitFileChange, Boolean) -> Unit, + onAct: (List) -> Unit +) { + if (files.isEmpty()) return + item(key = "$key-title") { + SectionTitle("$title (${files.size})") { + TextButton(onClick = { onAct(files.map { it.path }) }, enabled = !busy) { Text(allAction) } + } + } + items(files, key = { "$key-${it.path}" }) { file -> + Row( + Modifier.fillMaxWidth().clickable { onOpen(file, staged) }.padding(start = 16.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + file.status, + Modifier.widthIn(min = 18.dp), + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = statusColor(file.status) + ) + Spacer(Modifier.width(8.dp)) + Text(file.path, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + if (file.added > 0 || file.deleted > 0) { + Text("+${file.added} −${file.deleted}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + TextButton(onClick = { onAct(listOf(file.path)) }, enabled = !busy) { Text(action) } + } + } +} + +@Composable +private fun SectionTitle(text: String, trailing: (@Composable () -> Unit)?) { + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text.uppercase(), + Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + trailing?.invoke() + } +} + +@Composable +private fun Hint(text: String, modifier: Modifier = Modifier) { + Text(text, modifier, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +/** The letters the desktop's Source Control colours (`gitStatusColors`): added/new, deleted, the rest. */ +private fun statusColor(status: String): Color = when (status) { + "A", "U" -> NtColors.success + "D" -> NtColors.attention + "R", "C" -> NtColors.accent + else -> NtColors.warning +} + +@Composable +private fun DiffView(diff: GitDiff?, error: String?) { + when { + error != null -> Centered(error) + diff == null -> Centered("Reading the diff…") + diff.isEmpty -> Centered("No differences to show.") + else -> LazyColumn(Modifier.fillMaxSize().padding(horizontal = 4.dp), contentPadding = PaddingValues(bottom = 32.dp)) { + items(diff.lines.size) { i -> + val line = diff.lines[i] + val (bg, fg) = when (line.kind) { + GitDiff.Kind.ADD -> NtColors.success.copy(alpha = 0.14f) to MaterialTheme.colorScheme.onSurface + GitDiff.Kind.DEL -> NtColors.attention.copy(alpha = 0.14f) to MaterialTheme.colorScheme.onSurface + GitDiff.Kind.HUNK -> NtColors.accent.copy(alpha = 0.10f) to NtColors.accent + GitDiff.Kind.META, GitDiff.Kind.NOTE -> Color.Transparent to MaterialTheme.colorScheme.onSurfaceVariant + GitDiff.Kind.CONTEXT -> Color.Transparent to MaterialTheme.colorScheme.onSurface + } + Text( + line.text.ifEmpty { " " }, + Modifier.fillMaxWidth().background(bg, RoundedCornerShape(2.dp)).padding(horizontal = 6.dp, vertical = 1.dp), + color = fg, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 16.sp + ) + } + if (diff.omitted > 0) { + item { Hint("${diff.omitted} more lines are not shown. The whole diff is in nodeterm on the computer.", Modifier.padding(12.dp)) } + } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitDiff.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitDiff.kt new file mode 100644 index 0000000000..88c2449e2a --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitDiff.kt @@ -0,0 +1,65 @@ +package dev.nodeterm.protocol.git + +/** + * A unified diff as the phone shows it: each line with the kind that colours it. The desktop's + * `git.diff` answers git's own text (`git diff -- `, `--cached` for a staged file, and + * `--no-index /dev/null ` for an untracked one), with no size cap of its own, so the view + * keeps the first [MAX_LINES] lines and says how many it left out. + */ +data class GitDiff(val lines: List, val omitted: Int) { + enum class Kind { + /** `diff --git`, `index`, `---`/`+++` file names, mode and rename lines, "Binary files … differ". */ + META, + /** `@@ -a,b +c,d @@`. */ + HUNK, + ADD, + DEL, + CONTEXT, + /** `\ No newline at end of file`. */ + NOTE + } + + data class Line(val kind: Kind, val text: String) + + val isEmpty: Boolean get() = lines.isEmpty() + + companion object { + const val MAX_LINES = 4_000 + + /** Longer lines (a minified file) are cut, so one line cannot hold the view. */ + const val MAX_LINE_CHARS = 2_000 + + /** + * Classify [text]. A `+++`/`---` line is a file name only BEFORE the first hunk of a file: + * inside a hunk it is an added line that starts with `++` (or a removed one with `--`), which + * a naive prefix test would colour as a header. + */ + fun parse(text: String, maxLines: Int = MAX_LINES): GitDiff { + if (text.isEmpty()) return GitDiff(emptyList(), 0) + val raw = text.removeSuffix("\n").split('\n') + val out = ArrayList(minOf(raw.size, maxLines)) + var inHunk = false + for (rawLine in raw.take(maxLines)) { + val line = rawLine.removeSuffix("\r") + val kind = when { + line.startsWith("diff ") -> { + inHunk = false + Kind.META + } + line.startsWith("@@") -> { + inHunk = true + Kind.HUNK + } + !inHunk -> Kind.META + line.startsWith("+") -> Kind.ADD + line.startsWith("-") -> Kind.DEL + line.startsWith("\\") -> Kind.NOTE + else -> Kind.CONTEXT + } + val shown = if (line.length > MAX_LINE_CHARS) line.take(MAX_LINE_CHARS) + " …" else line + out += Line(kind, shown) + } + return GitDiff(out, (raw.size - maxLines).coerceAtLeast(0)) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitReplies.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitReplies.kt new file mode 100644 index 0000000000..e612a72a70 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitReplies.kt @@ -0,0 +1,145 @@ +package dev.nodeterm.protocol.git + +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.objects +import dev.nodeterm.protocol.model.J.s +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * One changed file, as the desktop's `GitService.status` reports it (`GitFileChange` in + * src/shared/types.ts). [status] is git's one letter: `M`, `A`, `D`, `R`, `C`, … and `U` for an + * UNTRACKED file (the desktop maps porcelain `??` to `U`; it is not git's "unmerged" here). + */ +data class GitFileChange(val path: String, val status: String, val added: Int, val deleted: Int) { + val untracked: Boolean get() = status == "U" +} + +/** + * `git.status` (`GitStatus` in src/shared/types.ts). A folder that is not a repository answers + * `hasRepo: false` with nothing else filled in. [changes] holds the working-tree changes AND the + * untracked files, exactly as the desktop sends them; [unstaged] and [untracked] split them. + */ +data class GitStatus( + val hasRepo: Boolean, + val repoName: String, + /** The current branch, or `HEAD` when detached. */ + val branch: String, + val ahead: Int, + val behind: Int, + /** Any remote at all (a fork may have only `upstream`). */ + val hasRemote: Boolean, + /** The current branch tracks an upstream (it has been pushed once). */ + val hasUpstream: Boolean, + val staged: List, + val changes: List +) { + val unstaged: List get() = changes.filter { !it.untracked } + val untracked: List get() = changes.filter { it.untracked } +} + +/** + * What a write verb (`git.stage|unstage|commit|push|pull`) answered: `GitResult` in + * src/shared/types.ts. A git command that FAILED on the computer is an answer, not an RPC error: + * `ok: false` with git's own message (`stderr`), which the screen shows as it is. Only a refused + * request (no git bridge, a folder outside the shared roots) is an RPC error, i.e. a + * [dev.nodeterm.protocol.host.HostException] carrying the host's sentence. + */ +data class GitResult(val ok: Boolean, val message: String) + +/** One commit of `git.history` (`GitHistoryItem`, src/shared/git-history-types.ts). */ +data class GitCommit( + val id: String, + /** The short hash the desktop shows; the first 7 characters of [id] when it sent none. */ + val shortId: String, + val subject: String, + val author: String?, + /** Author time in ms since the epoch (the desktop converts git's seconds). */ + val timestampMs: Long?, + /** Branch and tag names pointing at this commit. */ + val refs: List +) + +/** `git.history` (`GitHistoryResult`): the newest commits of the current branch first. */ +data class GitHistory( + val commits: List, + /** More commits exist past the host's limit (50 by default). */ + val hasMore: Boolean, + val currentRef: String?, + val remoteRef: String?, + val hasIncomingChanges: Boolean, + val hasOutgoingChanges: Boolean +) + +/** + * Reads the bodies the desktop's jailed git bridge sends back (`handleGit` in + * src/main/remote/host-service.ts, which hands each verb to the real `GitService` in + * src/core/git-service.ts and responds with its return value). A reply that is not the shape that + * verb returns is null: the caller says so instead of showing an empty repository that is not + * there. Field by field the reads are tolerant, like the rest of the phone's parsing. The status + * fields the screen does not use (`branches`, `remoteBranches`, `hasOrigin`, `ghAvailable`, + * `ghAuthed`) are left unread: the phone switches no branches and publishes nothing. + */ +object GitReplies { + fun status(body: JsonElement?): GitStatus? { + val o = J.obj(body) ?: return null + val hasRepo = o.b("hasRepo") ?: return null + return GitStatus( + hasRepo = hasRepo, + repoName = o.s("repoName") ?: "", + branch = o.s("branch") ?: "", + ahead = o.l("ahead")?.toInt()?.coerceAtLeast(0) ?: 0, + behind = o.l("behind")?.toInt()?.coerceAtLeast(0) ?: 0, + hasRemote = o.b("hasRemote") == true, + hasUpstream = o.b("hasUpstream") == true, + staged = o.objects("staged").mapNotNull(::change), + changes = o.objects("changes").mapNotNull(::change) + ) + } + + private fun change(c: JsonObject): GitFileChange? { + val path = c.s("path")?.takeIf { it.isNotEmpty() } ?: return null + return GitFileChange( + path = path, + status = c.s("status")?.takeIf { it.isNotEmpty() } ?: "M", + added = c.l("added")?.toInt()?.coerceAtLeast(0) ?: 0, + deleted = c.l("deleted")?.toInt()?.coerceAtLeast(0) ?: 0 + ) + } + + /** `git.diff` answers the diff text itself (a JSON string); empty when there is nothing to show. */ + fun diff(body: JsonElement?): String? = J.str(body) + + fun result(body: JsonElement?): GitResult? { + val o = J.obj(body) ?: return null + val ok = o.b("ok") ?: return null + return GitResult(ok, o.s("message") ?: "") + } + + fun history(body: JsonElement?): GitHistory? { + val o = J.obj(body) ?: return null + if (o["items"] !is JsonArray) return null + return GitHistory( + commits = o.objects("items").mapNotNull { c -> + val id = c.s("id")?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + GitCommit( + id = id, + shortId = c.s("displayId")?.takeIf { it.isNotEmpty() } ?: id.take(7), + subject = c.s("subject") ?: "", + author = c.s("author")?.takeIf { it.isNotBlank() }, + timestampMs = c.l("timestamp")?.takeIf { it > 0 }, + refs = c.objects("references").mapNotNull { it.s("name")?.takeIf(String::isNotEmpty) } + ) + }, + hasMore = o.b("hasMore") == true, + currentRef = o.o("currentRef")?.s("name"), + remoteRef = o.o("remoteRef")?.s("name"), + hasIncomingChanges = o.b("hasIncomingChanges") == true, + hasOutgoingChanges = o.b("hasOutgoingChanges") == true + ) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/SourceControl.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/SourceControl.kt new file mode 100644 index 0000000000..31abf585e5 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/SourceControl.kt @@ -0,0 +1,131 @@ +package dev.nodeterm.protocol.git + +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.model.ProjectInfo +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive + +/** + * The project's source control over the desktop's typed, jailed git bridge (audit A29): the `git.*` + * verbs `createHostHandlers` serves (src/main/remote/host-service.ts), each one handed to the real + * `GitService` in src/core/git-service.ts. There is no free-form git: the phone can read the status, + * a file's diff and the recent history, stage and unstage files, commit what is staged, and push or + * pull the current branch, which is what the bridge serves and nothing more. + * + * [cwd] is the project's folder as `projects.list` names it. The desktop refuses any other folder + * ("cwd is outside the shared project roots."), and a desktop without the bridge refuses every verb + * ("git is not served on this host."); both arrive as a [HostException] carrying that sentence, for + * the screen to show as it is. + * + * Over direct SSH the verbs go through the relay leg next to the SSH connection (audit A26, + * [LegRouting]); the SSH transport has no git of its own. + */ +class SourceControl(private val conn: HostConnection, val cwd: String) { + suspend fun status(): GitStatus = GitReplies.status(conn.git(GitVerb.STATUS, cwd)) ?: throw unreadable() + + /** + * The diff of [file] on one side: [staged] = the index against HEAD (`--cached`), else the working + * tree against the index, or the whole file for an untracked one. + */ + suspend fun diff(file: GitFileChange, staged: Boolean): GitDiff { + val body = conn.git( + GitVerb.DIFF, cwd, + mapOf( + "path" to JsonPrimitive(file.path), + "staged" to JsonPrimitive(staged), + "untracked" to JsonPrimitive(!staged && file.untracked) + ) + ) + return GitDiff.parse(GitReplies.diff(body) ?: throw unreadable()) + } + + suspend fun stage(paths: List): GitResult = write(GitVerb.STAGE, mapOf("paths" to JsonArray(paths.map(::JsonPrimitive)))) + + suspend fun unstage(paths: List): GitResult = write(GitVerb.UNSTAGE, mapOf("paths" to JsonArray(paths.map(::JsonPrimitive)))) + + /** Commits what is STAGED (the desktop adds nothing on its own). */ + suspend fun commit(message: String): GitResult = write(GitVerb.COMMIT, mapOf("message" to JsonPrimitive(message))) + + /** `git push`; a branch with no upstream is pushed to `origin` with `-u` by the desktop. */ + suspend fun push(): GitResult = write(GitVerb.PUSH) + + suspend fun pull(): GitResult = write(GitVerb.PULL) + + suspend fun history(): GitHistory = GitReplies.history(conn.git(GitVerb.HISTORY, cwd)) ?: throw unreadable() + + private suspend fun write(verb: GitVerb, args: Map = emptyMap()): GitResult = + GitReplies.result(conn.git(verb, cwd, args)) ?: throw unreadable() + + private fun unreadable() = HostException("The computer's answer to a source-control request could not be read.") + + companion object { + /** + * Why a commit cannot be made yet, or null when it can. The desktop commits only what is + * staged and refuses an empty message ("Commit message is empty."); the button says so first. + */ + fun commitBlocker(status: GitStatus?, message: String): String? = when { + status == null || !status.hasRepo -> "There is no repository to commit to." + status.staged.isEmpty() -> "Stage the changes to commit first." + message.isBlank() -> "Write a commit message." + else -> null + } + + /** Why Push and Pull cannot run, or null when they can. */ + fun syncBlocker(status: GitStatus?): String? = when { + status == null || !status.hasRepo -> "There is no repository." + !status.hasRemote -> "This repository has no remote to push to or pull from." + else -> null + } + + /** + * What the screen says after a write verb: git's own message as the desktop returned it, as an + * error when the command failed there. A success that said nothing (staging) says nothing. + */ + fun outcome(result: GitResult): Outcome? = when { + !result.ok -> Outcome(error = true, text = result.message.ifBlank { "git failed on the computer." }) + result.message.isBlank() -> null + else -> Outcome(error = false, text = result.message) + } + } + + data class Outcome(val error: Boolean, val text: String) +} + +/** + * Whether a project's source control can be opened from the phone at all, and if not, why — decided + * before any request, so a control can say it instead of failing on a tap (audit A29). + */ +object SourceControlGate { + sealed interface Availability { + /** Open it: [cwd] is the project's folder on the computer. */ + data class Available(val cwd: String) : Availability + data class Unavailable(val reason: String) : Availability + } + + /** + * [leg] is where `git.*` goes right now (`LegRouting.route(Capability.GIT, …)`). + * + * An SSH project of the desktop is refused here rather than by the host: its folder is on another + * machine (the desktop's own Source Control reaches it over its ControlMaster), the listing does + * not carry that folder's path, and the bridge's jail is the computer's own project folders. + */ + fun of(project: ProjectInfo?, leg: LegRouting.Leg): Availability { + if (project == null) return Availability.Unavailable("This project is no longer on the computer.") + project.sshTarget?.let { + return Availability.Unavailable( + "This project's folder is on $it, which the computer reaches over SSH. Source control from the " + + "phone covers folders on the computer itself; use Source Control in nodeterm on the computer." + ) + } + val cwd = project.cwd?.takeIf { it.isNotBlank() } + ?: return Availability.Unavailable("This project has no folder on the computer, so it has no repository.") + return when (leg) { + is LegRouting.Leg.Unavailable -> Availability.Unavailable(leg.reason) + LegRouting.Leg.Primary, LegRouting.Leg.Relay -> Availability.Available(cwd) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt index 37389dc109..921e5fae0a 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -4,6 +4,7 @@ import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.KanbanColumn import dev.nodeterm.protocol.model.KanbanLabel import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.relay.RelaySocket import kotlinx.serialization.json.JsonElement import java.io.Closeable @@ -121,11 +122,20 @@ interface HostConnection : Closeable { fun setOnClosed(listener: ((String?) -> Unit)?) } -enum class GitVerb(val wire: String) { +/** + * The typed `git.*` verbs of the desktop's jailed git bridge (host-service.ts `handleGit`), and how + * long the phone waits for each. Push and pull talk to the repository's remote from the computer and + * can take minutes on a slow link; the desktop sets no limit of its own, so giving up after the usual + * RPC wait would report a failure for a push that is still running there. + */ +enum class GitVerb(val wire: String, val timeoutMs: Long = RelaySocket.RPC_TIMEOUT_MS) { STATUS("git.status"), DIFF("git.diff"), STAGE("git.stage"), UNSTAGE("git.unstage"), - COMMIT("git.commit"), PUSH("git.push"), PULL("git.pull"), HISTORY("git.history") + COMMIT("git.commit"), PUSH("git.push", GIT_NETWORK_TIMEOUT_MS), PULL("git.pull", GIT_NETWORK_TIMEOUT_MS), HISTORY("git.history") } +/** How long a push or a pull may take on the computer before the phone stops waiting (see [GitVerb]). */ +const val GIT_NETWORK_TIMEOUT_MS = 180_000L + open class HostException(message: String) : Exception(message) /** diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt index 28cf674989..4d3a48eec7 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt @@ -138,8 +138,8 @@ class RelayHostConnection private constructor() : HostConnection, RelaySocketLis // ---- RPC plumbing --------------------------------------------------------------------- - private suspend fun call(method: String, params: JsonElement? = null): JsonElement? = try { - socket.call(method, params) + private suspend fun call(method: String, params: JsonElement? = null, timeoutMs: Long = RelaySocket.RPC_TIMEOUT_MS): JsonElement? = try { + socket.call(method, params, timeoutMs) } catch (e: RpcException) { throw HostException(e.message ?: "Request failed.") } @@ -335,8 +335,10 @@ class RelayHostConnection private constructor() : HostConnection, RelaySocketLis } } + /** `git.*` with `{cwd, …args}`; the host's refusal ("git is not served on this host.", "cwd is + * outside the shared project roots.") arrives as a [HostException] carrying that sentence. */ override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = - call(verb.wire, JsonObject(args + ("cwd" to JsonPrimitive(cwd)))) + call(verb.wire, JsonObject(args + ("cwd" to JsonPrimitive(cwd))), verb.timeoutMs) override fun setOnChanged(listener: (() -> Unit)?) { onChanged = listener diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts index e895d701ad..aedaf68e06 100644 --- a/android/protocol/src/test/interop/host-fixture.ts +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -7,7 +7,9 @@ // serving a FAKE pty/kanban/inbox bridge that records what the phone asked for. // `projects.list` is NOT faked: the desktop's `buildProjectsListBlob` over a real // `WorkspaceStore` and a mirror file the real agent-status mirror wrote, all under -// FIXTURE_USERDATA (see seedDesktopState; audit A64). +// FIXTURE_USERDATA (see seedDesktopState; audit A64). Neither is `git.*`: the real +// `GitService` behind the real jail, over a repository in the project's folder (see +// seedGitRepo; audit A29). // mode "pair": the desktop's real `createPairingService` (src/main/pairing-service.ts) with the // home dir pointed at a temp dir by the caller (HOME, and USERPROFILE for Windows, see // InteropHarness.scratchHomeEnv; refused unless `os.homedir()` is FIXTURE_HOME), and a @@ -23,6 +25,8 @@ // tsconfig.node.json (audit A67): a desktop interface it implements cannot drift from it unseen. // Do not cast what it hands to the desktop code (`as unknown as`, `as never`); a cast turns that // check off for the value, and the drift then shows up only at run time. +import { execFileSync } from 'child_process' +import fs from 'fs' import http from 'http' import os from 'os' import path from 'path' @@ -50,6 +54,7 @@ import { observedClaudeAccount, registerClaudeAccountsSource } from '../../../../../src/core/claude-config-dir' +import { GitService } from '../../../../../src/core/git-service' import { buildProjectsListBlob } from '../../../../../src/core/projects-list-blob' import { WorkspaceStore } from '../../../../../src/core/workspace-store' import { normalizeFor } from '../../../../../src/shared/agents/normalize' @@ -194,6 +199,35 @@ async function seedDesktopState(): Promise { return store } +/** + * The project's folder as a git repository the phone's source control works on (audit A29), made the + * way a user would make one: an initial commit (the project's own `.nodeterm/project.json` included) + * pushed to a bare `origin` beside the folder, then one change of each kind the status reports — a + * staged new file, a modified tracked file and an untracked file. Repo-local identity, no signing and + * no hooks, so a contributor's global git config cannot decide whether the phone's commit succeeds. + */ +function seedGitRepo(dir: string): void { + const git = (cwd: string, ...args: string[]): void => { + execFileSync('git', args, { cwd, stdio: 'pipe' }) + } + const origin = path.join(path.dirname(dir), 'origin.git') + git(path.dirname(dir), 'init', '--bare', '--initial-branch=main', origin) + git(dir, 'init', '--initial-branch=main') + git(dir, 'config', 'user.name', 'Interop') + git(dir, 'config', 'user.email', 'interop@example.test') + git(dir, 'config', 'commit.gpgsign', 'false') + git(dir, 'config', 'core.hooksPath', path.join(dir, '.git', 'no-hooks')) + fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n') + git(dir, 'add', '-A') + git(dir, 'commit', '-m', 'initial') + git(dir, 'remote', 'add', 'origin', origin) + git(dir, 'push', '-u', 'origin', 'main') + fs.writeFileSync(path.join(dir, 'README.md'), 'hello again\n') + fs.writeFileSync(path.join(dir, 'staged.txt'), 'staged\n') + git(dir, 'add', 'staged.txt') + fs.writeFileSync(path.join(dir, 'new.txt'), 'new file\n') +} + async function runRelay(): Promise { // Relay mode writes a workspace and the agent-status mirror under userData: never into the // checkout the fixture runs from. @@ -248,6 +282,10 @@ async function runRelay(): Promise { } const store = await seedDesktopState() + // The folder the WorkspaceStore just wrote the project file into (`project.cwd` in projects.list). + const [projectDir] = store.localProjectCwds() + if (!projectDir) throw new Error('the seeded workspace has no local project folder') + seedGitRepo(projectDir) let session: HostSession | null = null const approveNow = (): void => { @@ -274,6 +312,12 @@ async function runRelay(): Promise { attached: (nodeId) => emit({ event: 'viewer-attached', nodeId }), detached: (nodeId) => emit({ event: 'viewer-detached', nodeId }) }, + // A29: the git bridge both phone hosts serve (`hostBridge.git` in src/main/index.ts is this same + // class), jailed like production's: the canvas cwds (none here) plus every local project folder, + // read from the store exactly as `workspaceRoots` reads it. `FIXTURE_NO_GIT=1` stands for a + // desktop that serves no git bridge. + ...(process.env.FIXTURE_NO_GIT === '1' ? {} : { git: new GitService() }), + extraRoots: () => store.localProjectCwds(), registerNode: async (projectId, node) => { emit({ event: 'registerNode', projectId, node }) return true diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt index 0c2fcba3f0..6ce319b453 100644 --- a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -1,6 +1,10 @@ package dev.nodeterm.protocol import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.git.GitDiff +import dev.nodeterm.protocol.git.GitFileChange +import dev.nodeterm.protocol.git.GitResult +import dev.nodeterm.protocol.git.SourceControl import dev.nodeterm.protocol.host.CardLabelEdit import dev.nodeterm.protocol.host.HostException import dev.nodeterm.protocol.host.NewNode @@ -43,7 +47,9 @@ import kotlin.test.assertTrue * local broker. Everything the phone does over the relay goes across this wire at least once. The * `projects.list` blob it parses is the desktop's own (`buildProjectsListBlob` over a real * `WorkspaceStore` and a mirror file the real agent-status mirror wrote, audit A64); the pty, - * kanban, inbox and node-action bridges behind the verbs are fakes that record what was asked. + * kanban, inbox and node-action bridges behind the verbs are fakes that record what was asked. The + * `git.*` verbs are not: they run the desktop's real `GitService` over a repository in the project's + * folder (audit A29). */ class RelayInteropTest { private val harnesses = ArrayList() @@ -419,4 +425,95 @@ class RelayInteropTest { s.detach() } } + + /** + * Audit A29: the phone's Source Control drives the desktop's REAL git bridge — `handleGit` behind + * its jail, handing each verb to the real `GitService` (src/core/git-service.ts) — over a git + * repository in the project's folder (seedGitRepo in host-fixture.ts). The folder is the one + * `projects.list` names, as the app takes it. + */ + @Test + fun `source control works the project's repository through the desktop's git bridge`() = runBlocking { + val h = start() + val connected = connect(h) + connected.connection.use { conn -> + val cwd = connected.first.projects.single().cwd!! + val git = SourceControl(conn, cwd) + + val st = git.status() + assertTrue(st.hasRepo) + assertEquals("main", st.branch) + assertTrue(st.hasRemote && st.hasUpstream, "the seed pushed main with -u") + assertEquals(0, st.ahead) + assertEquals(listOf(GitFileChange("staged.txt", "A", 1, 0)), st.staged) + assertEquals(listOf(GitFileChange("README.md", "M", 1, 1)), st.unstaged) + assertEquals(listOf("new.txt"), st.untracked.map { it.path }) + + // Each side of the diff, and the whole file for an untracked one. + val readme = git.diff(st.unstaged.single(), staged = false) + assertTrue(GitDiff.Line(GitDiff.Kind.DEL, "-hello") in readme.lines, "${readme.lines}") + assertTrue(GitDiff.Line(GitDiff.Kind.ADD, "+hello again") in readme.lines, "${readme.lines}") + assertTrue(readme.lines.any { it.kind == GitDiff.Kind.HUNK }) + assertTrue(GitDiff.Line(GitDiff.Kind.ADD, "+staged") in git.diff(st.staged.single(), staged = true).lines) + assertTrue(GitDiff.Line(GitDiff.Kind.ADD, "+new file") in git.diff(st.untracked.single(), staged = false).lines) + + // Stage the untracked file, unstage the staged one: both move. + assertEquals(GitResult(true, ""), git.stage(listOf("new.txt"))) + assertEquals(GitResult(true, ""), git.unstage(listOf("staged.txt"))) + val moved = git.status() + assertEquals(listOf("new.txt"), moved.staged.map { it.path }) + assertEquals(listOf("staged.txt"), moved.untracked.map { it.path }) + + // A git command that fails on the computer is an ANSWER carrying git's own words, not an + // RPC error: the screen shows it as it is. + val badPath = git.stage(listOf("no-such-file")) + assertFalse(badPath.ok) + assertTrue(badPath.message.contains("no-such-file"), badPath.message) + // The desktop refuses an empty message itself; the phone's button never sends one. + assertEquals(GitResult(false, "Commit message is empty."), git.commit(" ")) + + val commit = git.commit("Add new.txt from the phone") + assertTrue(commit.ok, commit.message) + assertTrue(commit.message.contains("Add new.txt from the phone"), commit.message) + val after = git.status() + assertEquals(1, after.ahead) + assertTrue(after.staged.isEmpty()) + + val history = git.history() + assertEquals(listOf("Add new.txt from the phone", "initial"), history.commits.map { it.subject }) + val head = history.commits.first() + assertEquals("Interop", head.author) + assertEquals(head.id.take(head.shortId.length), head.shortId) + assertTrue((head.timestampMs ?: 0) > 1_600_000_000_000, "author time in ms: ${head.timestampMs}") + assertTrue("main" in head.refs, "${head.refs}") + assertTrue(history.hasOutgoingChanges) + + assertEquals(GitResult(true, "Pushed."), git.push()) + assertEquals(0, git.status().ahead) + val pulled = git.pull() + assertTrue(pulled.ok, pulled.message) + assertFalse(git.history().hasOutgoingChanges) + } + } + + @Test + fun `the git bridge's refusals reach the phone as the host's own sentences`() = runBlocking { + val h = start() + val connected = connect(h) + connected.connection.use { conn -> + val cwd = connected.first.projects.single().cwd!! + // The jail is lexical and resolves `..`: the folder above the project is outside it. + for (outside in listOf("/etc", "$cwd/..", "")) { + val e = assertFailsWith { SourceControl(conn, outside).status() } + assertEquals("cwd is outside the shared project roots.", e.message, outside) + } + } + + val bare = start(extra = mapOf("FIXTURE_NO_GIT" to "1")) + val c2 = connect(bare) + c2.connection.use { conn -> + val e = assertFailsWith { SourceControl(conn, c2.first.projects.single().cwd!!).status() } + assertEquals("git is not served on this host.", e.message) + } + } } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SourceControlTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SourceControlTest.kt new file mode 100644 index 0000000000..070be6e0f1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SourceControlTest.kt @@ -0,0 +1,372 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.git.GitDiff +import dev.nodeterm.protocol.git.GitDiff.Kind +import dev.nodeterm.protocol.git.GitFileChange +import dev.nodeterm.protocol.git.GitReplies +import dev.nodeterm.protocol.git.GitResult +import dev.nodeterm.protocol.git.GitStatus +import dev.nodeterm.protocol.git.SourceControl +import dev.nodeterm.protocol.git.SourceControlGate +import dev.nodeterm.protocol.git.SourceControlGate.Availability +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GIT_NETWORK_TIMEOUT_MS +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.relay.RelaySocket +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A29: the phone's Source Control over the desktop's typed, jailed git bridge. What the bridge + * really answers is pinned by `RelayInteropTest` against the desktop's own `GitService`; this pins + * how the phone reads it (including what it does with a shape it does not know), what it sends, when + * it refuses before sending anything, and where the app's screen gets its connection. + */ +class SourceControlTest { + // ---- reading the replies ---------------------------------------------------------------------- + + /** `GitStatus` (src/shared/types.ts), every field the desktop sends. */ + private val statusJson = """ + {"hasRepo":true,"repoName":"me/app","branch":"main","branches":["main","dev"],"remoteBranches":["origin/main"], + "ahead":2,"behind":1,"hasRemote":true,"hasOrigin":true,"hasUpstream":true,"ghAvailable":false,"ghAuthed":false, + "staged":[{"path":"src/a.ts","status":"M","added":3,"deleted":1}], + "changes":[{"path":"src/a.ts","status":"M","added":1,"deleted":0}, + {"path":"docs/new.md","status":"U","added":0,"deleted":0}, + {"path":"old.txt","status":"D","added":0,"deleted":7}]} + """ + + @Test + fun `the status reads the desktop's shape and splits untracked files out of the changes`() { + val st = GitReplies.status(Json.parseToJsonElement(statusJson))!! + assertTrue(st.hasRepo) + assertEquals("me/app", st.repoName) + assertEquals("main", st.branch) + assertEquals(2, st.ahead) + assertEquals(1, st.behind) + assertTrue(st.hasRemote && st.hasUpstream) + assertEquals(listOf(GitFileChange("src/a.ts", "M", 3, 1)), st.staged) + assertEquals(listOf("src/a.ts", "old.txt"), st.unstaged.map { it.path }) + assertEquals(listOf("docs/new.md"), st.untracked.map { it.path }) + assertTrue(st.untracked.single().untracked) + } + + @Test + fun `a folder that is not a repository is an answer, a reply of another shape is not`() { + // What GitService.status answers for a plain folder. + val plain = GitReplies.status( + Json.parseToJsonElement("""{"hasRepo":false,"repoName":"demo","branch":"","branches":[],"ahead":0,"behind":0, + "hasRemote":false,"hasOrigin":false,"hasUpstream":false,"ghAvailable":false,"ghAuthed":false,"staged":[],"changes":[]}""") + )!! + assertFalse(plain.hasRepo) + + assertNull(GitReplies.status(null)) + assertNull(GitReplies.status(JsonPrimitive("nope"))) + assertNull(GitReplies.status(Json.parseToJsonElement("""{"branch":"main"}""")), "no hasRepo: not a status") + assertNull(GitReplies.result(Json.parseToJsonElement("""{"message":"x"}""")), "no ok: not a result") + assertNull(GitReplies.history(Json.parseToJsonElement("""{"hasMore":false}""")), "no items: not a history") + assertNull(GitReplies.diff(Json.parseToJsonElement("""{}""")), "a diff is the text itself") + assertEquals("", GitReplies.diff(JsonPrimitive(""))) + } + + @Test + fun `wrong-typed fields degrade to absent instead of failing the whole status`() { + val st = GitReplies.status( + Json.parseToJsonElement("""{"hasRepo":true,"branch":7,"ahead":"2","behind":-3, + "staged":[{"path":"a","status":"A","added":"x"},{"status":"M"},{"path":""},5],"changes":"no"}""") + )!! + assertEquals("", st.branch) + assertEquals(0, st.ahead) + assertEquals(0, st.behind) + assertEquals(listOf(GitFileChange("a", "A", 0, 0)), st.staged, "entries with no path are dropped") + assertTrue(st.changes.isEmpty()) + } + + @Test + fun `history reads the commits, the short hash falling back to the id`() { + val h = GitReplies.history( + Json.parseToJsonElement( + """{"items":[ + {"id":"0123456789abcdef","parentIds":["aa"],"subject":"Fix it","message":"Fix it\n\nbody","displayId":"0123456", + "author":"Ann","authorEmail":"ann@x","timestamp":1700000000000, + "references":[{"id":"refs/heads/main","name":"main","category":"branches"},{"id":"refs/remotes/origin/main","name":"origin/main"}]}, + {"id":"fedcba9876543210","subject":"Older"}, + {"subject":"no id"}], + "currentRef":{"id":"refs/heads/main","name":"main"},"remoteRef":{"id":"refs/remotes/origin/main","name":"origin/main"}, + "hasIncomingChanges":false,"hasOutgoingChanges":true,"hasMore":true,"limit":50}""" + ) + )!! + assertEquals(listOf("Fix it", "Older"), h.commits.map { it.subject }) + val head = h.commits.first() + assertEquals("0123456", head.shortId) + assertEquals("Ann", head.author) + assertEquals(1_700_000_000_000, head.timestampMs) + assertEquals(listOf("main", "origin/main"), head.refs) + assertEquals("fedcba9", h.commits[1].shortId) + assertNull(h.commits[1].author) + assertTrue(h.hasMore && h.hasOutgoingChanges && !h.hasIncomingChanges) + assertEquals("main", h.currentRef) + assertEquals("origin/main", h.remoteRef) + } + + // ---- the diff ----------------------------------------------------------------------------------- + + @Test + fun `file headers are told apart from added and removed lines that start with the same characters`() { + val text = """ + diff --git a/x.txt b/x.txt + index 1111111..2222222 100644 + --- a/x.txt + +++ b/x.txt + @@ -1,3 +1,3 @@ fun a() + keep + --- removed line that starts with two dashes + +++ added line that starts with two pluses + -old + +new + \ No newline at end of file + diff --git a/y.bin b/y.bin + Binary files a/y.bin and b/y.bin differ + """.trimIndent() + "\n" + val d = GitDiff.parse(text) + assertEquals(0, d.omitted) + assertEquals( + listOf( + Kind.META, Kind.META, Kind.META, Kind.META, Kind.HUNK, Kind.CONTEXT, + Kind.DEL, Kind.ADD, Kind.DEL, Kind.ADD, Kind.NOTE, Kind.META, Kind.META + ), + d.lines.map { it.kind } + ) + assertEquals("+++ added line that starts with two pluses", d.lines[7].text) + } + + @Test + fun `an untracked file's diff and a CRLF diff read the same way`() { + // What `git diff --no-index -- /dev/null new.txt` prints, with Windows line endings. + val text = "diff --git a/new.txt b/new.txt\r\nnew file mode 100644\r\n--- /dev/null\r\n+++ b/new.txt\r\n@@ -0,0 +1 @@\r\n+new file\r\n" + val d = GitDiff.parse(text) + assertEquals(listOf(Kind.META, Kind.META, Kind.META, Kind.META, Kind.HUNK, Kind.ADD), d.lines.map { it.kind }) + assertEquals("+new file", d.lines.last().text) + assertTrue(GitDiff.parse("").isEmpty) + } + + @Test + fun `a huge diff is cut to a bounded view that says how much it left out`() { + val text = "@@ -1 +1 @@\n" + (1..10).joinToString("\n") { "+line $it" } + "\n" + val d = GitDiff.parse(text, maxLines = 4) + assertEquals(4, d.lines.size) + assertEquals(7, d.omitted) + + val long = GitDiff.parse("@@ -1 +1 @@\n+" + "x".repeat(GitDiff.MAX_LINE_CHARS * 2)) + assertEquals(GitDiff.MAX_LINE_CHARS + 2, long.lines[1].text.length, "cut to the cap plus \" …\"") + assertTrue(long.lines[1].text.endsWith(" …")) + } + + // ---- what the screen allows --------------------------------------------------------------------- + + private fun status(staged: Int = 0, hasRemote: Boolean = true, hasRepo: Boolean = true) = GitStatus( + hasRepo = hasRepo, repoName = "", branch = "main", ahead = 0, behind = 0, hasRemote = hasRemote, hasUpstream = hasRemote, + staged = List(staged) { GitFileChange("f$it", "M", 1, 0) }, changes = emptyList() + ) + + @Test + fun `a commit needs something staged and a message, which is what the desktop commits`() { + assertEquals("Stage the changes to commit first.", SourceControl.commitBlocker(status(staged = 0), "msg")) + assertEquals("Write a commit message.", SourceControl.commitBlocker(status(staged = 1), " ")) + assertNull(SourceControl.commitBlocker(status(staged = 1), "msg")) + assertEquals("There is no repository to commit to.", SourceControl.commitBlocker(status(hasRepo = false), "msg")) + assertEquals("There is no repository to commit to.", SourceControl.commitBlocker(null, "msg")) + } + + @Test + fun `push and pull need a remote`() { + assertNull(SourceControl.syncBlocker(status())) + assertEquals("This repository has no remote to push to or pull from.", SourceControl.syncBlocker(status(hasRemote = false))) + } + + @Test + fun `what a write verb answered is shown as the computer said it`() { + val stderr = "fatal: pathspec 'x' did not match any files" + assertEquals(SourceControl.Outcome(error = true, text = stderr), SourceControl.outcome(GitResult(false, stderr))) + assertEquals(SourceControl.Outcome(error = false, text = "Pushed."), SourceControl.outcome(GitResult(true, "Pushed."))) + assertNull(SourceControl.outcome(GitResult(true, "")), "staging says nothing on success") + assertTrue(SourceControl.outcome(GitResult(false, ""))!!.error) + } + + // ---- whether it can open at all ----------------------------------------------------------------- + + private fun project(cwd: String? = "/work/app", ssh: String? = null) = + ProjectInfo(id = "p1", name = "App", color = null, cwd = cwd, sshTarget = ssh, closed = false, nodes = emptyList(), board = null) + + @Test + fun `the gate opens on the project's folder through either leg`() { + assertEquals(Availability.Available("/work/app"), SourceControlGate.of(project(), LegRouting.Leg.Primary)) + assertEquals(Availability.Available("/work/app"), SourceControlGate.of(project(), LegRouting.Leg.Relay)) + // A Windows computer's folder goes as it is: the desktop's jail resolves it, not the phone. + assertEquals(Availability.Available("C:\\work\\app"), SourceControlGate.of(project("C:\\work\\app"), LegRouting.Leg.Primary)) + } + + @Test + fun `the gate says why it cannot open, and never sends a request it knows will be refused`() { + val noLeg = LegRouting.route( + dev.nodeterm.protocol.host.Capability.GIT, TransportKind.SSH, + HostCapabilities(boardWrites = false, git = false, nodeActions = false, registerNode = false, answerApprovals = true), + LegRouting.RelayLeg.NOT_SET_UP + ) + val reason = assertIs(SourceControlGate.of(project(), noLeg)).reason + assertEquals((noLeg as LegRouting.Leg.Unavailable).reason, reason) + assertTrue(reason.startsWith("Source control"), reason) + + val ssh = assertIs(SourceControlGate.of(project(cwd = null, ssh = "me@box"), LegRouting.Leg.Primary)) + assertTrue(ssh.reason.contains("me@box"), ssh.reason) + val inline = assertIs(SourceControlGate.of(project(cwd = null), LegRouting.Leg.Primary)) + assertTrue(inline.reason.contains("no folder"), inline.reason) + assertIs(SourceControlGate.of(project(cwd = " "), LegRouting.Leg.Primary)) + assertIs(SourceControlGate.of(null, LegRouting.Leg.Primary)) + } + + // ---- what goes on the wire ---------------------------------------------------------------------- + + private class RecordingConn(private val answer: (GitVerb) -> JsonElement?) : HostConnection { + val calls = mutableListOf>>() + override val kind = TransportKind.RELAY + override val capabilities = LegRouting.RELAY_CAPABILITIES + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? { + calls += Triple(verb, cwd, args) + return answer(verb) + } + + override suspend fun listProjects(): ProjectsSnapshot = error("unused") + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = + error("unused") + override suspend fun wake(nodeId: String) { error("unused") } + override suspend fun refresh(nodeId: String) { error("unused") } + override suspend fun rename(nodeId: String, title: String) { error("unused") } + override suspend fun ensureBoard(projectId: String): List? = error("unused") + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = error("unused") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? = error("unused") + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = error("unused") + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome = error("unused") + override suspend fun ackRead(nodeId: String, eventId: String?) { error("unused") } + override suspend fun sendKeys(nodeId: String, keys: String) { error("unused") } + override fun setOnChanged(listener: (() -> Unit)?) {} + override fun setOnClosed(listener: ((String?) -> Unit)?) {} + override fun close() {} + } + + private val ok = Json.parseToJsonElement("""{"ok":true,"message":""}""") + + @Test + fun `each verb sends the parameters host-service reads, and only those`() = runBlocking { + val conn = RecordingConn { verb -> if (verb == GitVerb.DIFF) JsonPrimitive("") else ok } + val git = SourceControl(conn, "/work/app") + val untracked = GitFileChange("new.txt", "U", 0, 0) + val changed = GitFileChange("a.ts", "M", 1, 1) + git.diff(untracked, staged = false) + git.diff(changed, staged = false) + git.diff(changed, staged = true) + git.stage(listOf("a.ts", "new.txt")) + git.unstage(listOf("a.ts")) + git.commit("msg") + git.push() + git.pull() + + val bool = { b: Boolean -> JsonPrimitive(b) } + assertEquals( + listOf( + GitVerb.DIFF to mapOf("path" to JsonPrimitive("new.txt"), "staged" to bool(false), "untracked" to bool(true)), + GitVerb.DIFF to mapOf("path" to JsonPrimitive("a.ts"), "staged" to bool(false), "untracked" to bool(false)), + GitVerb.DIFF to mapOf("path" to JsonPrimitive("a.ts"), "staged" to bool(true), "untracked" to bool(false)), + GitVerb.STAGE to mapOf("paths" to JsonArray(listOf(JsonPrimitive("a.ts"), JsonPrimitive("new.txt")))), + GitVerb.UNSTAGE to mapOf("paths" to JsonArray(listOf(JsonPrimitive("a.ts")))), + GitVerb.COMMIT to mapOf("message" to JsonPrimitive("msg")), + GitVerb.PUSH to emptyMap(), + GitVerb.PULL to emptyMap() + ), + conn.calls.map { it.first to it.third } + ) + assertTrue(conn.calls.all { it.second == "/work/app" }, "every verb names the project's folder") + assertEquals( + listOf("git.status", "git.diff", "git.stage", "git.unstage", "git.commit", "git.push", "git.pull", "git.history"), + GitVerb.entries.map { it.wire }, + "the verbs host-service.ts routes to handleGit" + ) + } + + @Test + fun `a reply the phone cannot read is said so, never shown as an empty repository`() = runBlocking { + val conn = RecordingConn { JsonPrimitive(42) } + val git = SourceControl(conn, "/work/app") + assertFailsWith { git.status() } + assertFailsWith { git.history() } + assertFailsWith { git.stage(listOf("a")) } + assertFailsWith { git.diff(GitFileChange("a", "M", 0, 0), staged = false) } + } + + @Test + fun `push and pull wait for the remote longer than the usual request`() { + // The desktop sets no limit on `git push`; a slow remote must not read as a failed push. + assertEquals(GIT_NETWORK_TIMEOUT_MS, GitVerb.PUSH.timeoutMs) + assertEquals(GIT_NETWORK_TIMEOUT_MS, GitVerb.PULL.timeoutMs) + assertTrue(GIT_NETWORK_TIMEOUT_MS > RelaySocket.RPC_TIMEOUT_MS) + for (v in GitVerb.entries - setOf(GitVerb.PUSH, GitVerb.PULL)) assertEquals(RelaySocket.RPC_TIMEOUT_MS, v.timeoutMs, "$v") + val relay = java.io.File(InteropHarness.repoRoot, "android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt").readText() + assertTrue(relay.contains("verb.timeoutMs"), "the relay connection must pass the verb's timeout on") + } + + // ---- the app ------------------------------------------------------------------------------------ + + /** + * The screen cannot run on a JVM, so its wiring is pinned in the source ([AppSourcePins]): it + * decides from the one routing decision and the gate, and every git call goes through the leg + * that routing names, so on the LAN it is the relay leg next to SSH, never the SSH connection. + */ + @Test + fun `the screen asks the routing and the gate, and sends git through the leg they name`() { + val screen = AppSourcePins.ui("SourceControlScreen.kt") + assertTrue(screen.contains("SourceControlGate.of(project, session.route(Capability.GIT))")) + assertTrue(screen.contains("SourceControl(session.connectionFor(Capability.GIT), dir)")) + assertFalse(screen.contains("ensureConnected()"), "git must not go to whatever connection is open") + assertFalse(Regex("""connection\??\.git\(""").containsMatchIn(screen), "git must not go to whatever connection is open") + // The cwd is the gate's (the project's folder from projects.list), never typed or derived. + assertTrue(screen.contains("(gate as? SourceControlGate.Availability.Available)?.cwd")) + // A git failure on the computer, and a refused request, are shown in its own words. + assertTrue(screen.contains("result?.let(SourceControl::outcome)")) + assertTrue(screen.contains("e.message ?:")) + } + + @Test + fun `the Sessions and Board project headers open it, and the back stack keeps it`() { + assertTrue(AppSourcePins.ui("SessionsTab.kt").contains("nav.push(Route.SourceControl(hostId, project.id))")) + assertTrue(AppSourcePins.ui("BoardTab.kt").contains("nav.push(Route.SourceControl(hostId, project.id))")) + val main = AppSourcePins.app("MainActivity.kt") + assertTrue(main.contains("is Route.SourceControl -> listOf(\"git\", r.hostId, r.projectId)")) + assertTrue(main.contains("\"git\" -> if (parts.size == 3) Route.SourceControl(parts[1], parts[2]) else null")) + assertTrue(main.contains("is Route.SourceControl -> graph.hosts.get(r.hostId) != null"), "a forgotten computer's screen is dropped") + assertTrue(main.contains("is Route.SourceControl -> SourceControlScreen(nav, r.hostId, r.projectId)")) + } +} diff --git a/docs/android.md b/docs/android.md index de838a5267..c7dbfc0d92 100644 --- a/docs/android.md +++ b/docs/android.md @@ -60,10 +60,12 @@ capabilities include the verb, else the relay leg when the phone holds one (a re stored device token — often minted while on SSH by late adoption — and a route other than "Only on my network"), else unavailable with a reason. The screens ask the same function, so an unavailable control is shown **disabled with that reason** (New session, the board's card actions, the session -menu's wake/refresh/rename), never hidden. The relay dial still goes through `RelayApprovalGate` -with the caller's trigger: these are taps (`Trigger.USER`), so the first one on a desktop that has -not pinned the phone shows the approval code on the host screen, and a background path never makes a -first handshake. Answering approvals, read-acks, typing keys and ending a session stay on SSH. +menu's wake/refresh/rename), never hidden; the Source control screen (`A29`) says it in place of the +repository. The relay dial still goes through `RelayApprovalGate` with the caller's trigger: these +are taps (`Trigger.USER`), so the first one on a desktop that has not pinned the phone shows the +approval code on the screen that asked (the host screen, or Source control), and a background path +never makes a first handshake. Answering approvals, read-acks, typing keys and ending a session stay +on SSH. ## Protocol mapping @@ -82,6 +84,7 @@ describes as the future. The Android client implements what the host actually se | Wake, refresh, rename | `node.wake|refresh|rename` | through the relay leg opened next to SSH (`A26`); disabled with the reason when the phone has none | | Board | `projects.ensureBoard|setCardColumn|editCardLabels` | reads over SSH; writes through the relay leg opened next to it (`A26`), disabled with the reason when the phone has none | | New session | `pty.attach` of a fresh `term-…` id, launch line, then `projects.registerNode` | the whole launch goes through the relay leg opened next to SSH (`A26`); disabled with the reason when the phone has none | +| Source control (`A29`) | `git.status\|diff\|stage\|unstage\|commit\|push\|pull\|history {cwd, …}`, `cwd` = the project's folder from `projects.list`; the desktop jails it to its project folders and hands each verb to its `GitService` | through the relay leg opened next to SSH (`A26`); the screen says why when the phone has none. There is no SSH git of its own (see Known gaps) | | Answer a held approval | **`approvals.answer`** (new) → `{answered}`, plus `reason: gone\|failed` when not | write `~/.nodeterm/pending/.answer` (prints `gone` when the hold ended) | | Read-ack | **`inbox.ack`** (new) | write `~/.nodeterm/acks/.seen` | | Quick answer keys (question digits, legacy approve/deny) | **`node.sendKeys {nodeId, keys}`** (new) → `{sent}`, typed through the node's existing session; an older desktop gets attach → wait for paint → write → linger | `tmux send-keys -l` | @@ -112,9 +115,12 @@ unchanged. through a local broker: handshake, SAS agreement, approval wait, `projects.list`, attach with snapshot paint (including a >256 KB snapshot whose chunk boundary splits a code point), input, resize/`OP.Resized`, exit codes, scroll, detach/destroy, node actions, board verbs (`null` = - Ungrouped), `approvals.answer`, `inbox.ack`, registration. What is real is the verb routing and - its validation; the pty, board, inbox and node-action bridges behind the verbs are fakes that - record what was asked. The `projects.list` blob is the desktop's own: `buildProjectsListBlob` + Ungrouped), `approvals.answer`, `inbox.ack`, registration, and the `git.*` verbs. What is real is + the verb routing and its validation; the pty, board, inbox and node-action bridges behind the + verbs are fakes that record what was asked. The git bridge is not: it is the desktop's own + `GitService` (`src/core/git-service.ts`, what `hostBridge.git` hands both phone hosts) behind the + production jail, over a repository the fixture makes in the project's folder (`A29`). The + `projects.list` blob is the desktop's own: `buildProjectsListBlob` (`src/core/projects-list-blob.ts`, which the desktop's `listProjectsOutput` calls too) over a real `WorkspaceStore` (it writes the v3 index and the project file, then assembles them) and an `agent-status.json` written by the real mirror from Claude hook payloads (audit `A64`). @@ -297,6 +303,28 @@ A phone that stores none follows the default of the build it runs, so storing th it to this build's for good. The address rule and the leave decision are unit-tested; the wiring is pinned in the source, and whether the back gesture reaches it is a device check. +`SourceControlTest` and two relay interop tests cover the Source Control screen (`A29`). The app +already had the client half of the desktop's git bridge (`HostConnection.git`) but no screen used +it. A project's Source control (from its heading on the Sessions tab, or beside the Board's project +picker) now shows the status split into staged, changed and untracked files, a file's diff on either +side, stage and unstage (one file or a whole section), a commit of what is staged, push and pull, and +the last 50 commits. The folder is the project's `cwd` from `projects.list`; there is no free-form +git, only the bridge's typed verbs. The interop tests run them through the desktop's real +`GitService` over a repository in the project's folder, from the status to a pushed commit, and check +that the bridge's refusals ("cwd is outside the shared project roots." for a folder outside its jail, +`..` included; "git is not served on this host." for a desktop without the bridge) reach the phone +as those sentences. A git command that fails on the computer is an answer, not an error: `ok: false` +with git's own message, which the screen shows as it is. The unit tests pin the reading of each +reply (a reply of another shape says so instead of showing an empty repository), the diff colouring +(a `+++` inside a hunk is an added line, not a file header; the view keeps the first 4,000 lines and +says how many it left out), the parameters each verb sends, and that push and pull wait three +minutes rather than the usual 30 s, since the desktop sets no limit on them. Whether it can open at +all is decided before any request (`SourceControlGate`): a project with no folder, one of the +desktop's SSH projects (its folder is on another machine, and the listing does not carry its path), +and a computer the phone reaches only over SSH with no relay leg each get their reason on the +screen. The screen is only type-checked; its wiring (the routing decision, the gate, every call +through `connectionFor(Capability.GIT)`) is pinned in the source. + `InboxNotificationTextTest` pins what an Inbox notification says (`A52`). An approval's notification used to carry the desktop's tool summary (the command's first line, a file path, a fetched URL), and a finished turn's the agent's last message. Android shows a notification's full content on a secure @@ -581,6 +609,16 @@ later fix left to a device. network", the New session button, the card actions and the menu items are shown disabled with a reason that matches the case, and none of them opens a relay connection. *(A26)* +### Source control + +53. From a project's heading (Sessions) or beside the Board's project picker, open Source control, + through the relay and again on the same network (the relay leg next to SSH): the branch, the + staged, changed and untracked files and the recent commits match the desktop's Source Control; + a file's diff opens and Back closes it; stage, unstage, commit (the message box stays above the + keyboard), push and pull act and the desktop shows the result; a push that fails there (no + network, a rejected push) shows git's own message. An SSH project and a project with no folder + say why instead of opening. *(A29)* + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's @@ -596,6 +634,16 @@ later fix left to a device. whose computer has remote access OFF (so it holds no relay leg) cannot do them on the LAN at all, where iOS can for a local folder project whose file still fits in one argv string. The controls say so instead of vanishing. +- **Source control is the desktop's git bridge, and only that** (audit `A29`). Over direct SSH the + phone has no git of its own: it opens the relay leg next to SSH for it, as for the other app-only + verbs, so a phone with no relay leg (remote access off, or the route "Only on my network") cannot + use it on the LAN, and says so. An SSH implementation (`git -C ` over the session, jailed to + the project folders like the desktop's `isWithinRoots`) was deliberately not built: it would be a + second copy of the bridge's rules on the phone. The bridge serves no branch switch, discard, init, + publish, per-commit file list or older history (the desktop's default 50 commits), so the phone + offers none of them. The desktop's SSH projects are not reachable from the phone's Source Control: + their folder is on another host, the listing does not carry its path, and the bridge's jail is the + computer's own project folders. - **A desktop mounting a node can still detach a direct-SSH phone.** The desktop leaves `-D` off its own tmux client only while a relay-served client of that node is attached (it spawned that one itself, so it can see it). A phone attached over direct SSH is detached (exit 0), and so is a diff --git a/src/main/remote/android-interop-fixture.guard.test.ts b/src/main/remote/android-interop-fixture.guard.test.ts index a18a0ceca4..32ff8044d0 100644 --- a/src/main/remote/android-interop-fixture.guard.test.ts +++ b/src/main/remote/android-interop-fixture.guard.test.ts @@ -89,6 +89,27 @@ describe('the Android interop fixture is type-checked against the desktop interf expect(fixture).toMatch(/\brecordAgentEvent\(/) }) + it('serves git.* from the desktop\'s real GitService behind the production jail, never a fake (audit A29)', () => { + // The phone's Source Control is checked against what the git bridge really answers: the same + // class `hostBridge.git` hands both phone hosts, jailed to the same roots production passes (every + // local project folder, `workspaceRoots`). A recording fake here would pass whatever the phone + // parses, and a wider jail would hide a refusal the phone has to show. + const code = (file: string): string => + readFileSync(file, 'utf8') + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n') + const fixture = code(join(INTEROP_DIR, 'host-fixture.ts')) + const index = code(join(REPO_ROOT, 'src', 'main', 'index.ts')) + expect(index).toMatch(/\bconst gitService = new GitService\(\)/) + expect(index).toMatch(/\bgit: gitService\b/) + expect(index).toMatch(/\bworkspaceRoots: \(\) => workspaceStore\.localProjectCwds\(\)/) + expect(fixture).toMatch(/\{ git: new GitService\(\) \}/) + expect(fixture).toMatch(/\bextraRoots: \(\) => store\.localProjectCwds\(\)/) + expect(fixture).toMatch(/from '\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/src\/core\/git-service'/) + }) + it('hands the desktop code nothing through a cast that would switch the type check off', () => { const CAST = /\bas\s+(?:unknown\s+as|never|any)\b/ const offenders: string[] = [] From 7035bde1b8c9c5bdeece91b77efbd451ccc870ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:02:11 +0000 Subject: [PATCH 094/284] feat(android): open links and copy text from the phone terminal (A32) The phone terminal had no way to open a URL or copy text. terminal.js loaded only the fit addon, so plain URLs were never links. OSC 8 links fell back to xterm's confirm(), which a WebView with no WebChromeClient never shows. tmux runs `mouse on`, which keeps xterm's own selection off, and tmux's copy-mode is out of easy reach on a touch screen. Links (terminal.js): - Ports the desktop's URL matcher from src/renderer/terminal/file-links.ts (matchUrlTokens, continuesOnNextRow, paragraphContaining, tokenRange, osc8UrlAt). A URL is matched across xterm's soft wraps and across the full-width rows a tmux repaint paints with no wrap flag, so a long OAuth URL opens whole. @xterm/addon-web-links is not loaded: it joins only soft wraps. - Registers that matcher as a link provider, and sets term.options.linkHandler for OSC 8 links (allowNonHttpProtocols off, never confirm()). - Activation is a tap. On touchend the page hit-tests the cell under the tap (OSC 8 first, then text URLs). On a link it cancels the touchend, which suppresses the compatibility mouse events, so the click never reaches tmux or the app in the pane and the soft keyboard stays down. A tap elsewhere, a swipe (more than 10 px) and two fingers are left alone. - Every path ends at openUrl. It sends the new bridge method NodetermBridge.openUrl only an http(s) URL, as `new URL(text).href` writes it. Links (app): the bridge checks the URL again with the new protocol class ExternalLink: http(s), a host, printable ASCII, no backslash in the authority, and the host taken after any user info. The screen then shows a bar with "Open ?", the full URL, and Open / Copy / dismiss buttons. Open sends a browsable ACTION_VIEW inside a catch (no app, or a refused Intent, shows a toast). The WebViewClient's shouldOverrideUrlLoading no longer opens anything: it refuses every navigation, so the bar is the only way out. Why a tap plus a confirmation bar, not a long-press: tapping is the gesture people use on a link, and the bar keeps a stray tap or an OSC 8 label from leaving the app for an unseen host. Copy: a "Copy" chip, first in the key row, calls nt.copySheet. The page sends a snapshot: the buffer's last 500 rows with soft wraps joined (under tmux that is the visible screen), the http(s) links in them, and the line at the top of the screen. The bridge method onCopySheet parses it with the new TerminalCopy class. The sheet covers the body. A tap selects or deselects a line, and a long-press selects the range from the last line tapped. Copy and Share stay within Osc52.MAX_TEXT_CHARS (A53). setPrimaryClip and the share Intent are caught. Links in the sheet get Open and Copy, and Back closes the sheet. Departure from the desktop's code: paragraphContaining walks up at most 31 rows, not 32, so the 32 rows it joins downward always include the row asked about. The desktop's version can walk up 32 rows and then join only the 32 above that row. There, a tap below a run of more than 32 continuing full-width rows (a TUI's bordered box draws one) misses its link. In the Copy sheet's scan it never progressed, which would hang the page; checked under node before the fix. The scan also forces progress. The desktop's copy is unchanged; this is a follow-up for it. Tests: - TerminalJsLinksTest runs the real terminal.js in node through the extended terminal-js-driver.cjs (stub buffer, OSC 8 cells, touch events). It checks repainted and soft-wrapped URLs opened whole from any of their rows; taps off a link, swipes, the slop and two fingers; OSC 8 taps, with non-http(s) refused; the link provider's ranges; linkHandler with confirm() never called; that everything crossing the bridge is accepted by ExternalLink; and the snapshot's shape, row cap, visible line and links, parsed by TerminalCopy. - TerminalCopyTest covers ExternalLink's accept and refuse cases, snapshot parsing and bounds, the selection rules, and the copy cap. - TerminalLinksWiringTest pins the Android half in the source. Verification: - Protocol suite (gradle test --offline): 401 tests, 0 skipped, 0 failures. - Offline app type-check (tools/typecheck, compileKotlin --offline): clean. - Mutation checks, each fails the new tests: - terminal.js: no hard-wrap join; no preventDefault; any protocol let through; no linkHandler; no soft-wrap join in the snapshot; no slop; the raw text sent instead of the href; the desktop's 32-row walk-up. - ExternalLink: any scheme; the host read from the user info; no backslash refusal. - TerminalCopy: no cap; no long-press range; links not filtered. - Against HEAD's terminal.js, 11 of the 15 new JS tests fail. Not run on a device: the bar, the sheet, the intents, and whether a tap on a phone gives the events the page expects. Checklist items 54-56 are new (A32); item 20 and item 5 are updated. README's "OSC 52 copy" claim is replaced. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 2 +- .../app/src/main/assets/terminal/terminal.js | 274 +++++++++++++- .../nodeterm/android/ui/TerminalController.kt | 157 ++++++++- .../dev/nodeterm/android/ui/TerminalScreen.kt | 333 +++++++++++++----- .../nodeterm/protocol/model/ExternalLink.kt | 53 +++ .../nodeterm/protocol/model/TerminalCopy.kt | 91 +++++ .../src/test/interop/terminal-js-driver.cjs | 218 +++++++++++- .../dev/nodeterm/protocol/TerminalCopyTest.kt | 171 +++++++++ .../nodeterm/protocol/TerminalJsLinksTest.kt | 308 ++++++++++++++++ .../protocol/TerminalLinksWiringTest.kt | 72 ++++ docs/android.md | 52 ++- 11 files changed, 1604 insertions(+), 127 deletions(-) create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ExternalLink.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalCopy.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalCopyTest.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLinksTest.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalLinksWiringTest.kt diff --git a/android/README.md b/android/README.md index 7099b29a0b..bb30689c1f 100644 --- a/android/README.md +++ b/android/README.md @@ -25,7 +25,7 @@ written for it and tested where the layer allows, and the numbered | From anywhere (relay, E2EE, SAS approval) | ✓ | A current desktop approves the phone at the scan (its relay key rides the sealed `/pair` body); an older one shows a code on the first relay connect | | Late relay adoption (paired while remote access was off) | ✓ | Reads `~/.nodeterm/relay.json` over SSH, mints its own device token | | Sessions, grouped like the desktop sidebar | ✓ | Needs you / Running / Sleeping, activity + context % | -| Terminal (co-attach to the live tmux session) | ✓ | xterm.js renderer, native input bar, special keys, swipe = tmux scroll, OSC 52 copy | +| Terminal (co-attach to the live tmux session) | ✓ | xterm.js renderer, native input bar, special keys, swipe = tmux scroll. Tap a link (also one wrapped over several rows, or an OSC 8 link): the phone names its host and opens it only when you confirm, and only an http(s) one. The Copy chip opens a sheet of the screen's lines and links to select and copy or share. A copy the pane sends itself (OSC 52, e.g. vim's `"+y`) reaches the clipboard too, but tmux's copy-mode is out of easy reach on a touch screen | | Sleeping (Eco) session opened | ✓ | Through the relay the computer wakes it on open. Over SSH the phone offers the desktop's wake line (`--resume `, + permission mode for Claude only), while a shell owns the pane, typed only on a tap | | Cold-start resume offer (the computer rebooted) | relay | Offers the agent's own `--resume `; never types it unasked. Over SSH a session that is not running is never created (it would lack its hook environment): the phone offers to open it through the relay | | Sessions of the computer's SSH projects | relay | They run on another host; the computer attaches them over its SSH connection. Over direct SSH the phone offers the relay instead | diff --git a/android/app/src/main/assets/terminal/terminal.js b/android/app/src/main/assets/terminal/terminal.js index 61e5add4ae..c0cc39a50b 100644 --- a/android/app/src/main/assets/terminal/terminal.js +++ b/android/app/src/main/assets/terminal/terminal.js @@ -5,6 +5,9 @@ // tmux owns scrolling (its mouse is on and the pane is on the alternate screen), so a vertical // swipe becomes wheel notches the HOST writes into the pane — exactly what the relay's // `pty.scroll` does — and xterm keeps no scrollback of its own worth scrolling. +// tmux's mouse also means xterm's own selection never runs (it is off while the pane reports the +// mouse), so links and copying are this page's job (audit A32): a tap on a URL offers to open it, +// and the Copy sheet (`nt.copySheet`) hands the buffer's lines and links to the app. (function () { 'use strict' var bridge = window.NodetermBridge @@ -68,15 +71,263 @@ return true }) - // Vertical swipe → tmux history scroll. + // Links (audit A32). A URL in the pane's output is matched across the rows it wraps over, the + // desktop's way: the functions from matchUrlTokens to tokenRange are ported from + // src/renderer/terminal/file-links.ts and keep its names. tmux repaints, and an agent's fullscreen + // TUI paints, a long line as separate full-width rows with no wrap flag, so a long OAuth URL matched + // row by row would open only its first row's fragment. That is why @xterm/addon-web-links is not + // used: it joins only xterm's own soft wraps. OSC 8 links (a label with the URL hidden in the escape + // sequence; tmux passes them on because the desktop declares `hyperlinks`) go through + // term.options.linkHandler. Without one, xterm asks with confirm(), which a WebView without a + // WebChromeClient never shows, so the link did nothing. + // + // Every way in ends at openUrl, which hands the bridge only an http(s) URL, as the URL parser + // normalizes it. The app asks before it leaves for the browser ("Open ?") and checks again. + var URL_RE = /\bhttps?:\/\/[^\s"'`<>()[\]{}|\\^]+/gi + var TRAILING_PUNCT = /[.,;:!?'")\]}>]+$/ + /** Rows joined in each direction at most; a wrapped OAuth URL is about 7 rows at 80 columns. */ + var MAX_JOIN_ROWS = 32 + + /** The URL as the URL parser writes it, when it is http(s); null for anything else. */ + function httpHref(text) { + try { + var u = new URL(text) + return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null + } catch (e) { + return null + } + } + function openUrl(raw) { + var href = httpHref(raw) + if (href) bridge.openUrl(href) + } + + function matchUrlTokens(lineText) { + var out = [] + var re = new RegExp(URL_RE.source, 'gi') + var m + while ((m = re.exec(lineText)) !== null) { + var text = m[0].replace(TRAILING_PUNCT, '') + if (text.length < 8) continue // "http://x" is the shortest sane URL + if (!httpHref(text)) continue + out.push({ text: text, startIndex: m.index, url: text }) + } + return out + } + + function bufferView() { + var buf = term.buffer.active + return { + cols: term.cols, + length: buf.length, + line: function (row) { + var l = buf.getLine(row) + return l ? { isWrapped: l.isWrapped, text: function (trim) { return l.translateToString(trim) } } : undefined + } + } + } + + // Whether `row` runs into `row + 1`: the next row carries xterm's soft-wrap flag, or `row` is full + // to its last column and the next row starts at column 0 with a non-space (a repainted wrap). A + // heuristic: the buffer cannot tell a repainted wrap from text that exactly fills the row. + function continuesOnNextRow(view, row) { + var next = view.line(row + 1) + if (!next) return false + if (next.isWrapped) return true + var cur = view.line(row) + if (!cur) return false + var raw = cur.text(false) + if (raw.length < view.cols || raw[view.cols - 1] === ' ') return false + var nextRaw = next.text(false) + return nextRaw.length > 0 && nextRaw[0] !== ' ' + } + + // The logical paragraph containing `row` (0-based). Every row that continues contributes exactly + // `cols` characters, so an index into `text` is the cell (startRow + idx / cols, idx % cols); the + // last row is right-trimmed. One departure from the desktop's: the walk up stops a row earlier, so + // the MAX_JOIN_ROWS rows joined downward always reach `row`. The desktop's could walk up all 32 and + // then join only the 32 above `row` (a tap there missed its link), and the Copy sheet's scan below + // would make no progress through such a wall of full-width rows (a TUI's bordered box draws one). + function paragraphContaining(view, row) { + if (!view.line(row)) return null + var start = row + while (start > 0 && row - start < MAX_JOIN_ROWS - 1 && continuesOnNextRow(view, start - 1)) start-- + var text = '' + var r = start + for (;;) { + var joins = r - start + 1 < MAX_JOIN_ROWS && continuesOnNextRow(view, r) + var lineText = view.line(r).text(!joins) + text += joins ? lineText.padEnd(view.cols).slice(0, view.cols) : lineText + if (!joins) break + r++ + } + return { text: text, startRow: start, rows: r - start + 1 } + } + + /** An ILink range (1-based, inclusive) for a token at `startIndex..+len` of a paragraph. */ + function tokenRange(startRow, cols, startIndex, len) { + var endIndex = startIndex + len - 1 + return { + start: { x: (startIndex % cols) + 1, y: startRow + Math.floor(startIndex / cols) + 1 }, + end: { x: (endIndex % cols) + 1, y: startRow + Math.floor(endIndex / cols) + 1 } + } + } + + /** + * The http(s) URI of the OSC 8 link at a cell, or null. Read through xterm's private + * `extended.urlId` and `_core._oscLinkService`, as the desktop's osc8UrlAt does: the public buffer + * API has no hyperlink data. A fresh cell per read: a reused one keeps the previous cell's `extended`. + */ + function oscLinkUri(urlId) { + var core = term._core + var service = core && core._oscLinkService + var data = urlId && service ? service.getLinkData(urlId) : null + return data && data.uri && httpHref(data.uri) ? data.uri : null + } + function osc8UrlAt(row, col) { + var line = term.buffer.active.getLine(row) + var cell = line && line.getCell(col) + return oscLinkUri(cell && cell.extended ? cell.extended.urlId : 0) + } + + /** The URL at a buffer cell: an OSC 8 link's, else a URL in the text's paragraph. */ + function linkAt(row, col) { + var osc8 = osc8UrlAt(row, col) + if (osc8) return osc8 + var p = paragraphContaining(bufferView(), row) + if (!p) return null + var idx = (row - p.startRow) * term.cols + col + var tokens = matchUrlTokens(p.text) + for (var i = 0; i < tokens.length; i++) { + var t = tokens[i] + if (idx >= t.startIndex && idx < t.startIndex + t.text.length) return t.url + } + return null + } + + /** The cell (0-based column, 0-based buffer row) under a point in the page, or null. */ + function cellAt(x, y) { + var screen = term.element && term.element.querySelector('.xterm-screen') + if (!screen || term.cols <= 0 || term.rows <= 0) return null + var rect = screen.getBoundingClientRect() + var dx = x - rect.left + var dy = y - rect.top + if (dx < 0 || dy < 0 || dx >= rect.width || dy >= rect.height) return null + var cw = rect.width / term.cols + var ch = rect.height / term.rows + if (cw <= 0 || ch <= 0) return null + return { col: Math.floor(dx / cw), row: Math.floor(dy / ch) + term.buffer.active.viewportY } + } + + // A mouse click on a link (a phone with a mouse, while the pane does not report the mouse; xterm's + // own link handling stands aside when it does). On a touch screen the tap handler below runs first. + term.registerLinkProvider({ + provideLinks: function (y, callback) { + var p = paragraphContaining(bufferView(), y - 1) + if (!p) { + callback(undefined) + return + } + var links = matchUrlTokens(p.text).map(function (u) { + return { + text: u.text, + range: tokenRange(p.startRow, term.cols, u.startIndex, u.text.length), + activate: function () { openUrl(u.url) } + } + }) + callback(links.length ? links : undefined) + } + }) + term.options.linkHandler = { + activate: function (event, uri) { openUrl(uri) }, + allowNonHttpProtocols: false + } + + // The Copy sheet (audit A32): the buffer's last SNAPSHOT_ROWS rows (under tmux, the alternate screen + // has no scrollback, so that is the visible screen), soft wraps joined into one line each, and the + // http(s) links in them (text URLs joined across wraps as above, and OSC 8 links). The app shows the + // lines to select, copies or shares them under its clipboard cap, and offers the links. + var SNAPSHOT_ROWS = 500 + var SNAPSHOT_LINKS = 50 + function snapshot() { + var buf = term.buffer.active + var view = bufferView() + var end = buf.length + var start = Math.max(0, end - SNAPSHOT_ROWS) + // A line cut off at the top would start part-way through. + while (start < end && view.line(start) && view.line(start).isWrapped) start++ + var lines = [] + var firstVisible = 0 + for (var r = start; r < end; r++) { + var l = view.line(r) + if (!l) break + var next = view.line(r + 1) + var continues = !!(next && next.isWrapped) + var text = l.text(!continues) + if (l.isWrapped && lines.length) lines[lines.length - 1] += text + else lines.push(text) + if (r === buf.viewportY) firstVisible = lines.length - 1 + } + while (lines.length && lines[lines.length - 1] === '') lines.pop() + if (firstVisible > lines.length - 1) firstVisible = Math.max(0, lines.length - 1) + + var links = [] + var seen = {} + for (var row = start; row < end;) { + var p = paragraphContaining(view, row) + if (!p) break + var found = matchUrlTokens(p.text).map(function (t) { return { at: t.startIndex, url: t.url } }) + for (var pr = Math.max(p.startRow, start); pr < p.startRow + p.rows && pr < end; pr++) { + var line = buf.getLine(pr) + var last = 0 + for (var x = 0; line && x < term.cols; x++) { + var cell = line.getCell(x) + var id = cell && cell.extended ? cell.extended.urlId : 0 + if (id && id !== last) { + var uri = oscLinkUri(id) + if (uri) found.push({ at: (pr - p.startRow) * term.cols + x, url: uri }) + } + last = id + } + } + found.sort(function (a, b) { return a.at - b.at }) + for (var i = 0; i < found.length; i++) { + var href = httpHref(found[i].url) + if (href && !Object.prototype.hasOwnProperty.call(seen, href)) { + seen[href] = true + links.push(href) + } + } + row = Math.max(row + 1, p.startRow + p.rows) + } + if (links.length > SNAPSHOT_LINKS) links = links.slice(links.length - SNAPSHOT_LINKS) + return { lines: lines, links: links, firstVisible: firstVisible } + } + + // Vertical swipe → tmux history scroll. A tap (one finger, moved at most TAP_SLOP px) on a link hands + // it to the app, which offers to open it (audit A32). tmux runs `mouse on`, so the mouse events a tap + // produces would also reach the pane as a click. On a link the touchend is cancelled, which suppresses them: the click reaches + // neither tmux nor the app in the pane, and the soft keyboard does not come up. A tap anywhere else + // is left alone. + var TAP_SLOP = 10 var startY = null var acc = 0 + var tapX = null + var tapY = null host.addEventListener('touchstart', function (e) { - if (e.touches.length === 1) { startY = e.touches[0].clientY; acc = 0 } + if (e.touches.length === 1) { + startY = e.touches[0].clientY + acc = 0 + tapX = e.touches[0].clientX + tapY = e.touches[0].clientY + } else { + tapX = null + } }, { passive: true }) host.addEventListener('touchmove', function (e) { if (startY === null || e.touches.length !== 1) return var y = e.touches[0].clientY + if (tapX !== null && (Math.abs(e.touches[0].clientX - tapX) > TAP_SLOP || Math.abs(y - tapY) > TAP_SLOP)) tapX = null acc += y - startY startY = y var step = fontSize * 1.4 @@ -88,7 +339,22 @@ if (down) bridge.onScroll(false, down) e.preventDefault() }, { passive: false }) - host.addEventListener('touchend', function () { startY = null }) + host.addEventListener('touchend', function (e) { + startY = null + var x = tapX + var y = tapY + tapX = null + if (x === null || e.touches.length > 0) return + var cell = cellAt(x, y) + var url = cell ? linkAt(cell.row, cell.col) : null + if (!url) return + e.preventDefault() + openUrl(url) + }, { passive: false }) + host.addEventListener('touchcancel', function () { + startY = null + tapX = null + }, { passive: true }) window.nt = { write: function (b64) { term.write(b64ToBytes(b64)) }, @@ -103,6 +369,8 @@ // nothing, and that is the normal state once the terminal has been tapped. (With focus // reporting on, xterm reports a focus-out and a focus-in to the pane for this.) focusForKeyboard: function () { term.blur(); term.focus() }, + // The Copy sheet (audit A32): what the buffer holds, as JSON, for the app to show. + copySheet: function () { bridge.onCopySheet(JSON.stringify(snapshot())) }, setFontSize: function (n) { fontSize = n; term.options.fontSize = n; doFit(true) }, refit: function () { doFit(true) }, // A composed line from the native input bar. `term.paste` frames it as a bracketed paste when diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt index 0c0e3f657e..0e78926304 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -4,6 +4,8 @@ import dev.nodeterm.android.conn.ConnState import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import android.annotation.SuppressLint +import android.app.Activity +import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -44,11 +46,13 @@ import dev.nodeterm.protocol.host.TerminalSink import dev.nodeterm.protocol.host.TerminalStream import dev.nodeterm.protocol.host.ViewerSlot import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.ExternalLink import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.InputBar import dev.nodeterm.protocol.model.Keys import dev.nodeterm.protocol.model.OnScreen import dev.nodeterm.protocol.model.Osc52 +import dev.nodeterm.protocol.model.TerminalCopy import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay @@ -138,6 +142,19 @@ class TerminalController( /** A one-line message over the terminal (dismissable), e.g. a session the desktop refused to add. */ var notice by mutableStateOf(null) + /** + * A link the user tapped in the terminal (or clicked with a mouse), offered as "Open ?" + * (audit A32). Nothing opens on the tap itself: a tap meant for scrolling or for the pane can land on + * a link, and an OSC 8 link shows a label, not where it goes. Set only from [ExternalLink.parse], so + * only an http(s) URL is ever offered. + */ + var linkOffer by mutableStateOf(null) + private set + + /** The Copy sheet's snapshot of the page's buffer (audit A32), or null while the sheet is closed. */ + var copySheet by mutableStateOf(null) + private set + /** * Changes each time the WebView is lost with its renderer (audit A45). The screen keys its * AndroidView on it, so a change builds a new WebView ([createWebView]) in place of the dead one. @@ -293,6 +310,28 @@ class TerminalController( main.post { toast(COPY_TOO_LARGE, Toast.LENGTH_LONG) } } + /** + * A link the page found under a tap or a click (audit A32). The page sends only http(s) URLs; + * [ExternalLink.parse] checks that again, and anything else is dropped without a word. + */ + @JavascriptInterface + fun openUrl(url: String) { + if (!page.isCurrent(gen)) return + val link = ExternalLink.parse(url) ?: return + main.post { if (!disposed) linkOffer = link } + } + + /** The buffer's lines and links for the Copy sheet, as `nt.copySheet` built them (audit A32). */ + @JavascriptInterface + fun onCopySheet(json: String) { + if (!page.isCurrent(gen)) return + val snapshot = TerminalCopy.parse(json) + main.post { + if (disposed) return@post + if (snapshot == null) toast(COPY_SHEET_FAILED, Toast.LENGTH_SHORT) else copySheet = snapshot + } + } + /** A whole OSC 52 sequence (`;`), parsed here on the bridge thread. */ @JavascriptInterface fun onCopy(data: String) { @@ -311,29 +350,101 @@ class TerminalController( * the shared transaction buffer, or refused) is rethrown here as a RuntimeException. Uncaught, * pane output could crash the app (A53); caught, the user is told the copy did not happen. */ - private fun writeClipboard(text: String) { - val ctx = webView?.context ?: return + private fun writeClipboard(text: String, ctx: Context? = webView?.context, copied: String = linesCopied(text)) { + ctx ?: return val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager if (cm == null) { - toast(COPY_FAILED, Toast.LENGTH_LONG) + toast(COPY_FAILED, Toast.LENGTH_LONG, ctx) return } try { cm.setPrimaryClip(ClipData.newPlainText("nodeterm", text)) } catch (e: Exception) { - val tooLarge = generateSequence(e) { it.cause }.take(8).any { it is TransactionTooLargeException } - toast(if (tooLarge) COPY_REJECTED_SIZE else COPY_FAILED, Toast.LENGTH_LONG) + toast(if (tooLarge(e)) COPY_REJECTED_SIZE else COPY_FAILED, Toast.LENGTH_LONG, ctx) return } - val lines = text.count { it == '\n' } + 1 - toast("Copied $lines line${if (lines == 1) "" else "s"}", Toast.LENGTH_SHORT) + toast(copied, Toast.LENGTH_SHORT, ctx) } - private fun toast(message: String, length: Int) { - val ctx = webView?.context ?: return + private fun toast(message: String, length: Int, ctx: Context? = webView?.context) { + ctx ?: return Toast.makeText(ctx, message, length).show() } + /** + * Open the Copy sheet (audit A32) with what the page's buffer holds now. Only a page that is loaded + * is asked: queued for one that is not, the sheet would pop up whenever that page came, unasked. + */ + fun openCopySheet() { + val wv = webView + if (wv == null || !page.isReady) { + toast(COPY_SHEET_NOT_READY, Toast.LENGTH_SHORT) + return + } + wv.evaluateJavascript("nt.copySheet()", null) + } + + fun closeCopySheet() { + copySheet = null + } + + /** Copy the selected lines of the open sheet, within the OSC 52 copy's cap (audit A53). */ + fun copyLines(ctx: Context, selection: TerminalCopy.Selection) { + val lines = copySheet?.lines ?: return + when (val t = TerminalCopy.text(lines, selection)) { + is TerminalCopy.Text.Copy -> writeClipboard(t.text, ctx) + TerminalCopy.Text.TooLarge -> toast(COPY_TOO_LARGE, Toast.LENGTH_LONG, ctx) + TerminalCopy.Text.Empty -> toast(NOTHING_SELECTED, Toast.LENGTH_SHORT, ctx) + } + } + + /** + * Share the selected lines through the system's share sheet. The text rides the Intent, a binder + * transaction like the clipboard's, so it keeps to the same cap, and a refused one is caught. + */ + fun shareLines(ctx: Context, selection: TerminalCopy.Selection) { + val lines = copySheet?.lines ?: return + when (val t = TerminalCopy.text(lines, selection)) { + is TerminalCopy.Text.Copy -> { + val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, t.text) + start(ctx, Intent.createChooser(send, null), SHARE_FAILED) + } + TerminalCopy.Text.TooLarge -> toast(SHARE_TOO_LARGE, Toast.LENGTH_LONG, ctx) + TerminalCopy.Text.Empty -> toast(NOTHING_SELECTED, Toast.LENGTH_SHORT, ctx) + } + } + + /** + * Open [link] in the browser: the user tapped Open on the offer or in the Copy sheet, where its host + * and URL are shown. ACTION_VIEW, browsable apps only, and only ever an http(s) URL ([ExternalLink]). + */ + fun openLink(ctx: Context, link: ExternalLink) { + if (linkOffer == link) linkOffer = null + val view = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)).addCategory(Intent.CATEGORY_BROWSABLE) + start(ctx, view, OPEN_FAILED) + } + + fun copyLink(ctx: Context, link: ExternalLink) { + if (linkOffer == link) linkOffer = null + writeClipboard(link.url, ctx, copied = "Copied the link") + } + + fun dismissLink() { + linkOffer = null + } + + /** startActivity that cannot crash the app: no app to take it, or a refused (too large) Intent. */ + private fun start(ctx: Context, intent: Intent, failed: String) { + if (ctx !is Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + ctx.startActivity(intent) + } catch (e: ActivityNotFoundException) { + toast(NO_APP, Toast.LENGTH_LONG, ctx) + } catch (e: Exception) { + toast(if (tooLarge(e)) INTENT_TOO_LARGE else failed, Toast.LENGTH_LONG, ctx) + } + } + @SuppressLint("SetJavaScriptEnabled") fun createWebView(context: Context): WebView = WebView(context).apply { val gen = page.build() @@ -353,14 +464,9 @@ class TerminalController( setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, true) addJavascriptInterface(Bridge(gen), "NodetermBridge") webViewClient = object : WebViewClient() { - // The page never navigates; a link the user taps opens in the browser, outside this bridge. - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - val url = request.url - if (url.scheme == "http" || url.scheme == "https") { - runCatching { view.context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url.toString()))) } - } - return true - } + // The page never navigates, and nothing it does is opened from here: a link opens only + // through the bridge's openUrl, which asks first (audit A32). A navigation is refused. + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = true // The renderer was killed or crashed. The default (false) takes the whole app down with // it; true keeps the app, which must then stop using this WebView (A45). @@ -819,6 +925,23 @@ class TerminalController( "Too large to copy: nodeterm copies up to %,d characters to the clipboard.".format(Osc52.MAX_TEXT_CHARS) private const val COPY_REJECTED_SIZE = "Could not copy: too large for the clipboard." private const val COPY_FAILED = "Could not copy to the clipboard." + private const val COPY_SHEET_NOT_READY = "The terminal is not ready yet." + private const val COPY_SHEET_FAILED = "Could not read the terminal's text." + private const val NOTHING_SELECTED = "Select the lines to copy first." + private val SHARE_TOO_LARGE = + "Too large to share: nodeterm shares up to %,d characters.".format(Osc52.MAX_TEXT_CHARS) + private const val SHARE_FAILED = "Could not share the text." + private const val INTENT_TOO_LARGE = "Too large to hand to another app." + private const val OPEN_FAILED = "Could not open the link." + private const val NO_APP = "No app on this phone can open that." private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) + private fun linesCopied(text: String): String { + val lines = text.count { it == '\n' } + 1 + return "Copied $lines line${if (lines == 1) "" else "s"}" + } + + /** A binder transaction refused for its size, rethrown by the framework somewhere in the chain. */ + private fun tooLarge(e: Throwable): Boolean = + generateSequence(e) { it.cause }.take(8).any { it is TransactionTooLargeException } } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt index 954efcc1da..bfed0603ad 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -1,6 +1,9 @@ package dev.nodeterm.android.ui +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,6 +12,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -16,6 +23,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -44,11 +52,14 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import dev.nodeterm.android.Navigator import dev.nodeterm.android.NodetermApp +import dev.nodeterm.protocol.model.ExternalLink import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.TerminalCopy @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -97,119 +108,248 @@ fun TerminalScreen(nav: Navigator, hostId: String, nodeId: String, title: String ) } ) { padding -> - Column(Modifier.fillMaxSize().aboveKeyboard(padding)) { - Box(Modifier.weight(1f).fillMaxWidth().background(NtColors.canvas)) { - // A new key = a new WebView: the old one was destroyed with its renderer (audit A45). - // None at all after a loss until the next attach is asked for (the review of A45). - if (controller.hasWebView) { - key(controller.webViewKey) { - AndroidView(factory = { ctx -> controller.createWebView(ctx) }, modifier = Modifier.fillMaxSize()) - } - } - when (val st = controller.state) { - TermState.Connecting -> Row( - Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - CircularProgressIndicator(Modifier.padding(2.dp)) - Text("Opening terminal…") - } - is TermState.Ended -> Column( - Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(st.message) - Button(onClick = { controller.reattach() }) { Text("Reattach") } + Box(Modifier.fillMaxSize().aboveKeyboard(padding)) { + Column(Modifier.fillMaxSize()) { + Box(Modifier.weight(1f).fillMaxWidth().background(NtColors.canvas)) { + // A new key = a new WebView: the old one was destroyed with its renderer (audit A45). + // None at all after a loss until the next attach is asked for (the review of A45). + if (controller.hasWebView) { + key(controller.webViewKey) { + AndroidView(factory = { ctx -> controller.createWebView(ctx) }, modifier = Modifier.fillMaxSize()) + } } - is TermState.RelayOffer -> Column( - Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(st.message) - Button(onClick = { controller.openThroughRelay() }) { Text("Open through the relay") } + when (val st = controller.state) { + TermState.Connecting -> Row( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator(Modifier.padding(2.dp)) + Text("Opening terminal…") + } + is TermState.Ended -> Column( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.reattach() }) { Text("Reattach") } + } + is TermState.RelayOffer -> Column( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.openThroughRelay() }) { Text("Open through the relay") } + } + is TermState.ViewLost -> Column( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.reopenTerminal() }) { Text("Reopen terminal") } + } + is TermState.AwaitingApproval -> Column( + Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Approve this phone on your computer") + Text(st.sas, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.headlineMedium, color = NtColors.accent) + Text( + "Approve only if the code on the computer matches this one.", + style = MaterialTheme.typography.bodySmall + ) + } + TermState.Attached -> Unit } - is TermState.ViewLost -> Column( - Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(st.message) - Button(onClick = { controller.reopenTerminal() }) { Text("Reopen terminal") } + controller.resumeOffer?.let { offer -> + Column( + Modifier.align(Alignment.TopCenter).fillMaxWidth().background(NtColors.panel2).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(offer.message) + Text(offer.command, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { controller.acceptResume() }, enabled = controller.canResume) { Text(offer.button) } + OutlinedButton(onClick = { controller.dismissResume() }) { Text("Not now") } + } + } } - is TermState.AwaitingApproval -> Column( - Modifier.align(Alignment.Center).background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("Approve this phone on your computer") - Text(st.sas, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.headlineMedium, color = NtColors.accent) - Text( - "Approve only if the code on the computer matches this one.", - style = MaterialTheme.typography.bodySmall - ) + controller.notice?.let { msg -> + Row( + Modifier.align(Alignment.TopCenter).fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(msg, Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { controller.notice = null }) { Text("OK") } + } } - TermState.Attached -> Unit - } - controller.resumeOffer?.let { offer -> - Column( - Modifier.align(Alignment.TopCenter).fillMaxWidth().background(NtColors.panel2).padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text(offer.message) - Text(offer.command, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { controller.acceptResume() }, enabled = controller.canResume) { Text(offer.button) } - OutlinedButton(onClick = { controller.dismissResume() }) { Text("Not now") } + Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth()) { + // A tapped link, offered before anything opens (audit A32). + controller.linkOffer?.let { link -> LinkOffer(controller, link) } + controller.sizedElsewhere?.let { (c, r) -> + Row( + Modifier.fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Sized to another screen (${c}×$r)", Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { controller.fitHere() }) { Text("Fit this screen") } + } } } } - controller.notice?.let { msg -> - Row( - Modifier.align(Alignment.TopCenter).fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(msg, Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) - TextButton(onClick = { controller.notice = null }) { Text("OK") } - } + KeyRow(controller) + // The draft is cleared only once it was sent: while nothing is attached (connecting, + // disconnected, ended) Send is disabled and the keyboard's Send leaves the text in place, + // with the overlay above saying why (A41). Typing a draft meanwhile stays possible. + val send: () -> Unit = { if (controller.submit(draft, enter = true)) draft = "" } + Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Type a command or a prompt") }, + maxLines = 4, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { send() }) + ) + IconButton(onClick = send, enabled = controller.attached) { Icon(Icons.AutoMirrored.Filled.Send, "Send") } } - controller.sizedElsewhere?.let { (c, r) -> - Row( - Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("Sized to another screen (${c}×$r)", Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) - TextButton(onClick = { controller.fitHere() }) { Text("Fit this screen") } + } + // Over the whole body, key row and input bar included: they have nothing to do while copying. + controller.copySheet?.let { snapshot -> CopySheet(controller, snapshot) } + } + } +} + +/** "Open ?" for a link tapped in the terminal (audit A32): the host and the URL, then a choice. */ +@Composable +private fun LinkOffer(controller: TerminalController, link: ExternalLink) { + val ctx = LocalContext.current + Row( + Modifier.fillMaxWidth().background(NtColors.panel2).padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text("Open ${link.host}?", style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + link.url, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = NtColors.muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(onClick = { controller.openLink(ctx, link) }) { Text("Open") } + TextButton(onClick = { controller.copyLink(ctx, link) }) { Text("Copy") } + IconButton(onClick = { controller.dismissLink() }) { Icon(Icons.Filled.Close, "Dismiss") } + } +} + +/** + * The Copy sheet (audit A32): the lines the terminal's buffer held when it was opened, to select and + * copy or share, and the links in them. tmux's mouse keeps xterm's own selection from running, and + * tmux's copy-mode is out of reach of a touch screen, so this is the phone's copy path. A tap selects + * or deselects a line; a long-press selects every line from the last one tapped. The selection logic is + * [TerminalCopy] (tested); this drawing is only type-checked. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CopySheet(controller: TerminalController, snapshot: TerminalCopy.Snapshot) { + val ctx = LocalContext.current + var selection by remember(snapshot) { mutableStateOf(TerminalCopy.Selection()) } + BackHandler { controller.closeCopySheet() } + // The links come first in the list: the sheet opens on the screen's top line below them. + val linkRows = if (snapshot.links.isEmpty()) 0 else snapshot.links.size + 2 + val listState = rememberLazyListState(initialFirstVisibleItemIndex = linkRows + snapshot.firstVisible) + Column(Modifier.fillMaxSize().background(NtColors.panel)) { + Row(Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Copy from the terminal", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) + if (selection.selected.isEmpty()) { + TextButton(onClick = { selection = selection.all(snapshot.lines.size) }, enabled = snapshot.lines.isNotEmpty()) { Text("Select all") } + } else { + TextButton(onClick = { selection = selection.clear() }) { Text("Clear") } + } + IconButton(onClick = { controller.closeCopySheet() }) { Icon(Icons.Filled.Close, "Close") } + } + Text( + "Tap lines to select them. Long-press a line to select everything from the last line you tapped.", + Modifier.padding(horizontal = 12.dp), + style = MaterialTheme.typography.bodySmall, + color = NtColors.muted + ) + LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = listState) { + if (snapshot.links.isNotEmpty()) { + item(key = "links") { SheetHeading("Links") } + items(snapshot.links, key = { "link:" + it.url }) { link -> + Row(Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(link.host, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + link.url, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = NtColors.muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(onClick = { controller.openLink(ctx, link) }) { Text("Open") } + TextButton(onClick = { controller.copyLink(ctx, link) }) { Text("Copy") } } } + item(key = "text") { SheetHeading("Text") } + } + if (snapshot.lines.isEmpty()) { + item(key = "empty") { SheetHeading("The terminal shows no text.") } } - KeyRow(controller) - // The draft is cleared only once it was sent: while nothing is attached (connecting, - // disconnected, ended) Send is disabled and the keyboard's Send leaves the text in place, - // with the overlay above saying why (A41). Typing a draft meanwhile stays possible. - val send: () -> Unit = { if (controller.submit(draft, enter = true)) draft = "" } - Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Type a command or a prompt") }, - maxLines = 4, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { send() }) + itemsIndexed(snapshot.lines) { i, line -> + val selected = i in selection.selected + Text( + line.ifEmpty { " " }, + Modifier + .fillMaxWidth() + .background(if (selected) NtColors.accent.copy(alpha = 0.3f) else NtColors.canvas) + .combinedClickable(onClick = { selection = selection.toggle(i) }, onLongClick = { selection = selection.extendTo(i) }) + .padding(horizontal = 8.dp, vertical = 2.dp), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + color = NtColors.text ) - IconButton(onClick = send, enabled = controller.attached) { Icon(Icons.AutoMirrored.Filled.Send, "Send") } } } + val count = selection.selected.count { it in snapshot.lines.indices } + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + if (count == 0) "No lines selected" else "$count line${if (count == 1) "" else "s"} selected", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall + ) + OutlinedButton(onClick = { controller.shareLines(ctx, selection) }, enabled = count > 0) { Text("Share") } + Button(onClick = { controller.copyLines(ctx, selection) }, enabled = count > 0, modifier = Modifier.padding(start = 8.dp)) { Text("Copy") } + } } } +@Composable +private fun SheetHeading(text: String) { + Text( + text, + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = NtColors.muted + ) +} + /** * The keys a phone keyboard does not have, one tap each. Arrows honour the pane's cursor mode. The * sending keys are disabled while nothing is attached (A41): they used to look sent and reach nothing. - * Ctrl (a modifier for the next key) and ⌨ (opens the keyboard) send nothing themselves. + * Copy (the Copy sheet), Ctrl (a modifier for the next key) and ⌨ (opens the keyboard) send nothing + * themselves. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -220,6 +360,13 @@ private fun KeyRow(controller: TerminalController) { Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { + // First, where the row is not scrolled out of sight (audit A32). Reads what the terminal shows, + // so it works attached or not. The input bar lets go of focus first: its keyboard would sit + // over the sheet. + KeyChip("Copy") { + focusManager.clearFocus() + controller.openCopySheet() + } FilterChip(selected = controller.ctrlArmed, onClick = { controller.ctrlArmed = !controller.ctrlArmed }, label = { Text("Ctrl") }) KeyChip("Esc", on) { controller.key("esc") } KeyChip("Tab", on) { controller.key("tab") } diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ExternalLink.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ExternalLink.kt new file mode 100644 index 0000000000..6d01a5b8fa --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ExternalLink.kt @@ -0,0 +1,53 @@ +package dev.nodeterm.protocol.model + +/** + * A link from the terminal page that the phone may open in a browser (audit A32): an `http` or + * `https` URL, and nothing else. The page (terminal.js) only hands over a URL its URL parser wrote out + * (`new URL(text).href`): printable ASCII, the host lower-cased and punycoded, the rest + * percent-encoded, a backslash in the authority turned into a slash. This check is the app's own, so + * a page that did not do that (a bug, or the bridge called with something else) still cannot hand + * Android an `intent:`, `file:`, `content:` or `javascript:` URI to open. + * + * [host] is what the screen names before it opens the link ("Open ?"). An OSC 8 link shows a + * label and hides its URL, so the user is shown where it really goes, user info stripped: in + * `https://github.com@evil.example/`, the host is `evil.example`. + */ +data class ExternalLink(val url: String, val host: String) { + companion object { + /** Longer than any URL a page is expected to show; an OAuth URL is under 2,000 characters. */ + const val MAX_LENGTH = 8_192 + + private val SCHEME = Regex("^https?://", RegexOption.IGNORE_CASE) + private val PORT = Regex("^[0-9]*$") + + fun parse(url: String): ExternalLink? { + if (url.isEmpty() || url.length > MAX_LENGTH) return null + // Printable ASCII only: no space, no control character, nothing the URL parser would + // have encoded. + if (url.any { it.code !in 0x21..0x7e }) return null + val scheme = SCHEME.find(url) ?: return null + val rest = url.substring(scheme.range.last + 1) + val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' } + val authority = if (end < 0) rest else rest.substring(0, end) + // The URL parser never leaves one here; a browser reads it as a slash, so the host + // named below would not be the one it opens. + if ('\\' in authority) return null + val hostPort = authority.substringAfterLast('@') + val host: String + val port: String + if (hostPort.startsWith('[')) { + val close = hostPort.indexOf(']') + if (close < 0) return null + host = hostPort.substring(0, close + 1) + val after = hostPort.substring(close + 1) + if (after.isNotEmpty() && !after.startsWith(':')) return null + port = after.removePrefix(":") + } else { + host = hostPort.substringBefore(':') + port = hostPort.substringAfter(':', "") + } + if (host.isEmpty() || host == "[]" || !PORT.matches(port)) return null + return ExternalLink(url, host.lowercase()) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalCopy.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalCopy.kt new file mode 100644 index 0000000000..b5db0fdaea --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalCopy.kt @@ -0,0 +1,91 @@ +package dev.nodeterm.protocol.model + +import dev.nodeterm.protocol.model.J.a + +/** + * The terminal screen's Copy sheet (audit A32). tmux runs `mouse on`, so xterm's own selection never + * runs on the phone, and tmux's copy-mode (which would copy through OSC 52) is out of reach of a touch + * screen. The sheet is the copy path that depends on neither: the page (terminal.js `nt.copySheet`) + * hands over the lines its buffer holds (the visible screen; under tmux the alternate screen keeps no + * scrollback, otherwise up to 500 rows of it) and the http(s) links in them; the user selects lines and + * copies or shares them, or opens or copies a link. + * + * The JSON comes from the page and is read like any other input: a wrong-typed field degrades, and + * sizes are bounded here too. + */ +object TerminalCopy { + /** Lines kept from a snapshot, the last ones (the page sends at most 500 rows' worth). */ + const val MAX_LINES = 1_000 + + /** Links kept from a snapshot, the last ones (the page sends at most 50). */ + const val MAX_LINKS = 50 + + /** A snapshot larger than this is refused unread: 1,000 lines of 1,000 columns, escaped. */ + const val MAX_JSON = 4_000_000 + + /** + * What the page's buffer held: its [lines] (soft wraps joined, each right-trimmed), the http(s) + * [links] in them, and [firstVisible], the line at the top of the screen the user was looking at + * (where the sheet opens). + */ + data class Snapshot(val lines: List, val links: List, val firstVisible: Int) + + /** Null when [json] is not a snapshot at all. */ + fun parse(json: String): Snapshot? { + if (json.length > MAX_JSON) return null + val o = J.obj(J.parse(json)) ?: return null + val raw = J.arr(o["lines"]) ?: return null + val all = raw.map { J.str(it) ?: "" } + val lines = all.takeLast(MAX_LINES) + val dropped = all.size - lines.size + val first = (J.long(o["firstVisible"]) ?: 0L) - dropped + val firstVisible = first.coerceIn(0L, maxOf(0, lines.size - 1).toLong()).toInt() + val links = o.a("links").mapNotNull { J.str(it)?.let(ExternalLink::parse) } + .distinctBy { it.url } + .takeLast(MAX_LINKS) + return Snapshot(lines, links, firstVisible) + } + + /** + * Which lines are selected. A tap toggles one line and makes it the [anchor]; a long-press selects + * every line from the anchor to the pressed one (a range, without tapping each line). + */ + data class Selection(val selected: Set = emptySet(), val anchor: Int? = null) { + fun toggle(line: Int): Selection = copy(selected = if (line in selected) selected - line else selected + line, anchor = line) + + fun extendTo(line: Int): Selection { + val from = anchor ?: return copy(selected = selected + line, anchor = line) + return copy(selected = selected + (minOf(from, line)..maxOf(from, line)), anchor = line) + } + + fun all(count: Int): Selection = Selection((0 until count).toSet()) + + fun clear(): Selection = Selection() + } + + sealed interface Text { + /** Copy or share [text], [lines] lines of it. Never empty and never over [Osc52.MAX_TEXT_CHARS]. */ + data class Copy(val text: String, val lines: Int) : Text + + /** Nothing selected, or only empty lines. */ + data object Empty : Text + + /** + * Over [Osc52.MAX_TEXT_CHARS]: the clipboard write, like a share, is a binder call whose shared + * buffer is about 1 MB (audit A53), so the sheet keeps to the OSC 52 copy's cap. + */ + data object TooLarge : Text + } + + /** The selected [lines], in screen order whatever order they were selected in, one per line. */ + fun text(lines: List, selection: Selection): Text { + val picked = selection.selected.filter { it in lines.indices }.sorted() + if (picked.isEmpty()) return Text.Empty + var length = picked.size - 1L + for (i in picked) length += lines[i].length + if (length > Osc52.MAX_TEXT_CHARS) return Text.TooLarge + val text = picked.joinToString("\n") { lines[it] } + if (text.isBlank()) return Text.Empty + return Text.Copy(text, picked.size) + } +} diff --git a/android/protocol/src/test/interop/terminal-js-driver.cjs b/android/protocol/src/test/interop/terminal-js-driver.cjs index 79fc58e0cb..7a9051256b 100644 --- a/android/protocol/src/test/interop/terminal-js-driver.cjs +++ b/android/protocol/src/test/interop/terminal-js-driver.cjs @@ -1,19 +1,37 @@ // Runs the Android app's REAL terminal page script (android/app/src/main/assets/terminal/terminal.js) // in node, against stubs of xterm.js, the DOM and the WebView bridge, so a JVM test can check what -// the page does. Driven today: the OSC 52 handler (audit A53) and the page's focus for the ⌨ chip -// (audit A46). +// the page does. Driven today: the OSC 52 handler (audit A53), the page's focus for the ⌨ chip +// (audit A46), and links and the Copy sheet (audit A32). // // Usage: node terminal-js-driver.cjs // stdin: {"copyLimit": , "osc52": ["", ...], -// "textareaFocused": , "nt": ["", ...]} -// (every field but copyLimit is optional) +// "textareaFocused": , "nt": ["", ...], +// "screen": {"cols": n, "rows": n, "viewportY": n, +// "lines": [{"text": "...", "wrapped": , "links": [{"from": col, "to": col, "uri": "..."}]}]}, +// "provideLinks": [<1-based buffer line>, ...], +// "linkHandler": ["", ...], +// "taps": [{"col": c, "row": , "move": [dx, dy], "fingers": n}], +// "copySheet": } +// (every field but copyLimit is optional; with no "screen" the buffer is 80×24 and empty) // stdout: one JSON object: // {"copyLimitCalls": n, "osc52": [{"returned": , "calls": [[name, arg?], ...]}], -// "nt": [{"fn": name, "focusChanges": ["blur" | "focus", ...], "focusedAfter": bool}]} +// "nt": [{"fn": name, "focusChanges": ["blur" | "focus", ...], "focusedAfter": bool}], +// "provideLinks": [{"links": null | [{"text", "range", "opened": [url, ...]}]}], +// "linkHandler": [{"opened": [url, ...]}], +// "taps": [{"prevented": bool, "opened": [url, ...]}], +// "copySheet": {"raw": "", "calls": n}, +// "confirmCalls": n} // where an onCopy call's argument is reported as {"length": n, "sameAsInput": bool}, so a payload of // several hundred thousand characters never has to be echoed back. The stub textarea follows Blink's // rule for focus(): on the element that already has focus it returns early, changing nothing, and so // does blur() on one that has not. focusChanges lists only the changes that took effect. +// +// The stub buffer answers what terminal.js reads of xterm's: getLine(row).isWrapped, +// translateToString(trim) (a row's text padded to `cols` untrimmed, right-trimmed otherwise), and +// getCell(col).extended.urlId for the cells of an OSC 8 link, whose URI `_core._oscLinkService` holds. +// The screen element sits at (4, 2) with 10×20 px cells; a tap is a touchstart at the cell's centre, +// a touchmove by `move` when given, and a touchend at the end point. `fingers` > 1 starts with that many +// touches. "opened" lists the URLs the page handed bridge.openUrl. 'use strict' const fs = require('fs') const vm = require('vm') @@ -23,6 +41,10 @@ const input = JSON.parse(fs.readFileSync(0, 'utf8')) let current = null let copyLimitCalls = 0 +let opened = [] +let copySheetRaw = null +let copySheetCalls = 0 +let confirmCalls = 0 const bridge = { copyLimit() { copyLimitCalls++ @@ -34,21 +56,88 @@ const bridge = { onCopyTooLarge() { current.calls.push(['onCopyTooLarge']) }, + openUrl(url) { + if (typeof url !== 'string') throw new Error('openUrl takes a string') + opened.push(url) + }, + onCopySheet(json) { + if (typeof json !== 'string') throw new Error('onCopySheet takes a string') + copySheetCalls++ + copySheetRaw = json + }, onResize() {}, onReady() {}, onInput() {}, onScroll() {} } +const screenIn = input.screen || { cols: 80, rows: 24, lines: [] } +const COLS = screenIn.cols +const ROWS = screenIn.rows +const CELL_W = 10 +const CELL_H = 20 +const SCREEN_LEFT = 4 +const SCREEN_TOP = 2 +const linkUris = [] +const rowsData = (screenIn.lines || []).map((l) => { + const text = (l.text || '').slice(0, COLS) + const cellLinks = new Array(COLS).fill(0) + for (const link of l.links || []) { + linkUris.push(link.uri) + const id = linkUris.length + for (let x = link.from; x <= link.to && x < COLS; x++) cellLinks[x] = id + } + return { text, wrapped: !!l.wrapped, cellLinks } +}) +// At least a screenful: rows below the given lines are blank, as in a real buffer. +while (rowsData.length < ROWS) rowsData.push({ text: '', wrapped: false, cellLinks: new Array(COLS).fill(0) }) +const viewportY = screenIn.viewportY === undefined ? Math.max(0, rowsData.length - ROWS) : screenIn.viewportY + +function bufferLine(row) { + const d = rowsData[row] + if (!d) return undefined + return { + isWrapped: d.wrapped, + length: COLS, + translateToString(trim) { + return trim ? d.text.replace(/\s+$/, '') : d.text.padEnd(COLS) + }, + getCell(x) { + if (x < 0 || x >= COLS) return undefined + // A fresh object per read, as xterm's getCell(x) without a cell to reuse. + return { extended: { urlId: d.cellLinks[x] } } + } + } +} + const oscHandlers = {} +const linkProviders = [] let textareaFocused = !!input.textareaFocused let focusChanges = null +const screenElement = { + getBoundingClientRect() { + return { left: SCREEN_LEFT, top: SCREEN_TOP, width: COLS * CELL_W, height: ROWS * CELL_H } + } +} +let createdTerm = null class Terminal { constructor() { - this.cols = 80 - this.rows = 24 + createdTerm = this + this.cols = COLS + this.rows = ROWS this.modes = {} this.options = {} + this.element = undefined + this.buffer = { + active: { length: rowsData.length, viewportY, baseY: viewportY, getLine: bufferLine } + } + this._core = { + _oscLinkService: { + getLinkData(id) { + return id > 0 && id <= linkUris.length ? { id: String(id), uri: linkUris[id - 1] } : undefined + } + } + } this.parser = { registerOscHandler(ident, fn) { oscHandlers[ident] = fn @@ -56,7 +145,17 @@ class Terminal { } } loadAddon() {} - open() {} + open() { + this.element = { + querySelector(sel) { + return sel === '.xterm-screen' ? screenElement : null + } + } + } + registerLinkProvider(provider) { + linkProviders.push(provider) + return { dispose() {} } + } onData() {} onBinary() {} write() {} @@ -76,7 +175,12 @@ class Terminal { class FitAddonStub { fit() {} } -const element = { addEventListener() {} } +const hostListeners = {} +const element = { + addEventListener(type, fn) { + ;(hostListeners[type] = hostListeners[type] || []).push(fn) + } +} const sandbox = { Terminal, @@ -84,7 +188,14 @@ const sandbox = { document: { getElementById: () => element }, window: { NodetermBridge: bridge, addEventListener() {} }, setTimeout: () => 0, + // xterm's default OSC 8 activation asks with confirm(); the page must never get there. + confirm() { + confirmCalls++ + return false + }, TextDecoder, + URL, + JSON, atob } vm.createContext(sandbox) @@ -113,4 +224,91 @@ for (const fn of input.nt || []) { ntResults.push({ fn, focusChanges, focusedAfter: textareaFocused }) focusChanges = null } -process.stdout.write(JSON.stringify({ copyLimitCalls, osc52: results, nt: ntResults }) + '\n') + +const provided = [] +for (const y of input.provideLinks || []) { + if (linkProviders.length !== 1) fail('terminal.js registered ' + linkProviders.length + ' link providers, not 1') + let reply = 'no callback' + linkProviders[0].provideLinks(y, (links) => { + reply = links + }) + if (reply === 'no callback') fail('the link provider did not answer for line ' + y) + provided.push({ + links: reply + ? reply.map((l) => { + opened = [] + l.activate({ type: 'mouseup' }, l.text) + return { text: l.text, range: l.range, opened } + }) + : null + }) +} + +const handled = [] +for (const uri of input.linkHandler || []) { + opened = [] + const linkHandler = optionsOfTerm().linkHandler + if (!linkHandler || typeof linkHandler.activate !== 'function') fail('terminal.js set no options.linkHandler') + if (linkHandler.allowNonHttpProtocols) fail('options.linkHandler.allowNonHttpProtocols is on') + linkHandler.activate({ type: 'mouseup' }, uri, { start: { x: 1, y: 1 }, end: { x: 1, y: 1 } }) + handled.push({ opened }) +} + +function touchAt(x, y) { + return { clientX: x, clientY: y, identifier: 0 } +} +function dispatch(type, event) { + for (const fn of hostListeners[type] || []) fn(event) +} +const tapped = [] +for (const tap of input.taps || []) { + opened = [] + let prevented = false + const x = SCREEN_LEFT + tap.col * CELL_W + CELL_W / 2 + const y = SCREEN_TOP + tap.row * CELL_H + CELL_H / 2 + const fingers = tap.fingers || 1 + const start = [] + for (let i = 0; i < fingers; i++) start.push(touchAt(x + i * 50, y)) + dispatch('touchstart', { touches: start, changedTouches: start, preventDefault() {} }) + let ex = x + let ey = y + if (tap.move) { + ex = x + tap.move[0] + ey = y + tap.move[1] + const moved = [touchAt(ex, ey)] + dispatch('touchmove', { touches: moved, changedTouches: moved, preventDefault() {} }) + } + dispatch('touchend', { + touches: [], + changedTouches: [touchAt(ex, ey)], + preventDefault() { + prevented = true + } + }) + tapped.push({ prevented, opened }) +} + +let copySheet = null +if (input.copySheet) { + if (!nt || typeof nt.copySheet !== 'function') fail('terminal.js has no window.nt.copySheet') + nt.copySheet() + copySheet = { raw: copySheetRaw, calls: copySheetCalls } +} + +function optionsOfTerm() { + if (!createdTerm) fail('terminal.js created no Terminal') + return createdTerm.options +} + +process.stdout.write( + JSON.stringify({ + copyLimitCalls, + osc52: results, + nt: ntResults, + provideLinks: provided, + linkHandler: handled, + taps: tapped, + copySheet, + confirmCalls + }) + '\n' +) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalCopyTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalCopyTest.kt new file mode 100644 index 0000000000..209b1c4b7b --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalCopyTest.kt @@ -0,0 +1,171 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ExternalLink +import dev.nodeterm.protocol.model.Osc52 +import dev.nodeterm.protocol.model.TerminalCopy +import dev.nodeterm.protocol.model.TerminalCopy.Selection +import dev.nodeterm.protocol.model.TerminalCopy.Text +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A32: what the phone opens and copies from its terminal. [ExternalLink] is the app's own gate on a + * link the page hands over (only http(s), with the host the "Open ?" offer names), and + * [TerminalCopy] reads the Copy sheet's snapshot and turns a selection into text under the clipboard + * cap the OSC 52 copy keeps (A53). The page's half is [TerminalJsLinksTest]. + */ +class TerminalCopyTest { + @Test + fun `http and https links are accepted, with the host the offer names`() { + assertEquals(ExternalLink("https://github.com/x/y?z=1#f", "github.com"), ExternalLink.parse("https://github.com/x/y?z=1#f")) + assertEquals("example.com", ExternalLink.parse("HTTP://Example.COM:8080/a")?.host) + assertEquals("[::1]", ExternalLink.parse("http://[::1]:3000/")?.host) + assertEquals("example.com", ExternalLink.parse("https://example.com")?.host) + assertEquals("example.com", ExternalLink.parse("https://example.com?q")?.host) + // A backslash past the authority is the URL parser's own spelling of a query. + assertEquals("a.example", ExternalLink.parse("https://a.example/?x=\\y")?.host) + } + + @Test + fun `the host named is where the link goes, not the user info in front of it`() { + assertEquals("evil.example", ExternalLink.parse("https://github.com@evil.example/login")?.host) + assertEquals("evil.example", ExternalLink.parse("https://user:pw@evil.example/")?.host) + } + + @Test + fun `everything that is not an http(s) URL with a host is refused`() { + val refused = listOf( + "", + "javascript:alert(1)", + "file:///etc/passwd", + "content://com.android.contacts/contacts", + "intent://scan/#Intent;scheme=zxing;end", + "ftp://example.com/x", + "data:text/html,x", + "mailto:a@b.example", + "http:/example.com", + "https://", + "https:///path", + "https://:443/", + "https://user@/x", + "http://[::1/", + "http://[::1]x/", + "http://example.com:80a/", + // Not as the page sends it: space, control and non-ASCII characters are always encoded. + "https://example.com/a b", + "https://example.com/\u0007", + "https://exämple.com/", + // A browser reads a backslash in the authority as a slash, so the host would be github.com + // while the offer named evil.example; the page's URL parser never sends one. + "https://github.com\\@evil.example/", + " https://example.com/", + ) + for (url in refused) assertNull(ExternalLink.parse(url), "accepted: $url") + } + + @Test + fun `a link longer than the cap is refused`() { + val base = "https://example.com/" + assertEquals("example.com", ExternalLink.parse(base + "a".repeat(ExternalLink.MAX_LENGTH - base.length))?.host) + assertNull(ExternalLink.parse(base + "a".repeat(ExternalLink.MAX_LENGTH - base.length + 1))) + } + + private fun snapshotJson(lines: List, links: List = emptyList(), firstVisible: Any? = 0) = buildJsonObject { + putJsonArray("lines") { lines.forEach { add(it.json()) } } + putJsonArray("links") { links.forEach { add(it.json()) } } + when (firstVisible) { + is Int -> put("firstVisible", firstVisible) + is String -> put("firstVisible", firstVisible) + else -> Unit + } + }.toString() + + private fun Any?.json() = when (this) { + null -> kotlinx.serialization.json.JsonNull + is String -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + else -> error("unsupported") + } + + @Test + fun `a snapshot keeps its lines and only the http(s) links, each once`() { + val snap = TerminalCopy.parse( + snapshotJson( + lines = listOf("a", "", "b"), + links = listOf("https://a.example/", "javascript:alert(1)", "https://a.example/", "file:///x", "http://b.example/", 7), + firstVisible = 1 + ) + ) + assertEquals( + TerminalCopy.Snapshot(listOf("a", "", "b"), listOf(ExternalLink("https://a.example/", "a.example"), ExternalLink("http://b.example/", "b.example")), 1), + snap + ) + } + + @Test + fun `a malformed snapshot is refused or degrades, never throws`() { + for (bad in listOf("", "not json", "[]", "{}", "{\"lines\":\"a\"}", "{\"lines\":null}")) assertNull(TerminalCopy.parse(bad), bad) + assertNull(TerminalCopy.parse("{\"lines\":[]," + " ".repeat(TerminalCopy.MAX_JSON) + "\"links\":[]}")) + // A wrong-typed line is empty, a wrong-typed or out-of-range firstVisible is clamped. + assertEquals(TerminalCopy.Snapshot(listOf("a", "", ""), emptyList(), 0), TerminalCopy.parse(snapshotJson(listOf("a", 3, null), firstVisible = "x"))) + assertEquals(2, TerminalCopy.parse(snapshotJson(listOf("a", "b", "c"), firstVisible = 99))?.firstVisible) + assertEquals(0, TerminalCopy.parse(snapshotJson(listOf("a"), firstVisible = -4))?.firstVisible) + assertEquals(TerminalCopy.Snapshot(emptyList(), emptyList(), 0), TerminalCopy.parse("{\"lines\":[]}")) + } + + @Test + fun `a snapshot keeps the last lines and links, and the visible line moves with the cut`() { + val lines = (0 until TerminalCopy.MAX_LINES + 10).map { "l$it" } + val links = (0 until TerminalCopy.MAX_LINKS + 5).map { "https://h$it.example/" } + val snap = TerminalCopy.parse(snapshotJson(lines, links, firstVisible = TerminalCopy.MAX_LINES + 3))!! + assertEquals(lines.takeLast(TerminalCopy.MAX_LINES), snap.lines) + assertEquals(links.takeLast(TerminalCopy.MAX_LINKS), snap.links.map { it.url }) + assertEquals("l${TerminalCopy.MAX_LINES + 3}", snap.lines[snap.firstVisible]) + } + + @Test + fun `a tap toggles a line and a long-press selects the range from the last line tapped`() { + var s = Selection() + s = s.toggle(3) + assertEquals(Selection(setOf(3), anchor = 3), s) + s = s.extendTo(6) + assertEquals(setOf(3, 4, 5, 6), s.selected) + // From the new anchor, upwards, added to what is selected. + s = s.toggle(9).extendTo(8) + assertEquals(setOf(3, 4, 5, 6, 8, 9), s.selected) + // A tap on a selected line deselects it and still anchors there. + s = s.toggle(4) + assertEquals(Selection(setOf(3, 5, 6, 8, 9), anchor = 4), s) + // A long-press with nothing tapped yet selects that line. + assertEquals(Selection(setOf(2), anchor = 2), Selection().extendTo(2)) + assertEquals(Selection(setOf(0, 1, 2)), s.all(3)) + assertEquals(Selection(), s.clear()) + } + + @Test + fun `the selected lines are copied in screen order, one per line`() { + val lines = listOf("one", "two", "", "four") + assertEquals(Text.Copy("one\n\nfour", 3), TerminalCopy.text(lines, Selection(setOf(3, 0, 2)))) + assertEquals(Text.Copy("two", 1), TerminalCopy.text(lines, Selection(setOf(1, 7, -1)))) + assertEquals(Text.Empty, TerminalCopy.text(lines, Selection())) + assertEquals(Text.Empty, TerminalCopy.text(lines, Selection(setOf(2)))) + assertEquals(Text.Empty, TerminalCopy.text(lines, Selection(setOf(9)))) + } + + @Test + fun `a selection over the clipboard cap is refused before any text is built`() { + val half = Osc52.MAX_TEXT_CHARS / 2 + // Two lines and the newline between them: exactly at the cap, then one over. + val atCap = listOf("a".repeat(half), "b".repeat(Osc52.MAX_TEXT_CHARS - half - 1)) + val copied = TerminalCopy.text(atCap, Selection(setOf(0, 1))) + assertEquals(Osc52.MAX_TEXT_CHARS, (copied as Text.Copy).text.length) + val over = listOf("a".repeat(half), "b".repeat(Osc52.MAX_TEXT_CHARS - half)) + assertEquals(Text.TooLarge, TerminalCopy.text(over, Selection(setOf(0, 1)))) + assertEquals(Text.TooLarge, TerminalCopy.text(listOf("x".repeat(Osc52.MAX_TEXT_CHARS + 1)), Selection(setOf(0)))) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLinksTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLinksTest.kt new file mode 100644 index 0000000000..6363a13fa5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLinksTest.kt @@ -0,0 +1,308 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ExternalLink +import dev.nodeterm.protocol.model.TerminalCopy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * A32: links and copying in the phone's terminal, run in the app's real terminal.js + * ([TerminalJsDriver]) against a stub xterm buffer. + * + * - A URL is matched across the rows it wraps over, both xterm's soft wraps and the full-width rows a + * tmux repaint or an agent's fullscreen TUI paints with no wrap flag (the desktop's file-links.ts, + * ported). Matched row by row, a long OAuth URL opened only its first row's fragment. + * - A tap on a link hands it to the bridge's openUrl and cancels the touchend, so the click the tap + * would make never reaches tmux (it runs `mouse on`); any other tap is left alone. + * - Only http(s) reaches the bridge, from every way in: a tap, the link provider, and OSC 8 links + * through `options.linkHandler` (without one, xterm asks with confirm(), which the WebView cannot show). + * - The Copy sheet gets the buffer's lines (soft wraps joined) and its links, in a shape [TerminalCopy] + * reads. + * + * Whether a tap on a phone produces exactly these events, and the app's Android half (the "Open + * ?" offer, the intent, the sheet), are left to the device checklist. Skips without node. + */ +class TerminalJsLinksTest { + private data class Line(val text: String, val wrapped: Boolean = false, val links: List> = emptyList()) + private data class Tap(val col: Int, val row: Int, val move: Pair? = null, val fingers: Int = 1) + private data class Tapped(val prevented: Boolean, val opened: List) + + private fun screen(cols: Int, rows: Int, lines: List, viewportY: Int? = null) = buildJsonObject { + put("cols", cols) + put("rows", rows) + if (viewportY != null) put("viewportY", viewportY) + putJsonArray("lines") { + for (l in lines) add( + buildJsonObject { + put("text", l.text) + put("wrapped", l.wrapped) + putJsonArray("links") { + for ((from, to, uri) in l.links) add(buildJsonObject { put("from", from); put("to", to); put("uri", uri) }) + } + } + ) + } + } + + private fun run(screen: JsonObject, build: JsonObjectBuilder.() -> Unit): JsonObject = + TerminalJsDriver.run( + buildJsonObject { + put("copyLimit", 8) + put("screen", screen) + build() + } + ) + + private fun taps(screen: JsonObject, vararg taps: Tap): List { + val reply = run(screen) { + putJsonArray("taps") { + for (t in taps) add( + buildJsonObject { + put("col", t.col) + put("row", t.row) + if (t.move != null) put("move", buildJsonArray { add(JsonPrimitive(t.move.first)); add(JsonPrimitive(t.move.second)) }) + put("fingers", t.fingers) + } + ) + } + } + assertEquals(0, reply["confirmCalls"]!!.jsonPrimitive.int, "terminal.js reached xterm's confirm()") + return reply["taps"]!!.jsonArray.map { r -> + val o = r.jsonObject + Tapped(o["prevented"]!!.jsonPrimitive.boolean, o["opened"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + } + + private fun opened(url: String) = Tapped(prevented = true, opened = listOf(url)) + private val passedOn = Tapped(prevented = false, opened = emptyList()) + + // A 20-column screen holding a URL that a tmux repaint split over three full-width rows: no row + // carries a wrap flag, each continuing row is full to its last column, and the next starts at 0. + private val oauth = "https://example.com/oauth/authorize?client=abc" + private val repainted = screen( + cols = 20, + rows = 6, + lines = listOf( + Line("Open: https://exampl"), // 20 columns + Line("e.com/oauth/authoriz"), // 20 columns + Line("e?client=abc"), + Line("not a link here"), + ) + ) + + @Test + fun `a URL repainted over several full-width rows opens whole, from a tap on any of its rows`() { + // The first row's fragment alone ("https://exampl") is what a row-by-row match opened. + val results = taps(repainted, Tap(col = 8, row = 0), Tap(col = 3, row = 1), Tap(col = 5, row = 2)) + assertEquals(List(3) { opened(oauth) }, results) + } + + @Test + fun `a URL xterm soft-wrapped opens whole`() { + val soft = screen( + cols = 20, + rows = 4, + lines = listOf( + // A soft wrap need not fill the row: the flag says the next row continues it. + Line("x https://example.co"), + Line("m/a/b/c d", wrapped = true), + ) + ) + assertEquals(listOf(opened("https://example.com/a/b/c"), opened("https://example.com/a/b/c")), taps(soft, Tap(4, 0), Tap(2, 1))) + } + + // 70 rows of a 20-column screen that each run into the next by the repaint heuristic (full to the + // last column, the next starting at column 0), as a TUI's bordered box paints them, with a URL on + // row 40 below 40 such rows. The desktop's paragraphContaining walked up 32 rows and joined only + // the 32 above row 40, so the paragraph "containing" it did not, and a scan that steps from one + // paragraph to the next stood still on it. + private val wall = screen( + cols = 20, + rows = 5, + lines = List(40) { Line("-".repeat(20)) } + Line("https://a.example/b") + List(29) { Line("-".repeat(20)) }, + viewportY = 38 + ) + + @Test + fun `a URL under a wall of continuing rows is still found by a tap`() { + assertEquals(listOf(opened("https://a.example/b")), taps(wall, Tap(col = 3, row = 2))) + } + + @Test + fun `the Copy sheet gets through a wall of continuing rows`() { + // Without the fix this never returned: the driver times out, and on the phone the page hangs. + val (json, _) = copySheet(wall) + assertEquals(70, json["lines"]!!.jsonArray.size) + assertEquals(listOf("https://a.example/b"), json["links"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + + @Test + fun `a tap off a link, a swipe, and a second finger reach tmux untouched`() { + val results = taps( + repainted, + Tap(col = 2, row = 0), // "Open:" before the URL + Tap(col = 4, row = 3), // plain text + Tap(col = 15, row = 2), // past the end of the URL + Tap(col = 8, row = 0, move = 0 to 40), // a swipe that starts on the URL scrolls instead + Tap(col = 8, row = 0, move = 11 to 0), // just past the slop + Tap(col = 8, row = 0, fingers = 2), + ) + assertEquals(List(6) { passedOn }, results) + } + + @Test + fun `a tap that wobbles within the slop is still a tap`() { + assertEquals(listOf(opened(oauth)), taps(repainted, Tap(col = 8, row = 0, move = 6 to -6))) + } + + @Test + fun `a tap on an OSC 8 link opens the URL behind its label, and only an http(s) one`() { + val osc8 = screen( + cols = 30, + rows = 3, + lines = listOf( + Line("see the docs here", links = listOf(Triple(8, 11, "https://docs.example.com/guide"))), + Line("run evil now", links = listOf(Triple(4, 7, "javascript:alert(1)"))), + Line("a local file", links = listOf(Triple(2, 6, "file:///etc/passwd"))), + ) + ) + val results = taps(osc8, Tap(9, 0), Tap(5, 1), Tap(3, 2), Tap(0, 0)) + assertEquals(listOf(opened("https://docs.example.com/guide"), passedOn, passedOn, passedOn), results) + } + + @Test + fun `only http(s) is ever matched in the text`() { + val other = screen( + cols = 40, + rows = 3, + lines = listOf(Line("ftp://example.com/x javascript:alert(1)"), Line("file:///etc/passwd mailto:a@b.example")) + ) + val results = taps(other, Tap(3, 0), Tap(25, 0), Tap(3, 1), Tap(25, 1)) + assertEquals(List(4) { passedOn }, results) + } + + @Test + fun `the link provider underlines the whole wrapped URL on every row and opens it`() { + val reply = run(repainted) { putJsonArray("provideLinks") { add(JsonPrimitive(1)); add(JsonPrimitive(3)); add(JsonPrimitive(4)) } } + val provided = reply["provideLinks"]!!.jsonArray.map { it.jsonObject["links"]!! } + for (row in 0..1) { + val link = provided[row].jsonArray.single().jsonObject + assertEquals(oauth, link["text"]!!.jsonPrimitive.content) + // 1-based and inclusive: from column 7 of the first row to column 12 of the third. + val range = link["range"]!!.jsonObject + assertEquals(listOf(7, 1, 12, 3), listOf("start", "end").flatMap { k -> listOf("x", "y").map { range[k]!!.jsonObject[it]!!.jsonPrimitive.int } }) + assertEquals(listOf(oauth), link["opened"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + assertEquals(JsonNull, provided[2], "a row with no URL has no links") + } + + @Test + fun `OSC 8 links go through options linkHandler, never confirm(), and only http(s) is opened`() { + val uris = listOf("https://a.example/x", "HTTP://B.Example/y", "javascript:alert(1)", "file:///etc/passwd", "intent://x#Intent;end", "not a url") + val reply = run(screen(20, 3, emptyList())) { putJsonArray("linkHandler") { uris.forEach { add(JsonPrimitive(it)) } } } + val opened = reply["linkHandler"]!!.jsonArray.map { r -> r.jsonObject["opened"]!!.jsonArray.map { it.jsonPrimitive.content } } + // The URL parser's own spelling crosses the bridge: scheme and host lower-cased. + assertEquals(listOf(listOf("https://a.example/x"), listOf("http://b.example/y"), emptyList(), emptyList(), emptyList(), emptyList()), opened) + assertEquals(0, reply["confirmCalls"]!!.jsonPrimitive.int) + } + + @Test + fun `everything that crosses the bridge is a link the app accepts`() { + val reply = run(repainted) { + putJsonArray("linkHandler") { add(JsonPrimitive("https://a.example/x?q=a b")) } + putJsonArray("taps") { add(buildJsonObject { put("col", 8); put("row", 0) }) } + } + val crossed = (reply["linkHandler"]!!.jsonArray + reply["taps"]!!.jsonArray) + .flatMap { r -> r.jsonObject["opened"]!!.jsonArray.map { it.jsonPrimitive.content } } + assertEquals(2, crossed.size) + for (url in crossed) assertNotNull(ExternalLink.parse(url), "ExternalLink refuses what terminal.js sends: $url") + } + + private fun copySheet(screen: JsonObject): Pair { + val reply = run(screen) { put("copySheet", true) } + val sheet = reply["copySheet"]!!.jsonObject + assertEquals(1, sheet["calls"]!!.jsonPrimitive.int) + val raw = sheet["raw"]!!.jsonPrimitive.content + return Json.parseToJsonElement(raw).jsonObject to raw + } + + @Test + fun `the Copy sheet gets the lines, soft wraps joined and repainted rows kept apart, and the links`() { + val buffer = screen( + cols = 20, + rows = 8, + lines = listOf( + Line("Open: https://exampl"), + Line("e.com/oauth/authoriz"), + Line("e?client=abc"), + Line(""), + Line("a long line that xte"), + Line("rm wrapped itself", wrapped = true), + // The same URI under two link ids (xterm keys OSC 8 links by id): listed once. + Line("docs", links = listOf(Triple(0, 3, "https://docs.example.com/"), Triple(0, 0, "https://docs.example.com/"))), + Line("evil", links = listOf(Triple(0, 3, "javascript:alert(1)"))), + ) + ) + val (json, raw) = copySheet(buffer) + assertEquals( + listOf( + "Open: https://exampl", + "e.com/oauth/authoriz", + "e?client=abc", + "", + "a long line that xterm wrapped itself", + "docs", + "evil" + ), + json["lines"]!!.jsonArray.map { it.jsonPrimitive.content } + ) + assertEquals(listOf(oauth, "https://docs.example.com/"), json["links"]!!.jsonArray.map { it.jsonPrimitive.content }) + // The app reads what the page sends. + val parsed = assertNotNull(TerminalCopy.parse(raw)) + assertEquals(listOf(oauth, "https://docs.example.com/"), parsed.links.map { it.url }) + assertEquals(7, parsed.lines.size) + } + + @Test + fun `the Copy sheet drops the blank rows under the text and opens at the visible screen`() { + // 30 rows of scrollback above a 5-row screen (a buffer with scrollback: no tmux). + val lines = (0 until 33).map { Line("line $it") } + val (json, raw) = copySheet(screen(cols = 20, rows = 5, lines = lines + Line("") + Line(""), viewportY = 30)) + assertEquals((0 until 33).map { "line $it" }, json["lines"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(30, json["firstVisible"]!!.jsonPrimitive.int) + assertEquals(30, assertNotNull(TerminalCopy.parse(raw)).firstVisible) + } + + @Test + fun `the Copy sheet takes at most the last 500 rows, without a line cut off at the top`() { + val lines = (0 until 600).map { i -> if (i == 100) Line("cont", wrapped = true) else Line("r$i") } + val (json, _) = copySheet(screen(cols = 10, rows = 5, lines = lines)) + val got = json["lines"]!!.jsonArray.map { it.jsonPrimitive.content } + // Row 100 continues row 99, which is outside the 500: it is not offered as a line of its own. + assertEquals((101 until 600).map { "r$it" }, got) + } + + @Test + fun `an empty screen gives an empty sheet`() { + val (json, raw) = copySheet(screen(cols = 20, rows = 4, lines = emptyList())) + assertTrue(json["lines"]!!.jsonArray.isEmpty()) + assertTrue(json["links"]!!.jsonArray.isEmpty()) + assertEquals(TerminalCopy.Snapshot(emptyList(), emptyList(), 0), TerminalCopy.parse(raw)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalLinksWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalLinksWiringTest.kt new file mode 100644 index 0000000000..7c41358a08 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalLinksWiringTest.kt @@ -0,0 +1,72 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A32: the Android half of links and copying, which cannot run on a JVM, pinned in the app's source. + * The page's half is [TerminalJsLinksTest]; the rules both halves delegate to are [TerminalCopyTest]. + * What it does on a phone is the device checklist's. + */ +class TerminalLinksWiringTest { + private val controller = AppSourcePins.ui("TerminalController.kt") + private val screen = AppSourcePins.ui("TerminalScreen.kt") + private val assets = File(InteropHarness.repoRoot, "android/app/src/main/assets/terminal") + + @Test + fun `a link from the page is offered only once ExternalLink accepts it, never opened on the tap`() { + val bridge = AppSourcePins.blockAfter(controller, "fun openUrl(url: String)") + AppSourcePins.assertInOrder(bridge, "if (!page.isCurrent(gen)) return", "ExternalLink.parse(url) ?: return", "linkOffer = link") + assertFalse("startActivity" in bridge, "the bridge opened the link on the tap itself:\n$bridge") + assertEquals(1, Regex("""linkOffer = link\b""").findAll(controller).count(), "linkOffer is set from somewhere else") + } + + @Test + fun `opening is a browsable ACTION_VIEW behind a catch, from the user's Open only`() { + val open = AppSourcePins.blockAfter(controller, "fun openLink(ctx: Context, link: ExternalLink)") + AppSourcePins.assertInOrder(open, "Intent(Intent.ACTION_VIEW, Uri.parse(link.url))", ".addCategory(Intent.CATEGORY_BROWSABLE)", "start(ctx, view") + val start = AppSourcePins.blockAfter(controller, "private fun start(ctx: Context, intent: Intent, failed: String)") + AppSourcePins.assertInOrder(start, "try {", "ctx.startActivity(intent)", "catch (e: ActivityNotFoundException)", "catch (e: Exception)") + // The only ACTION_VIEW: the WebViewClient no longer opens what the page navigates to. + assertEquals(1, Regex("""Intent\.ACTION_VIEW""").findAll(controller).count()) + val client = AppSourcePins.blockAfter(controller, "fun createWebView(") + assertTrue("override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = true" in client, client) + // Both screen surfaces call it with the screen's own context. + assertEquals(2, Regex("""controller\.openLink\(ctx, link\)""").findAll(screen).count()) + } + + @Test + fun `copied and shared text keeps to the clipboard cap and a refused write is caught`() { + for (fn in listOf("fun copyLines(ctx: Context, selection: TerminalCopy.Selection)", "fun shareLines(ctx: Context, selection: TerminalCopy.Selection)")) { + val body = AppSourcePins.blockAfter(controller, fn) + AppSourcePins.assertInOrder(body, "TerminalCopy.text(lines, selection)", "is TerminalCopy.Text.Copy ->", "TerminalCopy.Text.TooLarge ->") + } + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(controller, "fun shareLines("), "Intent(Intent.ACTION_SEND)", "start(ctx, Intent.createChooser(send, null)") + val write = AppSourcePins.blockAfter(controller, "private fun writeClipboard(") + AppSourcePins.assertInOrder(write, "try {", "cm.setPrimaryClip(", "} catch (e: Exception) {") + } + + @Test + fun `the Copy chip opens the sheet, which a page not yet loaded is never asked for`() { + val chip = AppSourcePins.blockAfter(screen, "KeyChip(\"Copy\")") + AppSourcePins.assertInOrder(chip, "focusManager.clearFocus()", "controller.openCopySheet()") + val open = AppSourcePins.blockAfter(controller, "fun openCopySheet()") + AppSourcePins.assertInOrder(open, "!page.isReady", "return", "evaluateJavascript(\"nt.copySheet()\"") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(controller, "fun onCopySheet(json: String)"), "TerminalCopy.parse(json)", "copySheet = snapshot") + AppSourcePins.assertInOrder(screen, "controller.copySheet?.let { snapshot -> CopySheet(controller, snapshot) }") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "private fun CopySheet("), "BackHandler { controller.closeCopySheet() }") + } + + @Test + fun `the page loads no web-links addon, which cannot join repainted rows`() { + val html = File(assets, "index.html").readText() + val js = File(assets, "terminal.js").readText() + assertFalse(Regex("""web-?links""", RegexOption.IGNORE_CASE).containsMatchIn(html), html) + assertFalse("WebLinksAddon" in js) + assertFalse(File(assets, "addon-web-links.js").exists()) + assertTrue("term.registerLinkProvider(" in js && "term.options.linkHandler = {" in js) + } +} diff --git a/docs/android.md b/docs/android.md index c7dbfc0d92..2464889662 100644 --- a/docs/android.md +++ b/docs/android.md @@ -201,6 +201,32 @@ cap does not respect. `TerminalJsOsc52Test` runs the app's real `terminal.js` in xterm/bridge objects and checks that it applies that cap before a copy crosses the WebView bridge. The app also catches a failing `setPrimaryClip` and says so in a toast; that part is not tested. +Links and copying in the terminal (`A32`) had no way in: tmux runs `mouse on`, so xterm's own +selection never runs, its link handling stands aside, and tmux's copy-mode is out of easy reach on a +touch screen. `TerminalJsLinksTest` runs the real `terminal.js` in node against a stub xterm buffer. +A URL is matched across the rows it wraps over: xterm's soft wraps, and the full-width rows a tmux +repaint or an agent's fullscreen TUI paints with no wrap flag (the desktop's `file-links.ts` URL +matcher, ported; `@xterm/addon-web-links` joins only soft wraps, so a long OAuth URL opened its first +row's fragment). A tap on a link, an OSC 8 link included (its label hides the URL), hands the bridge +the URL as the URL parser writes it and cancels the touchend, so the click the tap makes never +reaches tmux or the app in the pane; a tap anywhere else, a swipe or two fingers are left alone. OSC 8 +links go through `options.linkHandler`, never xterm's `confirm()` (the WebView has no WebChromeClient +to show one), and only http(s) crosses from any way in. The app then names the host and shows the URL +(`Open ?`), and opens it with a browsable `ACTION_VIEW` only on Open; `ExternalLink` +(`TerminalCopyTest`) checks the URL again: http(s), a host, printable ASCII, and the host named is the +one after any user info. The key row's Copy chip opens a sheet of what the buffer holds (its last 500 +rows, which under tmux is the visible screen; soft wraps joined) and the links in it. `TerminalCopyTest` +covers how `TerminalCopy` reads that snapshot, the selection (a tap toggles a line, a long-press +selects the range from the last line tapped), and the 100,000-character cap the OSC 52 copy keeps, +for Copy and Share alike. `TerminalLinksWiringTest` pins the Android half in the source. The offer, +the sheet, the intents, and whether a tap on a phone produces the events the page expects are a +device check. The matching shares the desktop's limits: text that exactly fills a row can be joined +with the next, a URL inside a box a TUI draws with `│` at both edges is not joined, and each CJK +character before a URL on its row shifts where a tap lands by a column. One difference: below a run +of more than 32 such full-width rows, the desktop's join could leave out the row asked about (a +missed link there); the port's always includes it, which also keeps the Copy sheet's scan through +such a run from standing still (a test runs one). + An SSH test pins that a server which completes the key exchange and then refuses the phone's key (or user) leaves the host-key pin empty (`A49`). `SshFallbackTest` pins what follows a failed SSH leg (`A74`): a changed key goes on to the relay in Auto with a warning, stops on the SSH-only route, @@ -430,7 +456,7 @@ later fix left to a device. one of the two phones on an entitled (Pro) desktop: the other keeps working. A cloud backup restored onto a fresh install brings back nothing of the app either. *(A51)* 5. Once a release signing key exists: install the signed, minified release APK and run the pairing, - SSH, relay, OSC 52 copy and background-notification items on it, since that is where code R8 could + SSH, relay, OSC 52 copy, links and Copy sheet, and background-notification items on it, since that is where code R8 could have broken runs (BouncyCastle's provider tables on the first connect, the WebView bridge, the WorkManager worker). An error message names a real exception class, not an obfuscated one. `adb shell run-as dev.nodeterm.android` is refused on it, while on the debug APK it opens a shell @@ -489,8 +515,9 @@ later fix left to a device. key chip (Esc, Tab, ⇧Tab, the arrows, ⏎, ⇧⏎, ^C, ^D, ^R, ^L, Home, End, PgUp, PgDn): the connection survives all of it. *(A01, A04)* 19. Non-ASCII renders over SSH: Claude's rounded borders, accented letters, CJK, emoji. *(A03)* -20. Swipe to scroll the tmux history. Select text in tmux: the copy reaches Android's clipboard (OSC 52) - with a "Copied N lines" toast. *(A65)* +20. Swipe to scroll the tmux history. A copy the pane makes reaches Android's clipboard (OSC 52) with a + "Copied N lines" toast: in tmux's copy-mode (Ctrl, then b, then [ from the key row and the input + bar), or from an application such as vim (`"+y`). *(A65)* 21. A large OSC 52 copy. In the pane, run `printf '\033]52;c;%s\a' "$(head -c 150000 /dev/zero | tr '\0' x | base64 | tr -d '\n')"` (the desktop's tmux passes an application's OSC 52 on): the phone says it is too large to copy and @@ -619,6 +646,25 @@ later fix left to a device. network, a rejected push) shows git's own message. An SSH project and a project with no folder say why instead of opening. *(A29)* +### Links and copy in the terminal + +54. Print a URL long enough to wrap over several rows, e.g. + `printf 'https://example.com/%s\n' "$(head -c 300 /dev/zero | tr '\0' a)"` in a shell, and ask a + Claude session to print one: tap the URL on its first, a middle and its last row. Each time a bar names the host + and shows the WHOLE URL, and nothing reached the pane (no click in Claude, no soft keyboard). Open + opens the browser on the whole URL; Copy puts it on the clipboard ("Copied the link"); × closes the + bar. A tap on plain text, and a swipe that starts on a URL, behave as before. *(A32)* +55. An OSC 8 link: `printf '\033]8;;https://example.com/osc8\033\\label\033]8;;\033\\\n'` in the + pane, then tap "label": the bar offers example.com and opens `https://example.com/osc8`. The same + with `file:///etc/passwd` in place of the URL offers nothing. With a mouse connected, a click on a + link in a session that is not under tmux (a Windows computer's), where the pane does not report + the mouse, offers it too. *(A32)* +56. The key row's Copy chip opens a sheet of the screen's lines (with a session that is not under tmux, + some scrollback too) and, above them, its links with Open and Copy. Tap lines to select them, + long-press one to select the range from the last line tapped; Copy puts them on the clipboard + ("Copied N lines"), Share opens the system share sheet, and Back or × closes the sheet. The sheet + opens on the line at the top of the screen. *(A32)* + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 4ffb8b7acc5d9a289724fa08792148a3ea2baf37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:25:42 +0000 Subject: [PATCH 095/284] feat(android): answer from a notification, and its tap opens the session (A25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app part of A25. An Inbox notification had no actions, and its tap opened only the computer's Inbox tab. The FCM backend leg stays a documented gap (no wake-up push, no Live Activity equivalent). What changed: - New pure protocol object InboxNotificationActions: - plan(): which actions an event gets, on the Inbox card's own rules. Approve / Deny only where QuickActions can answer from outside the session (new QuickActions.answersApproval: a held ticket, or a claude prompt; answerApproval now asks the same predicate). A single-select question gets its options, at most three (Android's limit). More options, a multi-select question, another agent's ticketless approval, and a computer reachable only through a first relay handshake get "Open" instead. With "Show details in notifications" off, options are labelled "Option N" (A52). - perform(): runs the answer through QuickActions (still unresolved, node-state rules, the A38 ticketed path), retries through the relay on NeedsRelayException (A09), bounded dial and answer budgets, and is never retried. UNREACHABLE (nothing sent) is kept apart from UNCONFIRMED (may have arrived). - outcomeOf() / settled(): Approved / Denied / Answered with option N / Already handled go away after 10 s. Timed out, answer in the session, not sent (why) and unconfirmed stay, and their tap opens the session. - Request: the tap's hand-off as one JSON string (Intent extra, then work input). It carries no event title or detail. - App: InboxNotifier adds the planned actions. Each answering action is an explicit, FLAG_IMMUTABLE broadcast to the new unexported InboxActionReceiver, with a distinct data URI per notification and action, and setAuthenticationRequired(true). The receiver refuses a locked phone on Android 11 and lower, where the system does not ask for the unlock. Otherwise it reposts the notification without actions ("Approving…") and enqueues expedited unique work (KEEP, one answer per event). InboxActionWorker connects with Trigger.BACKGROUND only, for both the primary connection and viaRelay, after a pre-flight HostSession.reachableQuietly() check (an open connection, the SSH leg, or a relay that already approved this phone). So it never makes a first relay handshake: nobody is at the app to compare the desktop's code (A05). It then settles the notification. getForegroundInfo() runs on a new low-importance "Sending answers" channel for Android 11 and lower. - The tap (content intent and the Open action) carries the node id and title. MainActivity opens Route.Terminal over Route.Host(tab = 2), both at launch and in onNewIntent (A11/A19 path). The content PendingIntent's request code is now the notification id: with one per computer and FLAG_UPDATE_CURRENT, every notification of a computer would open the last one's session. - R8 keep rule plus check-r8-output.sh line for the new worker. FOREGROUND_SERVICE is declared explicitly. Type-check stubs added for NotificationCompat.Action, OneTimeWorkRequest, Data, ForegroundInfo, ExistingWorkPolicy and OutOfQuotaPolicy. - Docs: docs/android.md gains a verification paragraph, an updated checklist item 43, new items 57-59 and a fuller Push known gap; android/README.md's Notifications section describes the actions. Departures from the audit and the task: - "Opens the session for EXPIRED / OPEN_SESSION": the worker cannot start an activity. Android 12 forbids notification trampolines and Android 10 forbids background activity starts. The notification instead says "Tap to ..." and its tap opens the terminal. - Added: answering actions require an unlocked phone (A52's lock-screen concern applied to answers). - No separate "Open" action next to Approve/Deny: the tap opens the session. Verified: NotificationActionsTest (31 tests: the plan, quiet reach, the outcomes, perform() against QuickActions with fake connections, the request codec, and source pins for the PendingIntents, the unexported receiver, the trigger, the expedited unique work and the tap route). Mutation-checked: removing each of the terminal push, the onNewIntent tap, setAuthenticationRequired, the per-notification request code, the BACKGROUND trigger, the pre-flight check, the reachability gate, the three-option cap, the label redaction, the answersApproval gate, the relay retry and the UNCONFIRMED split, plus exporting the receiver, fails a test. Dropping the new keep rule fails R8RulesTest. Full protocol suite: 432 tests, 0 failures, 0 skipped (gradle test --offline). Offline app type-check passes. No desktop code changed. Not built with AGP here (Google Maven blocked) and not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 11 + android/app/proguard-rules.pro | 9 +- android/app/src/main/AndroidManifest.xml | 9 + .../dev/nodeterm/android/MainActivity.kt | 48 +- .../android/conn/ConnectionManager.kt | 15 + .../nodeterm/android/notify/InboxActions.kt | 105 ++++ .../nodeterm/android/notify/InboxNotifier.kt | 157 ++++- android/app/src/main/res/values/strings.xml | 2 + .../protocol/host/InboxNotificationActions.kt | 363 +++++++++++ .../nodeterm/protocol/host/QuickActions.kt | 11 +- .../protocol/host/RelayApprovalGate.kt | 5 +- .../protocol/NotificationActionsTest.kt | 594 ++++++++++++++++++ android/tools/check-r8-output.sh | 19 +- .../androidx/core/app/NotificationCompat.java | 12 + .../stubs/java/androidx/work/Data.java | 8 + .../androidx/work/ExistingWorkPolicy.java | 2 + .../java/androidx/work/ForegroundInfo.java | 5 + .../java/androidx/work/ListenableWorker.java | 1 + .../androidx/work/OneTimeWorkRequest.java | 9 + .../java/androidx/work/OutOfQuotaPolicy.java | 2 + .../stubs/java/androidx/work/WorkManager.java | 1 + .../stubs/java/dev/nodeterm/android/R.java | 2 +- .../stubs/kotlin/androidx/work/Stubs.kt | 3 + docs/android.md | 62 +- 24 files changed, 1420 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxActions.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/InboxNotificationActions.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/NotificationActionsTest.kt create mode 100644 android/tools/typecheck/stubs/java/androidx/work/Data.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/ExistingWorkPolicy.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/ForegroundInfo.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/OneTimeWorkRequest.java create mode 100644 android/tools/typecheck/stubs/java/androidx/work/OutOfQuotaPolicy.java diff --git a/android/README.md b/android/README.md index bb30689c1f..c6dbfcdb48 100644 --- a/android/README.md +++ b/android/README.md @@ -128,6 +128,17 @@ prompt only once the hold ends. A terminal that shows an error, a relay offer or instead of the session hides nothing, and one still connecting leaves the session's events for the next check. +Tapping a notification opens that session's terminal; Back from it lands on the computer's Inbox. An +approval's notification carries **Approve** and **Deny**, and a question's carries its options when it +has at most three ("Option 1", "Option 2", …, or their text with **Show details in notifications** +on), wherever the Inbox card could answer it in one tap: a held Claude approval or a Claude prompt, a +single-select question. Anything else carries **Open**. An answer needs an unlocked phone (Android 12 +and later ask for the unlock; on older versions the notification says to unlock first). It goes over +your network or through a relay that has already approved this phone, never through a first relay +connection, for the same reason as the background check, and otherwise the notification offers Open. +The notification then says how it went ("Approved.", "Already handled.", or why nothing was sent, with +a tap that opens the session), and an answer is never sent twice. + Each Inbox event raises at most one notification: only events younger than 6 hours are announced, and the phone remembers the ones it has announced, you have read or you had on screen for a day after it last saw them (longer when the computer's clock runs ahead), so nothing still eligible is diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 54fa1e0b10..7c9d35b5e8 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -21,7 +21,7 @@ # The reflection facts below were read from the jars with javap/jdeps (sshj 0.39.0, eddsa 0.3.0, # bcprov/bcpkix/bcutil 1.78.1, OkHttp 4.12.0, slf4j-api 2.0.13, zxing-android-embedded 4.3.0, zxing # core 3.4.1). Classes of the APP loaded by name: BouncyCastle's (by BouncyCastle, the JCA and sshj's -# SecurityUtils), and the two app classes below. OkHttp's reflection targets platform classes R8 never +# SecurityUtils), and the app classes below. OkHttp's reflection targets platform classes R8 never # renames (com.android.org.conscrypt, dalvik.system.CloseGuard); slf4j's ServiceLoader finds no binding # on Android either way and falls back to its no-op logger; eddsa and zxing do not reflect. What rests # on a library's own consumer rules instead (androidx, Compose, WorkManager and androidx.startup) is @@ -47,6 +47,13 @@ -keep class dev.nodeterm.android.notify.InboxWorker { public (android.content.Context, androidx.work.WorkerParameters); } +# The same for the worker that sends an answer given from a notification (audit A25). Its work is +# one-time, but it can still be pending in WorkManager's database across an update (an expedited job +# out of quota runs as ordinary work, later). Its receiver is declared in the manifest, whose +# components AGP keeps by itself. +-keep class dev.nodeterm.android.notify.InboxActionWorker { + public (android.content.Context, androidx.work.WorkerParameters); +} # BouncyCastle is a JCA provider, and that is reflection end to end: BouncyCastleProvider loads its # algorithm tables by name ("org.bouncycastle.jcajce.provider.symmetric." + "AES" + "$Mappings"), and diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 24c5a629dd..10ec350050 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,11 @@ + + @@ -45,5 +50,9 @@ + + +
diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt index cdfd03cbb7..09b8c710b0 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -125,19 +125,26 @@ class MainActivity : ComponentActivity() { } } - /** A notification tap naming a computer (at launch or while running) that awaits the UI. */ - private var incomingHost by mutableStateOf(null) + /** A notification tap (at launch or while running) that awaits the UI. */ + private var incomingTap by mutableStateOf(null) + + /** The session a notification names: [EXTRA_HOST_ID], and the node it is about (audit A25). */ + private fun tapOf(intent: Intent?): NotificationTap? { + val hostId = intent?.getStringExtra(EXTRA_HOST_ID) ?: return null + val nodeId = intent.getStringExtra(EXTRA_NODE_ID)?.takeIf { it.isNotBlank() } + return NotificationTap(hostId, nodeId, intent.getStringExtra(EXTRA_NODE_TITLE)) + } /** * A live activity gets later intents HERE, not in onCreate (it is `singleTask`): a notification * tapped while the app sat in the background used to open whatever screen was last showing - * instead of that computer's Inbox (audit A11/A19). + * instead of that computer's Inbox (audit A11/A19), and now its session (audit A25). */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) takePairLink(intent) - intent.getStringExtra(EXTRA_HOST_ID)?.let { incomingHost = it } + tapOf(intent)?.let { incomingTap = it } } private val notificationPermission = @@ -158,13 +165,13 @@ class MainActivity : ComponentActivity() { // recreation the saved back stack already reflects it, and re-applying it would push the // same screen again (audit A22). val fresh = savedInstanceState == null - val openHost = if (fresh) intent?.getStringExtra(EXTRA_HOST_ID) else null + val launchTap = if (fresh) tapOf(intent) else null if (fresh) takePairLink(intent) setContent { NodetermTheme { val nav = rememberSaveable(saver = Navigator.Saver) { Navigator(Route.Hosts).also { n -> - if (openHost != null && graph.hosts.get(openHost) != null) n.push(Route.Host(openHost, tab = 2)) + if (launchTap != null && graph.hosts.get(launchTap.hostId) != null) n.openTap(launchTap) } } // A restored stack may name a computer that was forgotten meanwhile. @@ -178,13 +185,13 @@ class MainActivity : ComponentActivity() { } } } - val hostTap = incomingHost - LaunchedEffect(hostTap) { - if (hostTap != null) { - incomingHost = null - if (graph.hosts.get(hostTap) != null) { + val tap = incomingTap + LaunchedEffect(tap) { + if (tap != null) { + incomingTap = null + if (graph.hosts.get(tap.hostId) != null) { nav.replaceAll(Route.Hosts) - nav.push(Route.Host(hostTap, tab = 2)) + nav.openTap(tap) } } } @@ -202,9 +209,26 @@ class MainActivity : ComponentActivity() { companion object { const val EXTRA_HOST_ID = "hostId" + /** The session a notification is about; its tap opens that terminal (audit A25). */ + const val EXTRA_NODE_ID = "nodeId" + /** That session's name, for the terminal's title until the listing names it. */ + const val EXTRA_NODE_TITLE = "nodeTitle" } } +/** What a notification's tap opens: [nodeId]'s terminal on [hostId], or that computer's Inbox without one. */ +private data class NotificationTap(val hostId: String, val nodeId: String?, val title: String?) + +/** + * Opens what a notification tap names: the session's terminal (audit A25), with that computer's Inbox + * under it, where the event is listed, so Back lands where the A11/A19 tap used to open. + */ +private fun Navigator.openTap(tap: NotificationTap) { + push(Route.Host(tap.hostId, tab = 2)) + val node = tap.nodeId ?: return + push(Route.Terminal(tap.hostId, node, tap.title?.takeIf { it.isNotBlank() } ?: "Session")) +} + @Composable private fun AppContent(nav: Navigator) { // Only the top entry is composed, so without a holder a screen's saved state died the moment diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index 8ac2c4bd45..9f3df4bad9 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -6,6 +6,7 @@ import dev.nodeterm.android.data.SecureStore import dev.nodeterm.protocol.host.Capability import dev.nodeterm.protocol.host.HostConnection import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.InboxNotificationActions import dev.nodeterm.protocol.host.LegRouting import dev.nodeterm.protocol.host.RelayApprovalGate import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger @@ -221,6 +222,20 @@ class HostSession(val hostId: String, private val graph: AppGraph) { ) } + /** + * This computer can be reached without a FIRST relay handshake (audit A25): over its SSH leg, or + * through a relay that has already approved this phone. What a notification's answer may use: the + * user tapped, but is not looking at the app to compare the desktop's approval code (audit A05). + */ + fun reachableQuietly(): Boolean { + val host = graph.hosts.get(hostId) ?: return false + return InboxNotificationActions.reachableQuietly( + sshLeg = host.sshAvailable && graph.hosts.route(hostId) != RoutePreference.RELAY_ONLY, + relayLeg = relayLeg() == LegRouting.RelayLeg.AVAILABLE, + relayApproved = graph.hosts.relayApproved(hostId) + ) + } + /** Which leg answers [cap] right now ([LegRouting.route]): the one decision the UI and [connectionFor] share. */ fun route(cap: Capability): LegRouting.Leg { val c = conn diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxActions.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxActions.kt new file mode 100644 index 0000000000..2be0a5df82 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxActions.kt @@ -0,0 +1,105 @@ +package dev.nodeterm.android.notify + +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dev.nodeterm.android.AppGraph +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.protocol.host.InboxNotificationActions +import dev.nodeterm.protocol.host.InboxNotificationActions.Outcome +import dev.nodeterm.protocol.host.InboxNotificationActions.Request +import dev.nodeterm.protocol.host.RelayApprovalGate +import kotlinx.coroutines.CancellationException + +/** + * An answer given from an Inbox notification (audit A25): Approve, Deny or a question's option, as + * [InboxNotificationActions.plan] put them on the notification. + * + * The tap arrives here, in a receiver the system starts for this app only (it is not exported, and + * the action's PendingIntent is explicit and immutable). The receiver does nothing slow: it takes the + * actions off the notification ("Approving…"), so a second tap cannot send a second answer, and hands + * the answer to [InboxActionWorker] as expedited work, which may connect for as long as a dial takes. + * One answer per event is in flight at a time (unique work, KEEP). + * + * Android 12 and later ask for the unlock before they send an answering action + * (`setAuthenticationRequired`); Android 11 and lower send it from a locked screen, so the receiver + * refuses a locked phone itself and the notification says to unlock. + */ +class InboxActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val request = Request.decode(intent.getStringExtra(InboxNotifier.EXTRA_REQUEST)) ?: return + if (!request.action.answers) return + if (Build.VERSION.SDK_INT < 31 && context.getSystemService(KeyguardManager::class.java)?.isDeviceLocked == true) { + InboxNotifier.settle(context, request, Outcome.LOCKED) + return + } + InboxNotifier.sending(context, request) + val work = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData(Data.Builder().putString(InboxActionWorker.KEY_REQUEST, request.encode()).build()) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(request.workName, ExistingWorkPolicy.KEEP, work) + } +} + +/** + * Sends one answer from a notification and says on the notification how it went. + * + * It connects to that computer the way the background check does, never making a FIRST relay + * handshake: the user tapped, but is not looking at the app, so nobody would compare the approval + * code the desktop shows (RelayApprovalGate, audit A05). A connection already open is reused; else + * the SSH leg or a relay that has already approved this phone; else the notification says to open + * the app, which connects as the user. The answer is [dev.nodeterm.protocol.host.QuickActions]', + * with its re-checks, through [InboxNotificationActions.perform]. It is never retried: one that may + * have arrived is reported as unconfirmed, and a second `1` would land in whatever the pane shows by + * then. + */ +class InboxActionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val request = Request.decode(inputData.getString(KEY_REQUEST)) ?: return Result.success() + val graph = NodetermApp.graph(applicationContext) + val outcome = try { + if (graph.hosts.get(request.hostId) == null) Outcome.NOT_PAIRED else answer(graph, request) + } catch (e: CancellationException) { + // Stopped by the system mid-way: whether the answer arrived is not known. + InboxNotifier.settle(applicationContext, request, Outcome.UNCONFIRMED) + throw e + } + InboxNotifier.settle(applicationContext, request, outcome) + return Result.success() + } + + private suspend fun answer(graph: AppGraph, request: Request): Outcome { + val session = graph.connections.session(request.hostId) + val watched = session.isWatched + try { + if (session.connection == null && !session.reachableQuietly()) return Outcome.NOT_APPROVED + return InboxNotificationActions.perform( + request.action, + request.event, + connect = { session.ensureConnected(RelayApprovalGate.Trigger.BACKGROUND) }, + viaRelay = { session.viaRelay(RelayApprovalGate.Trigger.BACKGROUND) } + ) + } finally { + // Don't hold a socket open in the background for a screen nobody is looking at. + if (!watched && !session.isWatched) session.disconnect() + } + } + + /** Android 11 and lower run expedited work as a foreground service, which needs a notification. */ + override suspend fun getForegroundInfo(): ForegroundInfo = InboxNotifier.sendingForeground(applicationContext) + + companion object { + const val KEY_REQUEST = "request" + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt index 58b53eed12..6cccc60108 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -7,6 +7,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -14,6 +15,7 @@ import androidx.core.content.ContextCompat import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager @@ -21,6 +23,10 @@ import androidx.work.WorkerParameters import dev.nodeterm.android.MainActivity import dev.nodeterm.android.NodetermApp import dev.nodeterm.android.R +import dev.nodeterm.android.ui.displayTitle +import dev.nodeterm.protocol.host.InboxNotificationActions +import dev.nodeterm.protocol.host.InboxNotificationActions.Outcome +import dev.nodeterm.protocol.host.InboxNotificationActions.Request import dev.nodeterm.protocol.host.RelayApprovalGate import dev.nodeterm.protocol.model.InboxEvent import dev.nodeterm.protocol.model.InboxKind @@ -44,12 +50,24 @@ import java.util.concurrent.TimeUnit * open from a screen the user left is not re-listed, so their notifications come from the background * check only. What the user is looking at ([OnScreen]) is recorded as seen instead of announced. A * real push leg is backend work. + * + * A notification's tap opens that session's terminal, and an approval or a question carries the + * answers the Inbox card offers (audit A25): which ones is [InboxNotificationActions.plan], and an + * answer runs in [InboxActionReceiver] / [InboxActionWorker], which update the notification here + * ([sending], [settle]). */ object InboxNotifier { private const val CH_ATTENTION = "attention" private const val CH_DONE = "done" + private const val CH_SENDING = "sending" private const val WORK = "nodeterm.inbox" + /** The [Request] an answering action carries to [InboxActionReceiver]. */ + const val EXTRA_REQUEST = "dev.nodeterm.android.notify.REQUEST" + + /** The notification an answer runs under on Android 11 and lower, where expedited work is a foreground service. */ + private const val SENDING_ID = 0x4e54_0025 + /** This phone will actually SHOW our notifications: the app-level switch in system settings, * which on Android 13+ also reflects the runtime POST_NOTIFICATIONS permission (audit A21). */ fun canPost(context: Context): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() @@ -65,6 +83,10 @@ object InboxNotifier { NotificationChannel(CH_DONE, context.getString(R.string.channel_done), NotificationManager.IMPORTANCE_DEFAULT) .apply { description = context.getString(R.string.channel_done_desc) } ) + nm.createNotificationChannel( + NotificationChannel(CH_SENDING, context.getString(R.string.channel_sending), NotificationManager.IMPORTANCE_LOW) + .apply { description = context.getString(R.string.channel_sending_desc) } + ) } fun schedule(context: Context, enabled: Boolean) { @@ -99,7 +121,7 @@ object InboxNotifier { val nm = NotificationManagerCompat.from(context) val showDetails = graph.hosts.notificationDetails for (ev in fresh.takeLast(5)) { - nm.notify("${host.id}:${ev.id}".hashCode(), build(context, host, snapshot, ev, showDetails)) + nm.notify(InboxNotificationActions.notificationId(host.id, ev.id), build(context, host, snapshot, ev, showDetails)) } return fresh.size } @@ -109,6 +131,9 @@ object InboxNotifier { * unless the user opted in: Android shows a notification's full content on a secure lock screen * under its default setting, and a public version changes that only for users who hide sensitive * content (audit A52). The words are [InboxNotificationText]'s; the public version is always set. + * + * The tap opens that session's terminal, and the actions are the Inbox card's answers as far as a + * notification can carry them (audit A25, [InboxNotificationActions.plan]). */ private fun build( context: Context, @@ -121,14 +146,12 @@ object InboxNotifier { val session = snapshot.statusOf(ev.nodeId)?.name?.takeIf { it.isNotBlank() } ?: node?.title val words = InboxNotificationText.of(ev, session, host.name, showDetails) val channel = if (ev.kind == InboxKind.DONE) CH_DONE else CH_ATTENTION - val open = PendingIntent.getActivity( - context, - host.id.hashCode(), - Intent(context, MainActivity::class.java) - .putExtra(MainActivity.EXTRA_HOST_ID, host.id) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + val id = InboxNotificationActions.notificationId(host.id, ev.id) + // The terminal's title as the Inbox card names the session, so the screen the tap opens reads the same. + val title = node?.let { displayTitle(it, snapshot) } + ?: snapshot.statusOf(ev.nodeId)?.name?.takeIf { it.isNotBlank() } + ?: InboxNotificationText.FALLBACK_SESSION + val open = openSession(context, id, host.id, ev.nodeId, title) val publicVersion = NotificationCompat.Builder(context, channel) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(words.publicTitle) @@ -146,8 +169,124 @@ object InboxNotifier { .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setPublicVersion(publicVersion) words.bigText?.let { builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) } + // An answer from here never makes a first relay handshake (nobody is at the app to compare the + // desktop's code): a computer reachable only that way gets Open instead of answers. + val quiet = NodetermApp.graph(context).connections.session(host.id).reachableQuietly() + for (action in InboxNotificationActions.plan(ev, showDetails, quiet)) { + val intent = if (action.answers) answerIntent(context, Request(host.id, host.name, id, words.title, title, action, ev)) + else open + // An answer needs an unlocked phone: Android 12 and later ask for the unlock before they + // send the action; below that the receiver refuses a locked phone itself. + builder.addAction( + NotificationCompat.Action.Builder(R.drawable.ic_launcher_foreground, action.label, intent) + .setAuthenticationRequired(action.answers) + .build() + ) + } return builder.build() } + + /** + * What a notification's tap (and its Open action) opens: that session's terminal, with its + * computer's Inbox one Back away (audit A25, MainActivity.openTap). One PendingIntent per + * notification ([notificationId] is its request code): PendingIntents are told apart by their + * Intent WITHOUT its extras, so with one request code per computer FLAG_UPDATE_CURRENT would hand + * every notification of that computer the session of the last one posted. + */ + private fun openSession(context: Context, notificationId: Int, hostId: String, nodeId: String, title: String): PendingIntent = + PendingIntent.getActivity( + context, + notificationId, + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.EXTRA_HOST_ID, hostId) + .putExtra(MainActivity.EXTRA_NODE_ID, nodeId) + .putExtra(MainActivity.EXTRA_NODE_TITLE, title) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + /** + * An answering action: an explicit broadcast to the app's own unexported [InboxActionReceiver], + * immutable, carrying the [Request]. Its data names the notification and the action, so each + * action of each notification is a PendingIntent of its own. + */ + private fun answerIntent(context: Context, request: Request): PendingIntent = + PendingIntent.getBroadcast( + context, + request.notificationId, + Intent(context, InboxActionReceiver::class.java) + .setData(Uri.fromParts("nodeterm-answer", "${request.notificationId}/${request.action.key}", null)) + .putExtra(EXTRA_REQUEST, request.encode()), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + /** The answer is on its way. The actions go with this update, so a second tap cannot send another. */ + fun sending(context: Context, request: Request) { + post( + context, request.notificationId, + reposted(context, request) + .setContentText(InboxNotificationActions.sendingText(request.action)) + .setProgress(0, 0, true) + .build() + ) + } + + /** The answer settled as [outcome]: say so, keeping the tap that opens the session. */ + fun settle(context: Context, request: Request, outcome: Outcome) { + val settled = InboxNotificationActions.settled(outcome, request.action, request.computerName) + val builder = reposted(context, request) + .setContentText(settled.text) + .setStyle(NotificationCompat.BigTextStyle().bigText(settled.text)) + settled.dismissAfterMs?.let { builder.setTimeoutAfter(it) } + post(context, request.notificationId, builder.build()) + } + + /** + * The notification an answer's work shows while it runs as a foreground service: Android 11 and + * lower run expedited work that way. Android 12 and later never ask for it. + */ + fun sendingForeground(context: Context): ForegroundInfo = + ForegroundInfo( + SENDING_ID, + NotificationCompat.Builder(context, CH_SENDING) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Sending your answer…") + .setSilent(true) + .build() + ) + + /** + * The event's notification again, under its id and on its channel, without actions and silent (an + * update must not alert a second time). Only words that carry no event text: its title is + * [InboxNotificationText]'s headline, and the rest is the computer and the outcome (audit A52). + */ + private fun reposted(context: Context, request: Request): NotificationCompat.Builder { + val open = openSession(context, request.notificationId, request.hostId, request.event.nodeId, request.sessionTitle) + val publicVersion = NotificationCompat.Builder(context, CH_ATTENTION) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(request.headline) + .setContentText(request.computerName) + .setWhen(request.event.ts) + .build() + return NotificationCompat.Builder(context, CH_ATTENTION) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(request.headline) + .setSubText(request.computerName) + .setWhen(request.event.ts) + .setAutoCancel(true) + .setContentIntent(open) + .setOnlyAlertOnce(true) + .setSilent(true) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) + } + + private fun post(context: Context, id: Int, notification: android.app.Notification) { + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) return + runCatching { NotificationManagerCompat.from(context).notify(id, notification) } + } } class InboxWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2b3a88b122..1ee4079b37 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -5,4 +5,6 @@ An agent needs an approval or an answer Finished An agent finished its turn + Sending answers + Shown on Android 11 and lower while an answer given from a notification is sent diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/InboxNotificationActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/InboxNotificationActions.kt new file mode 100644 index 0000000000..a270f0244d --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/InboxNotificationActions.kt @@ -0,0 +1,363 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.J.strings +import dev.nodeterm.protocol.model.QuestionChoices +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The actions an Inbox notification carries, and what it says once one has run (audit A25). + * + * The iOS app gets numbered actions on the APNs push the desktop sends (src/core/push-notify.ts: "The + * backend renders them as numbered notification actions"). Android has no push leg (the backend's + * `/v1/push` routes are APNs-only), but the notifications the app posts itself can carry the same + * answers: Approve / Deny on an approval and an option on a single-select question, sent through + * [QuickActions] with all its re-checks (still unresolved, the node-state rules, the ticketed path + * of audit A38), exactly as the Inbox card sends them. This object is the decision: which actions an + * event gets ([plan]), how an answer runs ([perform]) and what the notification says afterwards + * ([settled]). It is pure, so the JVM tests it; the app's part (the PendingIntents, the receiver, the + * expedited worker) is pinned in the app's source. + * + * The rules: + * - **The card's rule, not a second one.** An approval gets Approve / Deny only when + * [QuickActions.answersApproval] says it can be answered from outside the session, and a question + * gets its options only when [QuestionChoices] lists them as answers. What the answer path would + * turn into "open the session" is not offered; the notification offers Open instead. + * - **Three at most** ([MAX_ACTIONS]): Android shows no more. A question with more options than that + * offers Open, never its first three: a tap would answer a picker whose other options were never + * shown. + * - **Never a first relay handshake** ([reachableQuietly]). The user tapped, but is not looking at + * the app, so nobody would compare the approval code the desktop shows on a phone's first relay + * connect (RelayApprovalGate, audit A05). An answer goes over the SSH leg or over a relay that has + * already approved this phone; a computer reachable only otherwise gets Open, which opens the app. + * - **No event text in a label unless the user asked for it** (audit A52): a question's options are + * "Option 1", "Option 2", … unless "Show details in notifications" is on. Approve and Deny say + * nothing about the request. + * - **Never retried.** An answer that may or may not have arrived is said to be unconfirmed + * ([Outcome.UNCONFIRMED]), never sent again: a second `1` lands in whatever the pane shows by then. + */ +object InboxNotificationActions { + /** Android shows at most three actions on a notification. */ + const val MAX_ACTIONS = 3 + + /** An option's label is cut to this many characters (only with details on: the label is its text). */ + const val MAX_LABEL_CHARS = 40 + + /** How long reaching the computer may take before the notification says it could not be reached. */ + const val CONNECT_BUDGET_MS = 30_000L + + /** How long the answer may take once connected before the notification says it is unconfirmed. */ + const val ANSWER_BUDGET_MS = 30_000L + + /** How long a settled notification ("Approved.") stays before it goes away by itself. */ + const val SETTLED_SHOWN_MS = 10_000L + + /** [Action.option] of an action that is not an option. */ + const val NO_OPTION = -1 + + /** The label of the action that opens the session instead of answering. */ + const val OPEN_LABEL = "Open" + + /** The notification id of [eventId] on [hostId]: one notification per event per computer. */ + fun notificationId(hostId: String, eventId: String): Int = "$hostId:$eventId".hashCode() + + enum class Verb(val wire: String) { + APPROVE("approve"), + DENY("deny"), + /** Picks the question's option [Action.option] (0-based; the digit typed is one more). */ + OPTION("option"), + /** Opens the session; answers nothing. */ + OPEN("open"); + + companion object { + fun of(wire: String?): Verb? = entries.firstOrNull { it.wire == wire } + } + } + + data class Action(val verb: Verb, val label: String, val option: Int = NO_OPTION) { + /** + * Answers from the notification (a broadcast to the app, which needs an unlocked phone); + * [Verb.OPEN] opens the session instead. + */ + val answers: Boolean get() = verb != Verb.OPEN + + /** Unique among one notification's actions: what tells their PendingIntents apart. */ + val key: String get() = if (verb == Verb.OPTION) "${verb.wire}-$option" else verb.wire + } + + /** + * Whether the computer can be reached without a FIRST relay handshake: over its SSH leg ([sshLeg]: + * the pairing installed a key and the route allows SSH), or through a relay leg that has already + * approved this phone ([relayLeg] and [relayApproved]). A notification action uses only these; an + * open connection may be reused whatever it is, since reusing it makes no handshake. + */ + fun reachableQuietly(sshLeg: Boolean, relayLeg: Boolean, relayApproved: Boolean): Boolean = + sshLeg || (relayLeg && relayApproved) + + /** + * The actions a notification for [event] carries, at most [MAX_ACTIONS]. None for a finished turn + * or a settled card (the tap opens the session). [showDetails] is "Show details in notifications"; + * [reachableQuietly] is [InboxNotificationActions.reachableQuietly] for the event's computer. + */ + fun plan(event: InboxEvent, showDetails: Boolean, reachableQuietly: Boolean): List { + if (!event.actionable) return emptyList() + val open = listOf(Action(Verb.OPEN, OPEN_LABEL)) + if (!reachableQuietly) return open + return when (event.kind) { + InboxKind.APPROVAL -> + if (QuickActions.answersApproval(event)) listOf(Action(Verb.APPROVE, "Approve"), Action(Verb.DENY, "Deny")) + else open + InboxKind.QUESTION -> { + val choices = QuestionChoices.of(event) as? QuestionChoices.Answer + if (choices == null || choices.rows.size > MAX_ACTIONS) open + else choices.rows.mapIndexed { i, row -> + Action(Verb.OPTION, if (showDetails) cap(row) else "Option ${i + 1}", option = i) + } + } + InboxKind.DONE -> emptyList() + } + } + + private fun cap(label: String): String { + val oneLine = label.replace(Regex("\\s+"), " ").trim() + return if (oneLine.length <= MAX_LABEL_CHARS) oneLine else oneLine.take(MAX_LABEL_CHARS - 1).trimEnd() + "…" + } + + /** What happened to an answer given from a notification. */ + enum class Outcome { + APPROVED, + DENIED, + ANSWERED, + /** The card was settled before the answer arrived (on the computer, or elsewhere). Nothing was sent. */ + ALREADY_HANDLED, + /** The hook's hold ended before the answer arrived; the prompt is in the session now (audit A06). */ + EXPIRED, + /** The answer path sends this one to the session after all (QuickActions said so). */ + OPEN_SESSION, + /** Reaching the computer would take a first relay handshake, whose code nobody would see. Nothing was sent. */ + NOT_APPROVED, + /** The computer could not be reached. Nothing was sent. */ + UNREACHABLE, + /** Connected, but the answer was not confirmed: it may or may not have reached the computer. */ + UNCONFIRMED, + /** The phone was locked (Android 11 and lower, whose system does not ask for the unlock). Nothing was sent. */ + LOCKED, + /** The computer is no longer paired with this phone. Nothing was sent. */ + NOT_PAIRED + } + + /** [QuickActions]' [result] of [action], as the notification reports it. */ + fun outcomeOf(action: Action, result: QuickActions.Result): Outcome = when (result) { + QuickActions.Result.SENT -> when (action.verb) { + Verb.APPROVE -> Outcome.APPROVED + Verb.DENY -> Outcome.DENIED + Verb.OPTION -> Outcome.ANSWERED + Verb.OPEN -> Outcome.OPEN_SESSION + } + QuickActions.Result.ALREADY_HANDLED -> Outcome.ALREADY_HANDLED + QuickActions.Result.EXPIRED -> Outcome.EXPIRED + QuickActions.Result.OPEN_SESSION -> Outcome.OPEN_SESSION + } + + /** + * What the notification says once an answer settled: [text], and how long it stays + * ([dismissAfterMs]; null = until the user deals with it). Its tap always opens the session. An + * outcome that leaves something to do says "Tap" and stays; one that is done goes away by itself. + * No outcome carries the event's own text (audit A52). + */ + data class Settled(val text: String, val dismissAfterMs: Long?) + + fun settled(outcome: Outcome, action: Action, computerName: String): Settled = when (outcome) { + Outcome.APPROVED -> Settled("Approved.", SETTLED_SHOWN_MS) + Outcome.DENIED -> Settled("Denied.", SETTLED_SHOWN_MS) + Outcome.ANSWERED -> Settled("Answered with option ${action.option + 1}.", SETTLED_SHOWN_MS) + Outcome.ALREADY_HANDLED -> Settled("Already handled.", SETTLED_SHOWN_MS) + Outcome.EXPIRED -> Settled("The request timed out on the computer. Tap to answer it in the session.", null) + Outcome.OPEN_SESSION -> Settled("This one is answered in the session. Tap to open it.", null) + Outcome.NOT_APPROVED -> Settled( + "Not sent: this phone reaches $computerName only through the relay, and it has not approved this phone " + + "yet. Tap to open the app and connect.", + null + ) + Outcome.UNREACHABLE -> Settled("Not sent: couldn't reach $computerName. Tap to answer it in the session.", null) + Outcome.UNCONFIRMED -> Settled("Couldn't confirm that the answer reached $computerName. Tap to check the session.", null) + Outcome.LOCKED -> Settled("Not sent: unlock the phone first. Tap to answer it in the session.", null) + Outcome.NOT_PAIRED -> Settled("Not sent: $computerName is no longer paired with this phone.", null) + } + + /** What the notification says while the answer is on its way (its actions are gone by then). */ + fun sendingText(action: Action): String = when (action.verb) { + Verb.APPROVE -> "Approving…" + Verb.DENY -> "Denying…" + Verb.OPTION -> "Answering with option ${action.option + 1}…" + Verb.OPEN -> "Opening…" + } + + /** + * Runs [action] on [event] through [QuickActions], over [connect] (the computer's connection) and, + * when the direct-SSH transport sends the node to the relay ([NeedsRelayException], audit A09), + * over [viaRelay]. Both must never make a first relay handshake: the caller passes the + * background trigger ([RelayApprovalGate.Trigger.BACKGROUND]). Never throws but for cancellation. + * + * A dial that fails or takes longer than [connectBudgetMs] has sent nothing ([Outcome.UNREACHABLE]); + * an answer that fails or takes longer than [answerBudgetMs] once connected may have + * ([Outcome.UNCONFIRMED]), and is not retried. + */ + suspend fun perform( + action: Action, + event: InboxEvent, + connect: suspend () -> HostConnection, + viaRelay: suspend () -> HostConnection, + connectBudgetMs: Long = CONNECT_BUDGET_MS, + answerBudgetMs: Long = ANSWER_BUDGET_MS + ): Outcome { + if (!action.answers) return Outcome.OPEN_SESSION + val primary = dial(connect, connectBudgetMs) ?: return Outcome.UNREACHABLE + var attempt = attempt(primary, action, event, answerBudgetMs) + if (attempt is Attempt.NeedsRelay) { + // The SSH transport refused before sending anything; the node is answered where it lives. + val relay = dial(viaRelay, connectBudgetMs) ?: return Outcome.UNREACHABLE + attempt = attempt(relay, action, event, answerBudgetMs) + } + return when (attempt) { + is Attempt.Answered -> outcomeOf(action, attempt.result) + // The relay leg never refuses a node that way; if it did, it sent nothing. + Attempt.NeedsRelay -> Outcome.UNREACHABLE + Attempt.Unconfirmed -> Outcome.UNCONFIRMED + } + } + + private sealed interface Attempt { + data class Answered(val result: QuickActions.Result) : Attempt + data object NeedsRelay : Attempt + data object Unconfirmed : Attempt + } + + private suspend fun dial(open: suspend () -> HostConnection, budgetMs: Long): HostConnection? = try { + withTimeoutOrNull(budgetMs) { open() } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + private suspend fun attempt(conn: HostConnection, action: Action, event: InboxEvent, budgetMs: Long): Attempt = try { + withTimeoutOrNull(budgetMs) { Attempt.Answered(answer(conn, action, event)) } ?: Attempt.Unconfirmed + } catch (_: NeedsRelayException) { + Attempt.NeedsRelay + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + Attempt.Unconfirmed + } + + private suspend fun answer(conn: HostConnection, action: Action, event: InboxEvent): QuickActions.Result = when (action.verb) { + Verb.APPROVE -> QuickActions.answerApproval(conn, event, allow = true) + Verb.DENY -> QuickActions.answerApproval(conn, event, allow = false) + Verb.OPTION -> QuickActions.answerQuestion(conn, event, action.option) + Verb.OPEN -> QuickActions.Result.OPEN_SESSION + } + + /** + * One tap on an answering action, as the app hands it from the notification to its receiver and on + * to the worker (an Intent extra, then the work's input; both are strings, so it is one JSON + * string, [encode]). It names the notification to update ([notificationId], with the words it + * shows: [headline], [computerName]), the terminal its tap opens ([sessionTitle]), the [action], + * and the [event] with the fields [QuickActions] reads. Never the event's title or detail, which + * nothing after the tap needs (audit A52). + */ + data class Request( + val hostId: String, + val computerName: String, + val notificationId: Int, + /** The notification's title ([dev.nodeterm.protocol.model.InboxNotificationText.title]). */ + val headline: String, + /** The session's name, for the terminal the notification's tap opens. */ + val sessionTitle: String, + val action: Action, + val event: InboxEvent + ) { + /** One answer per event in flight: a second tap while the first runs is ignored. */ + val workName: String get() = "nodeterm.inbox.answer.$hostId.${event.id}" + + fun encode(): String = JsonObject( + mapOf( + "v" to JsonPrimitive(VERSION), + "hostId" to JsonPrimitive(hostId), + "computer" to JsonPrimitive(computerName.take(MAX_TEXT)), + "notificationId" to JsonPrimitive(notificationId), + "headline" to JsonPrimitive(headline.take(MAX_TEXT)), + "sessionTitle" to JsonPrimitive(sessionTitle.take(MAX_TEXT)), + "verb" to JsonPrimitive(action.verb.wire), + "label" to JsonPrimitive(action.label), + "option" to JsonPrimitive(action.option), + "event" to JsonObject( + buildMap { + put("id", JsonPrimitive(event.id)) + put("ts", JsonPrimitive(event.ts)) + put("nodeId", JsonPrimitive(event.nodeId)) + event.agentId?.let { put("agentId", JsonPrimitive(it)) } + event.sessionId?.let { put("sessionId", JsonPrimitive(it)) } + put("kind", JsonPrimitive(event.kind.wire)) + put("options", JsonArray(event.options.take(MAX_OPTIONS).map { JsonPrimitive(it.take(MAX_TEXT)) })) + put("multiSelect", JsonPrimitive(event.multiSelect)) + event.pendingId?.let { put("pendingId", JsonPrimitive(it)) } + } + ) + ) + ).toString() + + companion object { + private const val VERSION = 1L + private const val MAX_TEXT = 200 + private const val MAX_OPTIONS = 9 + + /** The request [encode] wrote, or null for anything else (a missing or malformed field). */ + fun decode(raw: String?): Request? { + val o = J.obj(J.parse(raw ?: return null)) ?: return null + if (o.l("v") != VERSION) return null + val hostId = o.s("hostId")?.takeIf { it.isNotEmpty() } ?: return null + val notificationId = o.l("notificationId")?.takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() }?.toInt() ?: return null + val verb = Verb.of(o.s("verb")) ?: return null + val option = o.l("option")?.toInt() ?: return null + if (verb == Verb.OPTION && option !in 0..8) return null + val e = o.o("event") ?: return null + val event = InboxEvent( + id = e.s("id")?.takeIf { it.isNotEmpty() } ?: return null, + ts = e.l("ts") ?: return null, + nodeId = e.s("nodeId")?.takeIf { it.isNotEmpty() } ?: return null, + agentId = e.s("agentId"), + sessionId = e.s("sessionId"), + kind = InboxKind.of(e.s("kind")) ?: return null, + title = "", + detail = null, + interrupted = false, + resolved = false, + options = e.strings("options"), + multiSelect = e.b("multiSelect") ?: return null, + pendingId = e.s("pendingId") + ) + return Request( + hostId = hostId, + computerName = o.s("computer") ?: return null, + notificationId = notificationId, + headline = o.s("headline") ?: return null, + sessionTitle = o.s("sessionTitle") ?: return null, + action = Action(verb, o.s("label") ?: return null, option), + event = event + ) + } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt index 8f8eb1e4d2..8b9ca9114a 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt @@ -50,10 +50,19 @@ object QuickActions { if (event.kind != InboxKind.APPROVAL) return Result.OPEN_SESSION if (event.pendingId != null) return answerTicket(conn, event, allow) if (!stillWaiting(conn, event, AgentState.BLOCKED)) return Result.ALREADY_HANDLED - if (event.agentId != "claude") return Result.OPEN_SESSION + if (!answersApproval(event)) return Result.OPEN_SESSION return typeOrOpen(conn, event, if (allow) "1" else "\u001b") } + /** + * Whether [answerApproval] can answer [event] from outside its session at all: a held hook-reply + * ticket, or a claude prompt (the only prompt layout known). Anything else goes to the session. + * The Inbox notification offers Approve / Deny by this rule (audit A25), so it never offers an + * answer this path would turn into "open the session". + */ + fun answersApproval(event: InboxEvent): Boolean = + event.kind == InboxKind.APPROVAL && (event.pendingId != null || event.agentId == "claude") + /** * AskUserQuestion: choices are digits on screen (a hook cannot inject an answer value). Only a * question [QuestionChoices] lists as [QuestionChoices.Answer] is typed, the rule the Inbox card diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt index 575f7b3bc1..07ceac6897 100644 --- a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt @@ -42,7 +42,10 @@ class RelayApprovalGate( USER, /** The app on its own, in the foreground (the poll, a reconnect after a drop). */ AUTO, - /** Nobody is looking (the Inbox worker). */ + /** + * Nobody is looking at the app: the Inbox worker, or an answer given from a notification (audit + * A25), where the user tapped but would not see an approval code either. + */ BACKGROUND } diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NotificationActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NotificationActionsTest.kt new file mode 100644 index 0000000000..6ba3b11b19 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NotificationActionsTest.kt @@ -0,0 +1,594 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.InboxNotificationActions +import dev.nodeterm.protocol.host.InboxNotificationActions.Action +import dev.nodeterm.protocol.host.InboxNotificationActions.Outcome +import dev.nodeterm.protocol.host.InboxNotificationActions.Request +import dev.nodeterm.protocol.host.InboxNotificationActions.Verb +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.MirrorInbox +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A25 (the in-app part): an Inbox notification carries the answers the Inbox card offers, and + * its tap opens the session. + * + * Before, a notification had no actions at all and its tap opened only the computer's Inbox. Now an + * approval gets Approve / Deny and a single-select question its options (three at most, Android's + * limit), sent through [QuickActions] with its re-checks, and only over a leg that makes no first + * relay handshake (nobody is at the app to compare the desktop's approval code, audit A05). The + * decision is the pure [InboxNotificationActions], tested here against [QuickActions] itself, so the + * notification cannot offer what the answer path refuses. The app's part (the PendingIntents, the + * receiver, the expedited worker, the tap's route) cannot run on a JVM and is pinned in its source; + * whether the actions show, and work, on a phone is a device check. + */ +class NotificationActionsTest { + private val node = "term-1" + + private fun ev( + kind: InboxKind = InboxKind.APPROVAL, + pendingId: String? = "term-1-1700000000000-42", + agentId: String? = "claude", + options: List = emptyList(), + multiSelect: Boolean = false, + resolved: Boolean = false, + id: String = "e1" + ) = InboxEvent( + id = id, ts = 1_700_000_000_000, nodeId = node, agentId = agentId, sessionId = "s", kind = kind, + title = "Run rm -rf build", detail = "secret detail", interrupted = false, resolved = resolved, + options = options, multiSelect = multiSelect, pendingId = pendingId + ) + + private fun question(vararg options: String, multiSelect: Boolean = false) = + ev(kind = InboxKind.QUESTION, pendingId = null, options = options.toList(), multiSelect = multiSelect) + + private fun snapshot(state: AgentState?, vararg events: InboxEvent) = ProjectsSnapshot( + projects = emptyList(), liveSessions = setOf("nt-$node"), + status = AgentStatusFile( + updatedAt = 1, + nodes = mapOf(node to AgentNodeStatus(state, "claude", "s", null, false, 1, null)), + settings = null, usage = null, + inbox = MirrorInbox(events.toList(), emptyMap()), + serverVersion = null + ), + fetchedAt = 1 + ) + + /** + * A connection whose answers are [answer] / [keys] (default: accept and record). [ssh] makes it + * the direct-SSH transport, which sends an SSH-project node to the relay (audit A09). + */ + private class FakeConn( + private val snap: ProjectsSnapshot, + override val kind: TransportKind = TransportKind.RELAY, + private val answer: suspend (InboxEvent, Boolean) -> ApprovalOutcome = { _, _ -> ApprovalOutcome.SENT }, + private val keys: suspend (String) -> Unit = {} + ) : HostConnection { + val answered = mutableListOf>() + val typed = mutableListOf() + + override val capabilities = HostCapabilities( + boardWrites = false, git = false, nodeActions = false, registerNode = false, answerApprovals = true + ) + + override suspend fun listProjects(): ProjectsSnapshot = snap + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome = + answer(event, allow).also { answered += event.pendingId to allow } + override suspend fun sendKeys(nodeId: String, keys: String) { + keys(keys) + typed += keys + } + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = + error("unused") + override suspend fun wake(nodeId: String) { error("unused") } + override suspend fun refresh(nodeId: String) { error("unused") } + override suspend fun rename(nodeId: String, title: String) { error("unused") } + override suspend fun ensureBoard(projectId: String): List? = error("unused") + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = error("unused") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? = error("unused") + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = error("unused") + override suspend fun ackRead(nodeId: String, eventId: String?) { error("unused") } + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = error("unused") + override fun setOnChanged(listener: (() -> Unit)?) {} + override fun setOnClosed(listener: ((String?) -> Unit)?) {} + override fun close() {} + } + + private fun plan(event: InboxEvent, showDetails: Boolean = false, quiet: Boolean = true) = + InboxNotificationActions.plan(event, showDetails, quiet) + + private fun verbs(actions: List) = actions.map { it.verb } + + // --- Which actions an event gets -------------------------------------------------------------- + + @Test + fun `a held approval and a claude prompt get Approve and Deny`() { + assertEquals(listOf(Verb.APPROVE, Verb.DENY), verbs(plan(ev()))) + // A held ticket is answered deterministically whatever the agent (QuickActions' ticket path). + assertEquals(listOf(Verb.APPROVE, Verb.DENY), verbs(plan(ev(agentId = "codex")))) + assertEquals(listOf(Verb.APPROVE, Verb.DENY), verbs(plan(ev(pendingId = null)))) + assertEquals(listOf("Approve", "Deny"), plan(ev()).map { it.label }) + } + + @Test + fun `an approval the answer path sends to the session gets Open, not Approve`() { + // No ticket and not claude: only claude's prompt layout is known, so QuickActions opens the + // session. Offering Approve would only end in "answer it in the session". + for (agent in listOf("codex", "gemini", null)) { + assertEquals(listOf(Verb.OPEN), verbs(plan(ev(pendingId = null, agentId = agent))), "agent $agent") + } + } + + @Test + fun `a single-select question gets one action per option, numbered as the picker numbers them`() { + val actions = plan(question("Yes", "No", "Ask me later")) + assertEquals(listOf(Verb.OPTION, Verb.OPTION, Verb.OPTION), verbs(actions)) + assertEquals(listOf(0, 1, 2), actions.map { it.option }) + assertEquals(listOf("Option 1", "Option 2", "Option 3"), actions.map { it.label }) + assertEquals(listOf("Option 1"), plan(question("Only")).map { it.label }) + } + + @Test + fun `a question with more options than Android shows offers Open, never its first three`() { + // A tap would answer a picker whose fourth option the user never saw. + assertEquals(listOf(Verb.OPEN), verbs(plan(question("a", "b", "c", "d")))) + assertEquals(listOf(Verb.OPEN), verbs(plan(question("a", "b", "c", "d", "e", "f", "g", "h", "i")))) + } + + @Test + fun `a multi-select question, or one without options, offers Open`() { + // The multi-select picker's keys are unmeasured (audit A57): the card shows it read-only too. + assertEquals(listOf(Verb.OPEN), verbs(plan(question("a", "b", multiSelect = true)))) + assertEquals(listOf(Verb.OPEN), verbs(plan(question()))) + } + + @Test + fun `a finished turn and a settled card get no actions`() { + assertEquals(emptyList(), plan(ev(kind = InboxKind.DONE, pendingId = null))) + assertEquals(emptyList(), plan(ev(resolved = true))) + assertEquals(emptyList(), plan(question("a", "b").copy(resolved = true))) + } + + @Test + fun `a computer reachable only through a first relay handshake offers Open`() { + // The desktop would show an approval code that nobody at the phone sees (audit A05). + assertEquals(listOf(Verb.OPEN), verbs(plan(ev(), quiet = false))) + assertEquals(listOf(Verb.OPEN), verbs(plan(question("a", "b"), quiet = false))) + assertEquals(emptyList(), plan(ev(kind = InboxKind.DONE, pendingId = null), quiet = false)) + } + + @Test + fun `quiet reach is the SSH leg or a relay that already approved this phone`() { + for (ssh in listOf(false, true)) for (relay in listOf(false, true)) for (approved in listOf(false, true)) { + assertEquals( + ssh || (relay && approved), + InboxNotificationActions.reachableQuietly(ssh, relay, approved), + "ssh=$ssh relay=$relay approved=$approved" + ) + } + } + + @Test + fun `no plan has more than three actions, and the actions of one notification are told apart`() { + val events = listOf( + ev(), ev(pendingId = null), ev(pendingId = null, agentId = "codex"), + question(), question("a"), question("a", "b"), question("a", "b", "c"), question("a", "b", "c", "d"), + question("a", "b", multiSelect = true), ev(kind = InboxKind.DONE, pendingId = null) + ) + for (e in events) for (details in listOf(false, true)) for (quiet in listOf(false, true)) { + val actions = plan(e, details, quiet) + assertTrue(actions.size <= InboxNotificationActions.MAX_ACTIONS, "${actions.size} actions for $e") + assertEquals(actions.size, actions.map { it.key }.toSet().size, "two actions share a PendingIntent: $actions") + } + } + + @Test + fun `with details off no label carries the option's text, and with details on it is cut`() { + val secret = "Deploy to production with the key in /root/.ssh" + val off = plan(question(secret, "No"), showDetails = false) + assertTrue(off.none { "Deploy" in it.label || "No" == it.label }, "a label leaks the option: $off") + val on = plan(question(secret, "No"), showDetails = true) + assertEquals("2. No", on[1].label) + assertTrue(on[0].label.startsWith("1. Deploy to production"), on[0].label) + assertTrue(on[0].label.length <= InboxNotificationActions.MAX_LABEL_CHARS && on[0].label.endsWith("…"), on[0].label) + // Approve and Deny say nothing about the request, either way. + assertEquals(plan(ev(), showDetails = false), plan(ev(), showDetails = true)) + } + + @Test + fun `every answer a notification offers is one QuickActions sends`() = runBlocking { + // The plan follows the answer path's own rules: what it offers on a node that still asks is + // SENT, never "open the session". + val cases = listOf( + ev() to AgentState.WAITING, // a concurrent ticket on a node waiting on a question (A38) + ev(pendingId = null) to AgentState.BLOCKED, + question("a", "b", "c") to AgentState.WAITING + ) + for ((event, state) in cases) { + for (action in plan(event)) { + val outcome = InboxNotificationActions.perform( + action, event, + connect = { FakeConn(snapshot(state, event)) }, + viaRelay = { error("no relay needed") } + ) + assertTrue( + outcome in setOf(Outcome.APPROVED, Outcome.DENIED, Outcome.ANSWERED), + "${action.verb} on $event ended $outcome" + ) + } + } + } + + // --- What the notification says afterwards ------------------------------------------------------ + + @Test + fun `each result of the answer path is what the notification reports`() { + val approve = Action(Verb.APPROVE, "Approve") + val deny = Action(Verb.DENY, "Deny") + val option = Action(Verb.OPTION, "Option 2", option = 1) + assertEquals(Outcome.APPROVED, InboxNotificationActions.outcomeOf(approve, QuickActions.Result.SENT)) + assertEquals(Outcome.DENIED, InboxNotificationActions.outcomeOf(deny, QuickActions.Result.SENT)) + assertEquals(Outcome.ANSWERED, InboxNotificationActions.outcomeOf(option, QuickActions.Result.SENT)) + for (a in listOf(approve, deny, option)) { + assertEquals(Outcome.ALREADY_HANDLED, InboxNotificationActions.outcomeOf(a, QuickActions.Result.ALREADY_HANDLED)) + assertEquals(Outcome.EXPIRED, InboxNotificationActions.outcomeOf(a, QuickActions.Result.EXPIRED)) + assertEquals(Outcome.OPEN_SESSION, InboxNotificationActions.outcomeOf(a, QuickActions.Result.OPEN_SESSION)) + } + } + + @Test + fun `an outcome that leaves something to do stays and says Tap, and a done one goes away`() { + val option = Action(Verb.OPTION, "Option 2", option = 1) + val done = setOf(Outcome.APPROVED, Outcome.DENIED, Outcome.ANSWERED, Outcome.ALREADY_HANDLED) + for (outcome in Outcome.entries) { + val s = InboxNotificationActions.settled(outcome, option, "studio-mac") + if (outcome in done) { + assertEquals(InboxNotificationActions.SETTLED_SHOWN_MS, s.dismissAfterMs, "$outcome") + } else { + assertNull(s.dismissAfterMs, "$outcome would vanish before the user acts on it") + if (outcome != Outcome.NOT_PAIRED) assertTrue("Tap" in s.text, "$outcome: ${s.text}") + } + } + assertEquals("Answered with option 2.", InboxNotificationActions.settled(Outcome.ANSWERED, option, "x").text) + assertEquals("Already handled.", InboxNotificationActions.settled(Outcome.ALREADY_HANDLED, option, "x").text) + assertTrue("studio-mac" in InboxNotificationActions.settled(Outcome.UNREACHABLE, option, "studio-mac").text) + // Only an unconfirmed answer is said to MAYBE have arrived; the others say nothing was sent. + assertTrue(InboxNotificationActions.settled(Outcome.UNCONFIRMED, option, "x").text.startsWith("Couldn't confirm")) + for (o in listOf(Outcome.NOT_APPROVED, Outcome.UNREACHABLE, Outcome.LOCKED, Outcome.NOT_PAIRED)) { + assertTrue(InboxNotificationActions.settled(o, option, "x").text.startsWith("Not sent"), "$o") + } + } + + // --- Running an answer -------------------------------------------------------------------------- + + @Test + fun `Approve and Deny answer the held ticket over the computer's connection`() = runBlocking { + val event = ev() + for ((action, allow) in listOf(Action(Verb.APPROVE, "Approve") to true, Action(Verb.DENY, "Deny") to false)) { + val conn = FakeConn(snapshot(AgentState.BLOCKED, event)) + val outcome = InboxNotificationActions.perform(action, event, connect = { conn }, viaRelay = { error("unused") }) + assertEquals(if (allow) Outcome.APPROVED else Outcome.DENIED, outcome) + assertEquals(listOf>(event.pendingId to allow), conn.answered) + assertEquals(emptyList(), conn.typed) + } + } + + @Test + fun `an option types its digit, and only on a node still waiting on the question`() = runBlocking { + val event = question("a", "b", "c") + val action = plan(event)[2] + val waiting = FakeConn(snapshot(AgentState.WAITING, event)) + assertEquals(Outcome.ANSWERED, InboxNotificationActions.perform(action, event, { waiting }, { error("unused") })) + assertEquals(listOf("3"), waiting.typed) + // A permission prompt is on screen now: the digit would answer it. QuickActions' rule holds. + val blocked = FakeConn(snapshot(AgentState.BLOCKED, event)) + assertEquals(Outcome.ALREADY_HANDLED, InboxNotificationActions.perform(action, event, { blocked }, { error("unused") })) + assertEquals(emptyList(), blocked.typed) + } + + @Test + fun `a settled card is not answered from a notification left in the shade`() = runBlocking { + val event = ev() + val conn = FakeConn(snapshot(AgentState.WAITING, event.copy(resolved = true))) + assertEquals(Outcome.ALREADY_HANDLED, InboxNotificationActions.perform(plan(event)[0], event, { conn }, { error("unused") })) + assertEquals(emptyList(), conn.answered) + } + + @Test + fun `a computer that cannot be reached is reported, and nothing is sent`() = runBlocking { + val event = ev() + var relayDialed = false + val outcome = InboxNotificationActions.perform( + plan(event)[0], event, + connect = { throw HostException("Through the relay: not approved") }, + viaRelay = { relayDialed = true; error("unused") } + ) + assertEquals(Outcome.UNREACHABLE, outcome) + assertFalse(relayDialed) + } + + @Test + fun `a dial that hangs is given up within its budget`() = runBlocking { + val event = ev() + val started = System.nanoTime() + val outcome = InboxNotificationActions.perform( + plan(event)[0], event, + connect = { awaitCancellation() }, + viaRelay = { error("unused") }, + connectBudgetMs = 100 + ) + assertEquals(Outcome.UNREACHABLE, outcome) + assertTrue(System.nanoTime() - started < 5_000_000_000L) + } + + @Test + fun `a node the SSH transport sends to the relay is answered through the relay`() = runBlocking { + // An SSH-project node over direct SSH (audit A09): the SSH transport refuses before sending. + val event = ev(pendingId = null) + val refuse: suspend (String) -> Unit = { throw NeedsRelayException(node, "runs on another host") } + val ssh = FakeConn(snapshot(AgentState.BLOCKED, event), kind = TransportKind.SSH, keys = refuse) + val relay = FakeConn(snapshot(AgentState.BLOCKED, event)) + val outcome = InboxNotificationActions.perform(plan(event)[0], event, connect = { ssh }, viaRelay = { relay }) + assertEquals(Outcome.APPROVED, outcome) + assertEquals(emptyList(), ssh.typed) + assertEquals(listOf("1"), relay.typed) + } + + @Test + fun `when the relay cannot be reached for such a node, nothing is sent`() = runBlocking { + val event = ev(pendingId = null) + val ssh = FakeConn( + snapshot(AgentState.BLOCKED, event), kind = TransportKind.SSH, + keys = { throw NeedsRelayException(node, "runs on another host") } + ) + val outcome = InboxNotificationActions.perform( + plan(event)[0], event, + connect = { ssh }, + viaRelay = { throw HostException("Not checked in the background: not approved") } + ) + assertEquals(Outcome.UNREACHABLE, outcome) + } + + @Test + fun `an answer that fails once connected is unconfirmed, and is not sent again`() = runBlocking { + val event = ev() + var tries = 0 + val conn = FakeConn(snapshot(AgentState.BLOCKED, event), answer = { _, _ -> tries++; throw HostException("socket closed") }) + val outcome = InboxNotificationActions.perform(plan(event)[0], event, { conn }, { error("unused") }) + assertEquals(Outcome.UNCONFIRMED, outcome) + assertEquals(1, tries) + } + + @Test + fun `an answer that hangs is unconfirmed within its budget`() = runBlocking { + val event = ev() + val conn = FakeConn(snapshot(AgentState.BLOCKED, event), answer = { _, _ -> awaitCancellation() }) + val outcome = InboxNotificationActions.perform(plan(event)[0], event, { conn }, { error("unused") }, answerBudgetMs = 100) + assertEquals(Outcome.UNCONFIRMED, outcome) + } + + @Test + fun `Open answers nothing and connects to nothing`() = runBlocking { + val event = ev() + val outcome = InboxNotificationActions.perform( + Action(Verb.OPEN, InboxNotificationActions.OPEN_LABEL), event, + connect = { error("Open dialed the computer") }, + viaRelay = { error("Open dialed the relay") } + ) + assertEquals(Outcome.OPEN_SESSION, outcome) + } + + // --- The tap's hand-off ------------------------------------------------------------------------- + + private fun request(event: InboxEvent = question("Yes", "No"), action: Action = plan(event, showDetails = true)[1]) = Request( + hostId = "host-1", computerName = "studio-mac", + notificationId = InboxNotificationActions.notificationId("host-1", event.id), + headline = "Needs you — build-bot", sessionTitle = "build-bot", action = action, event = event + ) + + @Test + fun `a request survives the trip through the Intent and the work input`() { + for (r in listOf(request(), request(ev(), plan(ev())[1]), request(ev(pendingId = null, agentId = null), Action(Verb.APPROVE, "Approve")))) { + val back = Request.decode(r.encode()) + // Everything QuickActions reads, the notification's words, and the action itself. + assertEquals(r.copy(event = r.event.copy(title = "", detail = null)), back) + } + assertEquals("nodeterm.inbox.answer.host-1.e1", request().workName) + assertEquals("host-1:e1".hashCode(), request().notificationId) + } + + @Test + fun `the request carries no title or detail of the event`() { + // Nothing after the tap needs them, and the extras outlive the notification (audit A52). + val raw = request(ev()).encode() + assertFalse("rm -rf" in raw, raw) + assertFalse("secret detail" in raw, raw) + } + + @Test + fun `anything but an encoded request decodes to null`() { + val good = request().encode() + assertTrue(Request.decode(good) != null) + for (bad in listOf( + null, "", "{", "[]", "{}", + good.replace("\"v\":1", "\"v\":2"), + good.replace("\"verb\":\"option\"", "\"verb\":\"always\""), + good.replace("\"option\":1", "\"option\":9"), + good.replace("\"kind\":\"question\"", "\"kind\":\"other\""), + good.replace("\"hostId\":\"host-1\"", "\"hostId\":\"\""), + good.replace("\"nodeId\":\"term-1\"", "\"nodeId\":7") + )) { + assertNull(Request.decode(bad), "decoded: $bad") + } + } + + // --- The app's wiring, pinned in its source ---------------------------------------------------- + + private val notifier get() = AppSourcePins.app("notify/InboxNotifier.kt") + private val actions get() = AppSourcePins.app("notify/InboxActions.kt") + + /** An expression-bodied function of [source], from [start] to the blank line after it. */ + private fun expressionAfter(source: String, start: String): String { + val at = source.indexOf(start) + assertTrue(at >= 0, "not found: $start") + val end = source.indexOf("\n\n", at) + return if (end < 0) source.substring(at) else source.substring(at, end) + } + + @Test + fun `the notification carries the planned actions, each answer behind an unlock`() { + val build = AppSourcePins.blockAfter(notifier, "private fun build(") + AppSourcePins.assertInOrder( + build, + "val quiet = NodetermApp.graph(context).connections.session(host.id).reachableQuietly()", + "for (action in InboxNotificationActions.plan(ev, showDetails, quiet))", + "if (action.answers) answerIntent(context,", + "else open", + "builder.addAction(", + ".setAuthenticationRequired(action.answers)" + ) + // The id the receiver and the worker update is the one the notification was posted under. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(notifier, "fun announce("), + "nm.notify(InboxNotificationActions.notificationId(host.id, ev.id), build(context, host, snapshot, ev, showDetails))" + ) + val session = AppSourcePins.blockAfter(AppSourcePins.app("conn/ConnectionManager.kt"), "fun reachableQuietly()") + AppSourcePins.assertInOrder( + session, + "InboxNotificationActions.reachableQuietly(", + "sshLeg = host.sshAvailable && graph.hosts.route(hostId) != RoutePreference.RELAY_ONLY", + "relayLeg = relayLeg() == LegRouting.RelayLeg.AVAILABLE", + "relayApproved = graph.hosts.relayApproved(hostId)" + ) + } + + @Test + fun `every PendingIntent is immutable and explicit, and the tap names the session`() { + val open = expressionAfter(notifier, "private fun openSession(") + AppSourcePins.assertInOrder( + open, + "PendingIntent.getActivity(", + // One per notification: the extras differ, and FLAG_UPDATE_CURRENT on a shared request code + // hands every notification of a computer the last one's session. + "notificationId,", + "Intent(context, MainActivity::class.java)", + ".putExtra(MainActivity.EXTRA_HOST_ID, hostId)", + ".putExtra(MainActivity.EXTRA_NODE_ID, nodeId)", + ".putExtra(MainActivity.EXTRA_NODE_TITLE, title)", + "PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE" + ) + val answer = expressionAfter(notifier, "private fun answerIntent(") + AppSourcePins.assertInOrder( + answer, + "PendingIntent.getBroadcast(", + "Intent(context, InboxActionReceiver::class.java)", + ".setData(", + "request.action.key", + ".putExtra(EXTRA_REQUEST, request.encode())", + "PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE" + ) + for (src in listOf(notifier, actions)) { + assertFalse("FLAG_MUTABLE" in src, "a mutable PendingIntent lets another app rewrite the answer") + } + val manifest = File(InteropHarness.repoRoot, "android/app/src/main/AndroidManifest.xml").readText() + assertTrue( + Regex("""""").containsMatchIn(manifest), + "the answer receiver must be declared and not exported" + ) + } + + @Test + fun `a tap hands the answer to expedited work, once, after the lock check`() { + val receive = AppSourcePins.blockAfter(actions, "override fun onReceive(") + AppSourcePins.assertInOrder( + receive, + "Request.decode(intent.getStringExtra(InboxNotifier.EXTRA_REQUEST))", + "if (!request.action.answers) return", + "if (Build.VERSION.SDK_INT < 31", + "isDeviceLocked", + "InboxNotifier.settle(context, request, Outcome.LOCKED)", + "return", + "InboxNotifier.sending(context, request)", + ".setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)", + "enqueueUniqueWork(request.workName, ExistingWorkPolicy.KEEP, work)" + ) + } + + @Test + fun `an answer from a notification never makes a first relay handshake, and is never retried`() { + val work = AppSourcePins.blockAfter(actions, "private suspend fun answer(") + AppSourcePins.assertInOrder( + work, + "val watched = session.isWatched", + "if (session.connection == null && !session.reachableQuietly()) return Outcome.NOT_APPROVED", + "InboxNotificationActions.perform(", + "connect = { session.ensureConnected(RelayApprovalGate.Trigger.BACKGROUND) }", + "viaRelay = { session.viaRelay(RelayApprovalGate.Trigger.BACKGROUND) }", + "if (!watched && !session.isWatched) session.disconnect()" + ) + // Every dial in the file is the background one; USER would release a hold and show a code. + assertFalse(Regex("""Trigger\.(USER|AUTO)""").containsMatchIn(actions), "a notification action dials as the user at the app") + assertFalse(Regex("""ensureConnected\(\s*\)|viaRelay\(\s*\)""").containsMatchIn(actions), "a dial with the default trigger") + val doWork = AppSourcePins.blockAfter(actions, "override suspend fun doWork()") + assertFalse("retry" in doWork, "an unconfirmed answer must not be sent again") + AppSourcePins.assertInOrder(doWork, "InboxNotifier.settle(applicationContext, request, outcome)", "return Result.success()") + } + + @Test + fun `a notification tap opens that session's terminal, at launch and while the app runs`() { + val main = AppSourcePins.app("MainActivity.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(main, "private fun Navigator.openTap(tap: NotificationTap)"), + "push(Route.Host(tap.hostId, tab = 2))", + "push(Route.Terminal(tap.hostId, node," + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(main, "override fun onNewIntent(intent: Intent)"), + "setIntent(intent)", + "tapOf(intent)?.let { incomingTap = it }" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(main, "override fun onCreate("), + "val launchTap = if (fresh) tapOf(intent) else null", + "if (launchTap != null && graph.hosts.get(launchTap.hostId) != null) n.openTap(launchTap)", + "LaunchedEffect(tap) {", + "nav.replaceAll(Route.Hosts)", + "nav.openTap(tap)" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(main, "private fun tapOf(intent: Intent?)"), + "getStringExtra(EXTRA_HOST_ID)", + "getStringExtra(EXTRA_NODE_ID)", + "getStringExtra(EXTRA_NODE_TITLE)" + ) + } +} diff --git a/android/tools/check-r8-output.sh b/android/tools/check-r8-output.sh index 8538dade68..f735fa1484 100755 --- a/android/tools/check-r8-output.sh +++ b/android/tools/check-r8-output.sh @@ -43,20 +43,25 @@ for m in $methods; do if grep -Eq "^$(lit "$bridge"): .*[ .]$m\(" "$seeds"; then ok "bridge method $m kept"; else bad "bridge method $m is not a seed"; fi done -# 2. The worker keeps its name (WorkManager stored it) and its (Context, WorkerParameters) constructor. +# 2. The workers keep their names (WorkManager stored them) and their (Context, WorkerParameters) +# constructors: the background check, and the answer sent from a notification (audit A25). worker=dev.nodeterm.android.notify.InboxWorker -if grep -Eq "^$(lit "$worker"): .*\(android\.content\.Context, ?androidx\.work\.WorkerParameters\)" "$seeds"; then - ok "$worker constructor kept" -else - bad "$worker (Context, WorkerParameters) constructor is not a seed" -fi +action_worker=dev.nodeterm.android.notify.InboxActionWorker +for w in "$worker" "$action_worker"; do + if grep -Eq "^$(lit "$w"): .*\(android\.content\.Context, ?androidx\.work\.WorkerParameters\)" "$seeds"; then + ok "$w constructor kept" + else + bad "$w (Context, WorkerParameters) constructor is not a seed" + fi +done -# 3. Classes loaded by name keep their names: the worker, BouncyCastle's provider, one of the algorithm +# 3. Classes loaded by name keep their names: the workers, BouncyCastle's provider, one of the algorithm # tables it loads by string concatenation, the X25519 SPI sshj's curve25519 key exchange asks it # for, and an exception whose name user-facing error text can fall back to. A class R8 did not # rename may be absent from mapping.txt; a pinned one is then still a line of its own in seeds.txt. for c in \ "$worker" \ + "$action_worker" \ org.bouncycastle.jce.provider.BouncyCastleProvider \ 'org.bouncycastle.jcajce.provider.symmetric.AES$Mappings' \ 'org.bouncycastle.jcajce.provider.asymmetric.edec.KeyAgreementSpi$X25519' \ diff --git a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java index 6826e26905..3cf1087703 100644 --- a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java +++ b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java @@ -8,6 +8,13 @@ public class NotificationCompat { public static final int VISIBILITY_SECRET = Notification.VISIBILITY_SECRET; public abstract static class Style {} public static class BigTextStyle extends Style { public BigTextStyle bigText(CharSequence cs) { return this; } } + public static class Action { + public static final class Builder { + public Builder(int icon, CharSequence title, PendingIntent intent) {} + public Builder setAuthenticationRequired(boolean authenticationRequired) { return this; } + public Action build() { return null; } + } + } public static class Builder { public Builder(Context context, String channelId) {} public Builder setSmallIcon(int icon) { return this; } @@ -20,6 +27,11 @@ public Builder(Context context, String channelId) {} public Builder setContentIntent(PendingIntent intent) { return this; } public Builder setVisibility(int visibility) { return this; } public Builder setPublicVersion(Notification n) { return this; } + public Builder addAction(Action action) { return this; } + public Builder setOnlyAlertOnce(boolean onlyAlertOnce) { return this; } + public Builder setSilent(boolean silent) { return this; } + public Builder setTimeoutAfter(long durationMs) { return this; } + public Builder setProgress(int max, int progress, boolean indeterminate) { return this; } public Notification build() { return null; } } } diff --git a/android/tools/typecheck/stubs/java/androidx/work/Data.java b/android/tools/typecheck/stubs/java/androidx/work/Data.java new file mode 100644 index 0000000000..bde2e4912f --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/Data.java @@ -0,0 +1,8 @@ +package androidx.work; +public final class Data { + public String getString(String key) { return null; } + public static final class Builder { + public Builder putString(String key, String value) { return this; } + public Data build() { return null; } + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/ExistingWorkPolicy.java b/android/tools/typecheck/stubs/java/androidx/work/ExistingWorkPolicy.java new file mode 100644 index 0000000000..aa6ca24704 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/ExistingWorkPolicy.java @@ -0,0 +1,2 @@ +package androidx.work; +public enum ExistingWorkPolicy { REPLACE, KEEP, APPEND, APPEND_OR_REPLACE } diff --git a/android/tools/typecheck/stubs/java/androidx/work/ForegroundInfo.java b/android/tools/typecheck/stubs/java/androidx/work/ForegroundInfo.java new file mode 100644 index 0000000000..6998cf43ae --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/ForegroundInfo.java @@ -0,0 +1,5 @@ +package androidx.work; +import android.app.Notification; +public final class ForegroundInfo { + public ForegroundInfo(int notificationId, Notification notification) {} +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java b/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java index 6aae4f39b3..673e29d4f3 100644 --- a/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java +++ b/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java @@ -3,5 +3,6 @@ public abstract class ListenableWorker { public ListenableWorker(Context appContext, WorkerParameters workerParams) {} public final Context getApplicationContext() { return null; } + public final Data getInputData() { return null; } public abstract static class Result { public static Result success() { return null; } public static Result retry() { return null; } } } diff --git a/android/tools/typecheck/stubs/java/androidx/work/OneTimeWorkRequest.java b/android/tools/typecheck/stubs/java/androidx/work/OneTimeWorkRequest.java new file mode 100644 index 0000000000..f18cd2668e --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/OneTimeWorkRequest.java @@ -0,0 +1,9 @@ +package androidx.work; +public final class OneTimeWorkRequest { + public static final class Builder { + public Builder(Class workerClass) {} + public Builder setExpedited(OutOfQuotaPolicy policy) { return this; } + public Builder setInputData(Data inputData) { return this; } + public OneTimeWorkRequest build() { return null; } + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/OutOfQuotaPolicy.java b/android/tools/typecheck/stubs/java/androidx/work/OutOfQuotaPolicy.java new file mode 100644 index 0000000000..3c36d2742b --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/OutOfQuotaPolicy.java @@ -0,0 +1,2 @@ +package androidx.work; +public enum OutOfQuotaPolicy { RUN_AS_NON_EXPEDITED_WORK_REQUEST, DROP_WORK_REQUEST } diff --git a/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java b/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java index 5265f8b33f..8c70b1ac3f 100644 --- a/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java +++ b/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java @@ -4,4 +4,5 @@ public abstract class WorkManager { public static WorkManager getInstance(Context context) { return null; } public abstract Object cancelUniqueWork(String uniqueWorkName); public abstract Object enqueueUniquePeriodicWork(String uniqueWorkName, ExistingPeriodicWorkPolicy policy, PeriodicWorkRequest request); + public final Object enqueueUniqueWork(String uniqueWorkName, ExistingWorkPolicy existingWorkPolicy, OneTimeWorkRequest work) { return null; } } diff --git a/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java b/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java index 363df0f8db..8d0d0f0a98 100644 --- a/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java +++ b/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java @@ -1,5 +1,5 @@ package dev.nodeterm.android; public final class R { - public static final class string { public static final int app_name = 1, channel_attention = 2, channel_attention_desc = 3, channel_done = 4, channel_done_desc = 5; } + public static final class string { public static final int app_name = 1, channel_attention = 2, channel_attention_desc = 3, channel_done = 4, channel_done_desc = 5, channel_sending = 7, channel_sending_desc = 8; } public static final class drawable { public static final int ic_launcher_foreground = 6; } } diff --git a/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt index 4a34de3299..3fce8f45d1 100644 --- a/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt +++ b/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt @@ -3,6 +3,9 @@ import android.content.Context import java.util.concurrent.TimeUnit abstract class CoroutineWorker(appContext: Context, params: WorkerParameters) : ListenableWorker(appContext, params) { abstract suspend fun doWork(): Result + open suspend fun getForegroundInfo(): ForegroundInfo = throw IllegalStateException("Not implemented") } inline fun PeriodicWorkRequestBuilder(repeatInterval: Long, repeatIntervalTimeUnit: TimeUnit): PeriodicWorkRequest.Builder = PeriodicWorkRequest.Builder(W::class.java, repeatInterval, repeatIntervalTimeUnit) +inline fun OneTimeWorkRequestBuilder(): OneTimeWorkRequest.Builder = + OneTimeWorkRequest.Builder(W::class.java) diff --git a/docs/android.md b/docs/android.md index 2464889662..9da97db1cb 100644 --- a/docs/android.md +++ b/docs/android.md @@ -388,6 +388,36 @@ seen-log only when something is new. The decision and the screen bookkeeping are wiring into the refresh, the worker and the two screens is pinned in the source, and whether a notification appears on a phone is a device check. +`NotificationActionsTest` covers answering from a notification, and where its tap goes (`A25`, the +in-app part). A notification had no actions, and its tap opened only the computer's Inbox. Now an +approval carries Approve and Deny and a single-select question its options, and the tap opens that +session's terminal, with the computer's Inbox one Back away. Which actions an event gets is the pure +`InboxNotificationActions.plan`, on the Inbox card's own rules: Approve and Deny only where +`QuickActions` can answer from outside the session (a held ticket, or a claude prompt), options only +where `QuestionChoices` lists them as answers, and at most three, Android's limit. A question with more +options, a multi-select one, another agent's approval without a ticket, and a computer the phone could +reach only through a first relay handshake get Open instead. With "Show details in notifications" off +the options are labelled "Option 1", "Option 2", … (`A52`). A tap reaches an unexported receiver +through an explicit, immutable PendingIntent. The receiver takes the actions off the notification +("Approving…", so a second tap cannot send a second answer) and hands the answer to expedited work, one +per event. The work connects the way the background check does and never makes a first relay +handshake, since the user tapped but is not looking at the app to compare the desktop's code (`A05`): +it reuses a connection already open, or takes the SSH leg or a relay that has already approved this +phone; otherwise the notification says to open the app. The answer is `QuickActions`', with its +re-checks (still unresolved, the node-state rules, the ticketed path of `A38`), and it is never sent +twice. The notification then says how it went. "Approved.", "Denied.", "Answered with option N." and +"Already handled." go away after 10 s; a timed-out hold, a request only the session can answer, an +answer not sent (and why) and one that could not be confirmed stay, and their tap opens the session. +The app does not open the session by itself at that point: Android 12 forbids starting an activity +from a notification's receiver or service (a "trampoline"), and Android 10 forbids starting one from +the background, so the notification says "Tap to …" instead. An answering action needs an unlocked +phone: Android 12 and later ask for the unlock before they send it (`setAuthenticationRequired`); +Android 11 and lower send it from a locked screen, so there the receiver refuses and says to unlock. +The plan, the answer run against `QuickActions` (every answer the plan offers is one `QuickActions` +sends), the outcomes and the hand-off's encoding are unit-tested; the PendingIntents, the receiver, the +work, its dial trigger and the tap's route are pinned in the source, and whether the actions show and +answer on a phone is a device check. + `PhoneIdentityTest` and `BackupRulesTest` cover what leaves the phone (`A51`). `allowBackup="false"` stops cloud backup, but an app that targets Android 12 or later is still copied by a device-to-device transfer unless its data extraction rules exclude it. The transfer carried @@ -595,8 +625,9 @@ later fix left to a device. answer lands in the live session in one tap. A multi-select question lists its options read-only beside "Open session". *(A12, A57, A65)* 42. Open a finished session on the phone: the desktop's unread dot clears. *(A65)* -43. A background notification arrives within about 15 minutes; tapping it opens that computer's Inbox, - also when the app is already open on another computer. *(A11, A19)* +43. A background notification arrives within about 15 minutes; tapping it opens that session's + terminal, with that computer's Inbox one Back away: at launch, with the app in the background, and + with the app open on another computer. *(A11, A19, A25)* 44. Notification permission on Android 13 or later: deny it at first launch; Settings → Notifications then reads Off; switching it on asks again or opens the app's notification settings; nothing is posted while it is denied. *(A21)* @@ -665,10 +696,35 @@ later fix left to a device. ("Copied N lines"), Share opens the system share sheet, and Back or × closes the sheet. The sheet opens on the line at the top of the screen. *(A32)* +### Notification actions + +57. An approval notification (a held Claude permission, through the relay and again on the same + network) shows Approve and Deny. With the phone unlocked, Approve answers it within a few seconds: + the notification reads "Approving…", then "Approved." and goes away by itself, and the desktop's + prompt is answered. Deny the same way. With the phone locked: on Android 12 or later the tap asks + for the unlock first; on Android 8 to 11 the notification says to unlock and nothing is sent. On + Android 11 or lower a short "Sending your answer…" notification shows while an answer is sent. + *(A25)* +58. A single-select question with up to three options shows "Option 1", "Option 2", … (the options' + text with "Show details in notifications" on, cut to fit); a tap answers it in the live session. + One with four or more options, and a multi-select one, shows Open, which opens the session. + *(A25, A57)* +59. Answer from a notification something already answered on the desktop: "Already handled.". Answer a + held approval after its hold expired: the notification says it timed out, and its tap opens the + session. Away from the desktop's network, on a computer this phone has never connected to through + the relay, a new approval's notification offers Open rather than Approve, and nothing appears on + the desktop. Tapping Approve twice quickly sends one answer. *(A25, A05, A06)* + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's - `/v1/push/*` fan-out is APNs-only. + `/v1/push/*` fan-out is APNs-only, so nothing wakes the app when an agent needs you, and there is no + equivalent of iOS's Live Activities (an ongoing notification would need FCM or a foreground + service). The desktop's phone-push switches (Needs you, Done, hold alerts while at the computer, the + per-computer mute) gate only its own APNs send and are not in the mirror's `settings`, so no phone + can read them; Android's per-kind control is its two notification channels. What the app does have + (`A25`): the notifications it posts itself carry Approve / Deny and a question's options, and their + tap opens the session. - **`/v1/relay/join`.** The request/response shape is not in this repo (the backend is separate). The client sends `{deviceToken, hostId}` and accepts `pairingToken`, `token` or `joinToken` — unverified against the live backend. From 71b592aeda2326477d40a71637aaa91b3752723c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:42:54 +0000 Subject: [PATCH 096/284] feat(android): needs-you count per computer and an All computers screen (A55) iOS merges its Agents feed across every paired connection and shows one Usages section per connection that reports usage (docs/mobile-usage-inbox.md). Android showed one computer's Inbox and Usage, inside that computer's screen, and the computers list showed no needs-you count at all. What changed: - android/protocol, new model/AllComputers.kt (pure): - InboxFeed.of(listings): the Inbox's one rule for one computer or several. Events newest first ACROSS computers (stable sort: the same moment keeps the computers' order, then each mirror's), open approvals/questions above the archive, working sessions in the computers' order (not by updatedAt, which moves with every tool call and would reshuffle the live cards on each refresh). Every item keeps its ComputerListing, and its key names the computer (two computers can mint the same event id, or share node ids). - AllComputers.needsYou (one count for the row, the Inbox tab and the merged screen), offered (2+ computers), labels (pairing name; a name two computers share gets user@host, the same computer paired twice a number), usage (one section per computer whose mirror has accounts). - App: each HostsScreen row shows "N need you" from its session's cached snapshot (no dial). With 2+ computers paired the list starts with an "All computers" entry (total count) that opens AllComputersScreen: Inbox and Usage tabs, a per-computer status strip (connecting, the approval code under the computer's name, a failure with Try again). - InboxTab's card list became InboxFeedList (same file), used by both screens; Open/Approve/Answer are bound to the card's own computer (graph.connections.session(card's hostId), its connection, and its relay leg on NeedsRelayException). UsageCard is shared, not copied. - The merged Inbox holds every computer's onScreen.showInbox() while started, so a listing announces none of what it shows (A73). - Route.AllComputers saved in the back stack as ["all"]; every earlier route keeps its saved form, and a build that does not know the name drops just that entry (BackStack.decode). - HostSession.refresh takes an optional trigger (default USER, so every existing caller is unchanged). - Docs: docs/android.md (what is verified, the notifications paragraph, device checklist item 60 in a new "All computers" section) and android/README.md (feature row, app description, notifications); the InboxNotifier comment says the merged screen is not polled. Departures from the audit / task proposal: - Opening the screen (and its Refresh) re-lists every computer with Trigger.AUTO, not Trigger.USER. Both go through the normal connect path and RelayApprovalGate, and both allow a first relay handshake in the foreground (its code shows under the computer's name). The difference is only the hold: USER would lift a computer's held refusal/timeout on every visit and raise the desktop's dialog again, each time, on a computer the user did not ask about and may not be at (what A05/A23 hold against). Each computer's own Try again is Trigger.USER and lifts it, which is what the hold's message tells the user to tap. - The merged screen is offered only with two or more paired computers (with one, its own Inbox/Usage tabs are all of it); a restored route still works with fewer. - Not polled: re-listed on ON_START (open, return from a terminal or the background) and Refresh, not every 8 s; the live 8 s refresh stays the computer's own screen's. - The per-computer Usage footer read "Updated now ago"; it and the merged section headers now share updatedAgo ("Updated just now"). Verified: new AllComputersTest (18 tests: merge order, ties, keys, per-computer context ring, working order, archive split, labels, usage sections, and source pins for the row count/no dials, the trigger split, showInbox on every computer, card actions bound to their computer, the route codec). Mutation-checked: per-computer sort instead of a merged one, plain-name labels, sections for empty usage, working sorted by time, needsYou counting non-actionable events, HostsScreen at HEAD, a screen-wide session in runOn, no showInbox, startWatching instead of a refresh, and the "all" decode removed each turn tests red. Full protocol suite: 450 tests, 0 failures, 0 skipped (gradle test --offline). Offline app type-check (tools/typecheck, gradle compileKotlin --offline) passes; no new stubs were needed. Nothing ran on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 23 +- .../dev/nodeterm/android/MainActivity.kt | 10 +- .../android/conn/ConnectionManager.kt | 11 +- .../nodeterm/android/notify/InboxNotifier.kt | 5 +- .../nodeterm/android/ui/AllComputersScreen.kt | 259 +++++++++++ .../dev/nodeterm/android/ui/HostScreen.kt | 4 +- .../dev/nodeterm/android/ui/HostsScreen.kt | 56 ++- .../dev/nodeterm/android/ui/InboxTab.kt | 233 ++++++---- .../nodeterm/protocol/model/AllComputers.kt | 130 ++++++ .../dev/nodeterm/protocol/AllComputersTest.kt | 403 ++++++++++++++++++ docs/android.md | 49 ++- 11 files changed, 1082 insertions(+), 101 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/AllComputersScreen.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AllComputers.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/AllComputersTest.kt diff --git a/android/README.md b/android/README.md index c6dbfcdb48..5b3231e82e 100644 --- a/android/README.md +++ b/android/README.md @@ -36,6 +36,7 @@ written for it and tested where the layer allows, and the numbered | Inbox: approvals, questions, finished turns | ✓ | Held hook approvals are answered deterministically (never with keystrokes); a multi-select question lists its options but is answered in the session; each card shows its node's context % when known | | Read-ack (reading a finished session clears it on the computer) | ✓ | `inbox.ack` over the relay, `~/.nodeterm/acks` over SSH | | Usage (rate limits per account) | ✓ | From the agent-status mirror, with a pace line ("5h usage pace faster") when the reset time is known | +| All computers: every paired computer's Inbox and Usage on one screen | ✓ | With two or more computers paired: cards newest first across computers, each naming its computer and answered on it; one Usage section per computer that reports usage. Each computer's row shows how many of its approvals and questions are open, from its last listing (the list dials nothing) | | Notifications | local | See "Notifications" below | ## Build @@ -100,8 +101,9 @@ are also debuggable, which hands the phone's pairing credentials to anyone with Linux, where node-pty ships no prebuild, not built at all) and `src/main/node-pty-patch.test.ts` red until you run `npm install` or `npm run rebuild` again (`bootstrap-windows.bat` on Windows). The SSH tests need tmux and `script(1)` (util-linux on Linux, BSD on macOS) and skip without them. -- **`app/`** — the Compose UI on top: pairing, the computers list, a computer's Sessions / Board / - Inbox / Usage tabs, the terminal screen, a project's source control, settings, background +- **`app/`** — the Compose UI on top: pairing, the computers list (each with its needs-you count), + a computer's Sessions / Board / Inbox / Usage tabs, the All computers screen (every computer's + Inbox and Usage), the terminal screen, a project's source control, settings, background notifications. ## Notifications @@ -117,16 +119,17 @@ leg, so this app polls instead: checked about every 15 minutes in the background floor), and live for the computer whose screen is open, which the app re-lists every 8 seconds. Other paired computers are not polled while you look at one, so their notifications still come from the background check only. That includes a computer you just left: the app may still hold its -connection for a while, but no longer re-lists it when it pushes a change. Real-time push on Android -needs an FCM leg in the backend. +connection for a while, but no longer re-lists it when it pushes a change. The All computers screen +is not polled either: it re-lists every computer once when you open it, and again on Refresh. +Real-time push on Android needs an FCM leg in the backend. The live check leaves out what you are looking at: nothing of a computer is announced while its -Inbox tab is on screen, and nothing a session's terminal shows while it is attached. Those events -count as seen, so no later check announces them either. An approval the computer is holding for an -answer (a hook-reply approval) is still announced with its terminal open, because Claude paints that -prompt only once the hold ends. A terminal that shows an error, a relay offer or an approval code -instead of the session hides nothing, and one still connecting leaves the session's events for the -next check. +Inbox tab (or the All computers Inbox) is on screen, and nothing a session's terminal shows while it +is attached. Those events count as seen, so no later check announces them either. An approval the +computer is holding for an answer (a hook-reply approval) is still announced with its terminal open, +because Claude paints that prompt only once the hold ends. A terminal that shows an error, a relay +offer or an approval code instead of the session hides nothing, and one still connecting leaves the +session's events for the next check. Tapping a notification opens that session's terminal; Back from it lands on the computer's Inbox. An approval's notification carries **Approve** and **Deny**, and a question's carries its options when it diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt index 09b8c710b0..e8ed3f78d3 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.core.content.ContextCompat +import dev.nodeterm.android.ui.AllComputersScreen import dev.nodeterm.android.ui.HostScreen import dev.nodeterm.android.ui.HostsScreen import dev.nodeterm.android.ui.NodetermTheme @@ -28,7 +29,7 @@ import dev.nodeterm.android.ui.SourceControlScreen import dev.nodeterm.android.ui.TerminalScreen import dev.nodeterm.protocol.model.BackStack -/** The screens. A plain back stack: six destinations, one deep link (the pairing URL). */ +/** The screens. A plain back stack: seven destinations, one deep link (the pairing URL). */ sealed interface Route { data object Hosts : Route data class PairHost(val code: String? = null) : Route @@ -37,6 +38,8 @@ sealed interface Route { data class Terminal(val hostId: String, val nodeId: String, val title: String) : Route /** One project's source control on that computer (audit A29). */ data class SourceControl(val hostId: String, val projectId: String) : Route + /** Every paired computer's Inbox and Usage, merged (audit A55). Names no computer of its own. */ + data object AllComputers : Route } /** @@ -99,6 +102,9 @@ class Navigator(initial: BackStack) { is Route.Host -> listOf("host", r.hostId, r.tab.toString()) is Route.Terminal -> listOf("terminal", r.hostId, r.nodeId, r.title) is Route.SourceControl -> listOf("git", r.hostId, r.projectId) + // A new name, not a new shape: a stack saved before this route existed decodes as before, + // and a build without it drops the entry (decode answers null) instead of guessing. + Route.AllComputers -> listOf("all") } private fun decode(parts: List): Route? = when (parts.firstOrNull()) { @@ -109,6 +115,7 @@ class Navigator(initial: BackStack) { "host" -> parts.getOrNull(1)?.let { Route.Host(it, parts.getOrNull(2)?.toIntOrNull() ?: 0) } "terminal" -> if (parts.size == 4) Route.Terminal(parts[1], parts[2], parts[3]) else null "git" -> if (parts.size == 3) Route.SourceControl(parts[1], parts[2]) else null + "all" -> Route.AllComputers else -> null } } @@ -250,6 +257,7 @@ private fun AppContent(nav: Navigator) { is Route.Host -> HostScreen(nav, r.hostId, r.tab) is Route.Terminal -> TerminalScreen(nav, r.hostId, r.nodeId, r.title) is Route.SourceControl -> SourceControlScreen(nav, r.hostId, r.projectId) + Route.AllComputers -> AllComputersScreen(nav) } } } diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt index 9f3df4bad9..da4ba416b9 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -421,9 +421,14 @@ class HostSession(val hostId: String, private val graph: AppGraph) { runCatching { graph.hosts.claimLive(events, OnScreen(nodes = setOf(nodeId)), notify = false) } } - /** A refresh the USER asked for (Refresh, Try again). */ - fun refresh() { - scope.launch { refreshNow(Trigger.USER) } + /** + * A refresh the USER asked for (Refresh, Try again), by default. The All computers screen re-lists + * every computer as the app's own foreground refresh instead ([Trigger.AUTO], audit A55): it may + * show a first approval code, but it does not lift a computer's held refusal; that computer's own + * Try again does. + */ + fun refresh(trigger: Trigger = Trigger.USER) { + scope.launch { refreshNow(trigger) } } /** diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt index 6cccc60108..1f145d5957 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -48,8 +48,9 @@ import java.util.concurrent.TimeUnit * each paired computer, and the app re-lists the computer on screen every 8 s, and when it pushes a * change (audit A73). Other computers are not polled, and a change one pushes over a connection still * open from a screen the user left is not re-listed, so their notifications come from the background - * check only. What the user is looking at ([OnScreen]) is recorded as seen instead of announced. A - * real push leg is backend work. + * check only. The All computers screen (audit A55) re-lists every computer once when it opens, and on + * Refresh, not every 8 s. What the user is looking at ([OnScreen]; on that screen's Inbox, every + * computer's Inbox) is recorded as seen instead of announced. A real push leg is backend work. * * A notification's tap opens that session's terminal, and an approval or a question carries the * answers the Inbox card offers (audit A25): which ones is [InboxNotificationActions.plan], and an diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/AllComputersScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/AllComputersScreen.kt new file mode 100644 index 0000000000..d7b71b40c1 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/AllComputersScreen.kt @@ -0,0 +1,259 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.conn.ConnState +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger +import dev.nodeterm.protocol.model.AllComputers +import dev.nodeterm.protocol.model.ComputerLabel +import dev.nodeterm.protocol.model.ComputerListing +import dev.nodeterm.protocol.model.InboxFeed +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +/** + * Every paired computer's Inbox and Usage on one screen (audit A55), like iOS's Agents and Usages tabs + * (docs/mobile-usage-inbox.md: "merged events across connections, newest first"; "one section per + * paired connection that reports `usage`"). Each card names its computer, and Open / Approve / Answer + * go to that computer's own session ([InboxFeedList]). The rules (merge, order, labels, sections) are + * [AllComputers], pure and tested; this only draws them. + * + * Opening the screen re-lists every paired computer once, through each one's normal connect path. It + * is not polled after that: Refresh re-lists them all, a computer's Try again re-lists it, and an + * answer re-lists its own computer. The live 8 s refresh stays the computer's own screen's. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AllComputersScreen(nav: Navigator) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val hosts by graph.hosts.hosts.collectAsState() + val listings = rememberListings(hosts) + val hostIds = remember(hosts) { hosts.map { it.id } } + val uiScope = rememberCoroutineScope() + // Saved under this back-stack entry's key, so it survives a terminal opened from a card (A43). + var tab by rememberSaveable { mutableIntStateOf(0) } + val tabStates = rememberSaveableStateHolder() + + // Every computer re-listed through its normal connect path, so a relay leg goes through + // RelayApprovalGate. As the app's own FOREGROUND refresh (Trigger.AUTO), not a tap on that computer: + // the user is looking, so a computer that has never approved this phone may show its code below, + // but a computer whose approval was refused or went unanswered keeps that hold (A05/A23). Lifting + // it would raise the desktop's dialog again on every visit to this screen, on a computer the user + // did not ask about and may not be at; its own Try again below asks (Trigger.USER). + fun refreshAll() = hostIds.forEach { graph.connections.session(it).refresh(Trigger.AUTO) } + + // On ON_START, like a computer's own screen starts watching: opening the screen, coming back to the + // app, and coming back from a terminal re-list. Launched on the UI dispatcher, i.e. after this + // frame's effects: the Inbox tab registers what it shows (A73) before the first listing is asked for. + LifecycleStartEffect(hostIds) { + uiScope.launch { refreshAll() } + onStopOrDispose { } + } + + val feed = AllComputers.feed(listings) + val needsYou = feed.actionable.size + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("All computers") + Text( + "Inbox and usage from ${hosts.size} computer" + (if (hosts.size == 1) "" else "s"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + actions = { IconButton(onClick = { refreshAll() }) { Icon(Icons.Filled.Refresh, "Refresh") } } + ) + } + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + // What stands between a computer and its cards: connecting, a code to approve, an error. + for (l in listings) key(l.computer.hostId) { ComputerStatus(l.computer, graph.connections.session(l.computer.hostId)) } + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(if (needsYou > 0) "Inbox ($needsYou)" else "Inbox") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Usage") }) + } + tabStates.SaveableStateProvider(tab) { + when (tab) { + 0 -> AllInbox(nav, hostIds, feed) + else -> AllUsage(AllComputers.usage(listings)) + } + } + } + } +} + +/** + * Each paired computer's cached listing (its session's latest snapshot), labelled ([AllComputers.labels]), + * in [hosts] order. It reads what the sessions already hold and updates as they re-list: nothing here + * connects, so the computers list can show its counts without a dial. + */ +@Composable +internal fun rememberListings(hosts: List): List { + val graph = NodetermApp.graph(LocalContext.current) + val labels = remember(hosts) { AllComputers.labels(hosts) } + val initial = remember(labels) { labels.map { ComputerListing(it, graph.connections.session(it.hostId).snapshot.value) } } + val flow = remember(labels) { + val snapshots = labels.map { graph.connections.session(it.hostId).snapshot } + if (snapshots.isEmpty()) flowOf(emptyList()) + else combine(snapshots) { latest: Array -> labels.mapIndexed { i, l -> ComputerListing(l, latest[i]) } } + } + val listings by flow.collectAsState(initial) + return listings +} + +/** The merged Inbox. While it is on screen, so is every computer's Inbox (A73). */ +@Composable +private fun AllInbox(nav: Navigator, hostIds: List, feed: InboxFeed) { + val graph = NodetermApp.graph(LocalContext.current) + // While this tab is on screen, a listing of ANY computer announces none of its events; it records + // them as seen, so no later check announces them either, as a computer's own Inbox tab does (A73). + LifecycleStartEffect(hostIds) { + val showing = hostIds.map { graph.connections.session(it).onScreen.showInbox() } + onStopOrDispose { showing.forEach { it.close() } } + } + InboxFeedList(nav, feed, showComputer = true, emptyText = "Nothing needs you on any computer.") +} + +/** One section per computer that reports usage: its label and when it was measured, then its accounts. */ +@Composable +private fun AllUsage(sections: List) { + if (sections.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "None of your computers is reporting usage. (Usage is shared by nodeterm on each computer for its local accounts.)", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + for (section in sections) { + val hostId = section.computer.hostId + item(key = "h-$hostId") { + Row(Modifier.fillMaxWidth().padding(top = 6.dp), verticalAlignment = Alignment.Bottom) { + Text( + section.computer.label, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + updatedAgo(section.usage.updatedAt)?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + // By position: the accounts of one computer are not guaranteed distinct by id and agent. + itemsIndexed(section.usage.accounts, key = { i, _ -> "u-$hostId-$i" }) { _, account -> + UsageCard(account, section.status) + } + } + } +} + +/** + * What keeps a computer's cards from this screen, if anything: it is connecting, the computer shows an + * approval code (the relay's first connect, or the relay leg an answer opened next to SSH), its connect + * failed, or its listing did. Nothing while it is connected and listed. + */ +@Composable +private fun ComputerStatus(computer: ComputerLabel, session: HostSession) { + val state by session.state.collectAsState() + val listError by session.lastError.collectAsState() + val relayApproval by session.relayApproval.collectAsState() + when (val s = state) { + is ConnState.AwaitingApproval -> ApprovalFor(computer, s.sas) + is ConnState.Connecting -> Text( + "${computer.label}: ${s.detail}", + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Try again is the user asking about THIS computer (Trigger.USER): it lifts a held approval. + is ConnState.Failed -> Problem("${computer.label}: ${s.message}") { session.refresh(Trigger.USER) } + is ConnState.Connected -> listError?.let { Problem("${computer.label}: $it") { session.refresh(Trigger.USER) } } + ConnState.Idle -> Unit + } + relayApproval?.let { if (state !is ConnState.AwaitingApproval) ApprovalFor(computer, it) } +} + +@Composable +private fun ApprovalFor(computer: ComputerLabel, sas: String) { + Column(Modifier.fillMaxWidth()) { + Text( + computer.label, + Modifier.fillMaxWidth().background(NtColors.panel2).padding(start = 16.dp, end = 16.dp, top = 10.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + ApprovalCode(sas) + } +} + +@Composable +private fun Problem(text: String, onRetry: () -> Unit) { + Row( + Modifier.fillMaxWidth().background(NtColors.attention.copy(alpha = 0.12f)).padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + TextButton(onClick = onRetry) { Text("Try again") } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt index 595c9e825c..9a802b0e97 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -49,6 +49,7 @@ import dev.nodeterm.android.conn.HostSession import dev.nodeterm.protocol.host.Capability import dev.nodeterm.protocol.host.LegRouting import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AllComputers import dev.nodeterm.protocol.model.NewSessionChoice @OptIn(ExperimentalMaterial3Api::class) @@ -79,7 +80,8 @@ fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { onStopOrDispose { session.stopWatching() } } - val needsYou = snapshot.status?.inbox?.events?.count { it.actionable } ?: 0 + // One count for this tab, the computer's row in the list and the All computers screen (A55). + val needsYou = AllComputers.needsYou(snapshot) // New session goes through nodeterm the app (the relay's `projects.registerNode`): on the LAN // (direct SSH) that is the relay leg opened next to it, and where this phone has none the button // stays, disabled, with the reason (audit A26) — it used to vanish without a word. diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt index 250a6d37cd..664be268b9 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt @@ -1,6 +1,8 @@ package dev.nodeterm.android.ui import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Settings @@ -36,15 +39,19 @@ 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 import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import dev.nodeterm.android.NodetermApp import dev.nodeterm.android.Navigator import dev.nodeterm.android.Route import dev.nodeterm.android.conn.ConnState import dev.nodeterm.android.data.SecureStore import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AllComputers import dev.nodeterm.protocol.model.PairedHost @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -96,9 +103,39 @@ fun HostsScreen(nav: Navigator) { } } else { LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp)) { + // Every computer's Inbox and Usage on one screen (audit A55), once a second one is paired. + if (AllComputers.offered(hosts.size)) { + item(key = "all-computers") { + // From what each session already holds: listing the computers list dials nothing. + val total = rememberListings(hosts).sumOf { AllComputers.needsYou(it.snapshot) } + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clickable { nav.push(Route.AllComputers) } + ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("All computers", style = MaterialTheme.typography.titleMedium) + Text( + "Inbox and usage from every computer", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + NeedsYouBadge(total) + } + } + } + } items(hosts, key = { it.id }) { host -> val session = graph.connections.session(host.id) val state by session.state.collectAsState() + // The needs-you count on the row (audit A55): from the listing this session already + // holds (an open screen's, the background check's), never a dial of its own. + val snapshot by session.snapshot.collectAsState() + val needsYou = AllComputers.needsYou(snapshot) // Whether a token is STORED, never its value: decrypting it here was a Keystore // round trip on the main thread per row per recomposition, and it waited on // SecureStore's lock behind any connection decrypting meanwhile (audit A47). @@ -117,7 +154,8 @@ fun HostsScreen(nav: Navigator) { Row(verticalAlignment = Alignment.CenterVertically) { ColorDot(stateColor(state)) Spacer(Modifier.width(10.dp)) - Text(host.name, style = MaterialTheme.typography.titleMedium) + Text(host.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + NeedsYouBadge(needsYou) } Text( "${host.user}@${host.host}", @@ -160,6 +198,22 @@ fun HostsScreen(nav: Navigator) { } } +/** "2 need you", in the NEEDS YOU badge's colours; nothing for 0. */ +@Composable +private fun NeedsYouBadge(count: Int) { + if (count <= 0) return + Text( + if (count == 1) "1 needs you" else "$count need you", + color = NtColors.attention, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(NtColors.attention.copy(alpha = 0.16f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) +} + fun routeSummary(host: PairedHost, hasRelayToken: Boolean): String { val legs = buildList { if (host.sshAvailable) add("On your network") diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt index 15162b49c0..6de046ba38 100644 --- a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -49,10 +49,12 @@ import dev.nodeterm.protocol.host.NeedsRelayException import dev.nodeterm.protocol.host.QuickActions import dev.nodeterm.protocol.model.AccountNames import dev.nodeterm.protocol.model.Agent -import dev.nodeterm.protocol.model.AgentState import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.ComputerLabel +import dev.nodeterm.protocol.model.ComputerListing import dev.nodeterm.protocol.model.ContextFill import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxFeed import dev.nodeterm.protocol.model.InboxKind import dev.nodeterm.protocol.model.ProjectsSnapshot import dev.nodeterm.protocol.model.QuestionChoices @@ -68,36 +70,50 @@ import java.util.Locale * The Agents feed (docs/mobile-usage-inbox.md): unresolved approvals and questions on top — with * deterministic Approve/Deny for a held hook-reply approval — then live activity, then the archive. * Read/unread is phone-local, like iOS; `resolved` from the computer only moves cards out of the - * actionable list. + * actionable list. This is one computer's; the All computers screen merges every computer's through + * the same [InboxFeed] and [InboxFeedList] (audit A55). */ @Composable fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: ProjectsSnapshot) { - val context = LocalContext.current - val graph = NodetermApp.graph(context) - val scope = rememberCoroutineScope() - // Saveable, so an open archive stays open after a terminal opened from it (audit A43). - var showArchive by rememberSaveable { mutableStateOf(false) } - val inbox = snapshot.status?.inbox - val events = inbox?.events.orEmpty().sortedByDescending { it.ts } - val actionable = events.filter { it.actionable } - val archived = events.filterNot { it.actionable } - val working = snapshot.status?.nodes.orEmpty() - .filter { (id, st) -> st.state == AgentState.WORKING && inbox?.nodes?.get(id)?.activity != null } - - LaunchedEffect(actionable.map { it.id }) { graph.hosts.markSeen(actionable) } + val graph = NodetermApp.graph(LocalContext.current) // While this tab is on screen, the live refresh announces none of this computer's events; it // records them as seen, so the background check does not announce them later either (A73). LifecycleStartEffect(hostId) { val showing = session.onScreen.showInbox() onStopOrDispose { showing.close() } } + val name = graph.hosts.get(hostId)?.name ?: "Computer" + val feed = InboxFeed.of(listOf(ComputerListing(ComputerLabel(hostId, name), snapshot))) + InboxFeedList(nav, feed, showComputer = false, emptyText = "Nothing needs you right now.") +} + +/** What a feed card calls its session: the agent's session name, else the node's title, else "Session". */ +internal fun feedTitle(snapshot: ProjectsSnapshot, nodeId: String): String = + snapshot.findNode(nodeId)?.second?.let { displayTitle(it, snapshot) } ?: snapshot.statusOf(nodeId)?.name ?: "Session" + +/** + * The cards of [feed]: one computer's Inbox tab, or every computer's merged ([showComputer]: each card + * then names its computer). Every card acts on ITS OWN computer (audit A55): Open opens the session on + * the computer the card came from, and an answer goes through that computer's session, since + * [QuickActions] answers over the one connection it is handed, and a merged feed holds cards of + * several computers. + */ +@Composable +internal fun InboxFeedList(nav: Navigator, feed: InboxFeed, showComputer: Boolean, emptyText: String) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val scope = rememberCoroutineScope() + // Saveable, so an open archive stays open after a terminal opened from it (audit A43). + var showArchive by rememberSaveable { mutableStateOf(false) } - fun titleOf(nodeId: String): String = - snapshot.findNode(nodeId)?.second?.let { displayTitle(it, snapshot) } ?: snapshot.statusOf(nodeId)?.name ?: "Session" + LaunchedEffect(feed.actionable.map { it.key }) { graph.hosts.markSeen(feed.actionable.map { it.event }) } - fun open(nodeId: String) = nav.push(Route.Terminal(hostId, nodeId, titleOf(nodeId))) + fun openOn(from: ComputerListing, nodeId: String) = + nav.push(Route.Terminal(from.computer.hostId, nodeId, feedTitle(from.snapshot, nodeId))) - fun run(label: String, block: suspend (HostConnection) -> QuickActions.Result, nodeId: String) { + fun runOn(from: ComputerListing, label: String, block: suspend (HostConnection) -> QuickActions.Result, nodeId: String) { + // The card's own computer, whichever screen shows it. + val session = graph.connections.session(from.computer.hostId) scope.launch { try { val result = try { @@ -111,105 +127,147 @@ fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: Pro when (result) { QuickActions.Result.SENT -> Toast.makeText(context, label, Toast.LENGTH_SHORT).show() QuickActions.Result.ALREADY_HANDLED -> Toast.makeText(context, "Already handled.", Toast.LENGTH_SHORT).show() - QuickActions.Result.OPEN_SESSION -> open(nodeId) + QuickActions.Result.OPEN_SESSION -> openOn(from, nodeId) QuickActions.Result.EXPIRED -> { Toast.makeText(context, "The request timed out on the computer. Answer it in the session.", Toast.LENGTH_LONG).show() - open(nodeId) + openOn(from, nodeId) } } session.refreshNow() } catch (e: Exception) { - Toast.makeText(context, e.message ?: "Couldn't reach the computer.", Toast.LENGTH_LONG).show() + Toast.makeText(context, e.message ?: "Couldn't reach ${from.computer.label}.", Toast.LENGTH_LONG).show() } } } - if (events.isEmpty() && working.isEmpty()) { + if (feed.isEmpty) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Text("Nothing needs you right now.", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(emptyText, color = MaterialTheme.colorScheme.onSurfaceVariant) } return } LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(actionable, key = { "a-${it.id}" }) { ev -> - EventCard(ev, titleOf(ev.nodeId), snapshot, inbox?.nodes?.get(ev.nodeId)?.contextPercent, highlight = true, onOpen = { open(ev.nodeId) }) { - if (ev.kind == InboxKind.APPROVAL) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - run("Approved.", { c -> QuickActions.answerApproval(c, ev, allow = true) }, ev.nodeId) - }) { Text("Approve") } - OutlinedButton(onClick = { - run("Denied.", { c -> QuickActions.answerApproval(c, ev, allow = false) }, ev.nodeId) - }) { Text("Deny") } - TextButton(onClick = { open(ev.nodeId) }) { Text("Open") } - } - } else { - // One rule for what the card offers and what the answer path accepts (audit A57). - when (val choices = QuestionChoices.of(ev)) { - is QuestionChoices.Answer -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - choices.rows.forEachIndexed { i, row -> - OutlinedButton(onClick = { - run("Answered.", { c -> QuickActions.answerQuestion(c, ev, i) }, ev.nodeId) - }, modifier = Modifier.fillMaxWidth()) { Text(row, maxLines = 2) } - } - } - // Multi-select: shown so the card says what is asked, but plain text, not - // buttons. The picker's toggle/submit keys are unmeasured, so it is answered - // in the session (QuestionChoices says why). - is QuestionChoices.ReadOnly -> Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - QuestionChoices.SEVERAL_NOTE, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - choices.rows.forEach { row -> - Text( - row, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 8.dp) - ) - } - TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } - } - QuestionChoices.None -> TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } - } - } + items(feed.actionable, key = { "a-${it.key}" }) { item -> + val ev = item.event + EventCard( + ev, feedTitle(item.from.snapshot, ev.nodeId), item.from.snapshot, item.contextPercent, + computer = item.from.computer.label.takeIf { showComputer }, + highlight = true, + onOpen = { openOn(item.from, ev.nodeId) } + ) { + EventActions( + ev, + open = { nodeId -> openOn(item.from, nodeId) }, + run = { label, block, nodeId -> runOn(item.from, label, block, nodeId) } + ) } } - items(working.keys.toList(), key = { "w-$it" }) { nodeId -> - val now = inbox?.nodes?.get(nodeId) + items(feed.working, key = { "w-${it.key}" }) { live -> + val now = live.now + val nodeId = live.nodeId Column( - Modifier.fillMaxWidth().background(NtColors.panel, RoundedCornerShape(10.dp)).clickable { open(nodeId) }.padding(12.dp), + Modifier.fillMaxWidth().background(NtColors.panel, RoundedCornerShape(10.dp)).clickable { openOn(live.from, nodeId) }.padding(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { + if (showComputer) ComputerLine(live.from.computer.label) Row(verticalAlignment = Alignment.CenterVertically) { StatusBadge(dev.nodeterm.protocol.model.SessionBucket.RUNNING) Spacer(Modifier.width(8.dp)) - Text(titleOf(nodeId), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(feedTitle(live.from.snapshot, nodeId), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) } - now?.prompt?.let { Text("You: $it", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) } - now?.activity?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - ContextIndicator(now?.contextPercent) + now.prompt?.let { Text("You: $it", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) } + now.activity?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + ContextIndicator(now.contextPercent) } } - if (archived.isNotEmpty()) { + if (feed.archived.isNotEmpty()) { item(key = "archive-toggle") { TextButton(onClick = { showArchive = !showArchive }) { - Text(if (showArchive) "Hide archive" else "Archive (${archived.size})") + Text(if (showArchive) "Hide archive" else "Archive (${feed.archived.size})") } } if (showArchive) { - items(archived, key = { "r-${it.id}" }) { ev -> - EventCard(ev, titleOf(ev.nodeId), snapshot, inbox?.nodes?.get(ev.nodeId)?.contextPercent, highlight = false, onOpen = { open(ev.nodeId) }) {} + items(feed.archived, key = { "r-${it.key}" }) { item -> + val ev = item.event + EventCard( + ev, feedTitle(item.from.snapshot, ev.nodeId), item.from.snapshot, item.contextPercent, + computer = item.from.computer.label.takeIf { showComputer }, + highlight = false, + onOpen = { openOn(item.from, ev.nodeId) } + ) {} + } + } + } + } +} + +/** + * What an open card offers. [open] and [run] are bound to the card's own computer by [InboxFeedList]. + */ +@Composable +private fun EventActions( + ev: InboxEvent, + open: (nodeId: String) -> Unit, + run: (label: String, block: suspend (HostConnection) -> QuickActions.Result, nodeId: String) -> Unit +) { + if (ev.kind == InboxKind.APPROVAL) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + run("Approved.", { c -> QuickActions.answerApproval(c, ev, allow = true) }, ev.nodeId) + }) { Text("Approve") } + OutlinedButton(onClick = { + run("Denied.", { c -> QuickActions.answerApproval(c, ev, allow = false) }, ev.nodeId) + }) { Text("Deny") } + TextButton(onClick = { open(ev.nodeId) }) { Text("Open") } + } + } else { + // One rule for what the card offers and what the answer path accepts (audit A57). + when (val choices = QuestionChoices.of(ev)) { + is QuestionChoices.Answer -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + choices.rows.forEachIndexed { i, row -> + OutlinedButton(onClick = { + run("Answered.", { c -> QuickActions.answerQuestion(c, ev, i) }, ev.nodeId) + }, modifier = Modifier.fillMaxWidth()) { Text(row, maxLines = 2) } + } + } + // Multi-select: shown so the card says what is asked, but plain text, not + // buttons. The picker's toggle/submit keys are unmeasured, so it is answered + // in the session (QuestionChoices says why). + is QuestionChoices.ReadOnly -> Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + QuestionChoices.SEVERAL_NOTE, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + choices.rows.forEach { row -> + Text( + row, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp) + ) } + TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } } + QuestionChoices.None -> TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } } } } +/** The computer a card on a merged screen came from (audit A55). */ +@Composable +private fun ComputerLine(label: String) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) +} + @Composable private fun EventCard( ev: InboxEvent, @@ -217,6 +275,8 @@ private fun EventCard( snapshot: ProjectsSnapshot, /** The node's context-window fill (`inbox.nodes[nodeId].contextPercent`), when known. */ contextPercent: Double?, + /** The card's computer, on a merged screen (audit A55); null on a computer's own Inbox. */ + computer: String?, highlight: Boolean, onOpen: () -> Unit, actions: @Composable () -> Unit @@ -234,6 +294,7 @@ private fun EventCard( .padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { + computer?.let { ComputerLine(it) } Row(verticalAlignment = Alignment.CenterVertically) { Text( when (ev.kind) { @@ -307,12 +368,20 @@ fun UsageTab(snapshot: ProjectsSnapshot) { } LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { items(usage.accounts, key = { it.accountId ?: "system" }) { account -> UsageCard(account, snapshot.status) } - item { Text("Updated ${relativeAge(usage.updatedAt)} ago", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + updatedAgo(usage.updatedAt)?.let { item { Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } } +/** "Updated 5m ago" for a usage snapshot taken at [ts]; "Updated just now" for a fresh one; null for no time. */ +internal fun updatedAgo(ts: Long, now: Long = System.currentTimeMillis()): String? = when (val age = relativeAge(ts, now)) { + "" -> null + "now" -> "Updated just now" + else -> "Updated $age ago" +} + +/** One account's usage. Also drawn, per computer, by the All computers screen (audit A55). */ @Composable -private fun UsageCard(account: UsageAccount, status: AgentStatusFile?) { +internal fun UsageCard(account: UsageAccount, status: AgentStatusFile?) { // The same resolver the sessions list uses, so a managed account is never titled by its UUID. val title = account.accountId?.let { AccountNames.managed(it, status) } ?: account.label ?: account.email ?: AccountNames.SYSTEM diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AllComputers.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AllComputers.kt new file mode 100644 index 0000000000..7bce81bee4 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AllComputers.kt @@ -0,0 +1,130 @@ +package dev.nodeterm.protocol.model + +/** + * One paired computer as a merged screen names it (audit A55): its pairing id, and the label its + * cards and usage sections carry. Built by [AllComputers.labels]. + */ +data class ComputerLabel(val hostId: String, val label: String) + +/** A computer's latest listing (its session's cached snapshot), with the label it is shown under. */ +data class ComputerListing(val computer: ComputerLabel, val snapshot: ProjectsSnapshot) + +/** + * What an Inbox shows (docs/mobile-usage-inbox.md, "Agents tab (feed)"): the open approvals and + * questions newest first, then the sessions working right now, then the archive (finished turns and + * resolved cards), newest first. + * + * One computer's (its Inbox tab) and every paired computer's merged (the "All computers" screen, + * audit A55) are built by the same [of], so the merged feed never sorts, splits or counts differently + * from a computer's own. Every card keeps the computer it came from ([Item.from]): its title, its + * context ring and its answer all belong to that computer's listing and session, never to another's. + */ +data class InboxFeed( + val actionable: List, + val working: List, + val archived: List +) { + /** One event, with the listing it came from. */ + data class Item(val from: ComputerListing, val event: InboxEvent) { + val hostId: String get() = from.computer.hostId + + /** A list key. Two computers can mint the same event id, so it names the computer too. */ + val key: String get() = "$hostId/${event.id}" + + /** The node's context-window fill on its own computer, when known. */ + val contextPercent: Double? get() = from.snapshot.status?.inbox?.nodes?.get(event.nodeId)?.contextPercent + } + + /** A session working right now that reports what it is doing, from the listing it came from. */ + data class Live(val from: ComputerListing, val nodeId: String, val now: InboxNodeNow) { + val hostId: String get() = from.computer.hostId + + /** A list key. Two computers can have the same node id (a canvas committed to two repos). */ + val key: String get() = "$hostId/$nodeId" + } + + val isEmpty: Boolean get() = actionable.isEmpty() && working.isEmpty() && archived.isEmpty() + + companion object { + /** + * The feed of [listings], in their order. Events are sorted newest first ACROSS computers; the + * sort is stable, so events with the same timestamp keep the order of [listings], then each + * mirror's own order. Working sessions are not sorted by time: their `updatedAt` moves with + * every tool call, so a time order would reshuffle the live cards on every refresh. They come + * in [listings] order, each computer's in its mirror's order, which is what a single + * computer's Inbox always showed. + */ + fun of(listings: List): InboxFeed { + val events = listings + .flatMap { l -> l.snapshot.status?.inbox?.events.orEmpty().map { Item(l, it) } } + .sortedByDescending { it.event.ts } + val working = listings.flatMap { l -> + val status = l.snapshot.status ?: return@flatMap emptyList() + status.nodes.mapNotNull { (nodeId, st) -> + val now = status.inbox?.nodes?.get(nodeId) + if (st.state == AgentState.WORKING && now?.activity != null) Live(l, nodeId, now) else null + } + } + return InboxFeed( + actionable = events.filter { it.event.actionable }, + working = working, + archived = events.filterNot { it.event.actionable } + ) + } + } +} + +/** + * The phone's "All computers" screen (audit A55): iOS's Agents and Usages tabs merge every paired + * connection (docs/mobile-usage-inbox.md "iOS behavior (v1)"), where the app had only one computer's + * Inbox and Usage, inside that computer's screen. The rules are here, pure; the screen only draws. + */ +object AllComputers { + /** + * What a computer needs from you: its open approvals and questions. One definition for the + * count on its row in the computers list, on its Inbox tab, and on the All computers screen. + */ + fun needsYou(snapshot: ProjectsSnapshot): Int = snapshot.status?.inbox?.events?.count { it.actionable } ?: 0 + + /** + * Whether the computers list offers the merged screen: once a second computer is paired. With one, + * that computer's own Inbox and Usage tabs already are all of it. + */ + fun offered(pairedCount: Int): Boolean = pairedCount > 1 + + /** + * What each computer is called on a merged screen, in [hosts] order. Its pairing name, or + * "Computer" for a blank one. Two computers paired under the same name (two machines both called + * "MacBook Pro") would be told apart by nothing, so a shared name gets the address the phone + * dials (`user@host`), and a label that is still shared (the same computer paired twice) a number + * in pairing order. Names compare case-insensitively, the way a reader does. + */ + fun labels(hosts: List): List { + val names = hosts.map { it.name.trim().ifEmpty { "Computer" } } + val withAddress = hosts.mapIndexed { i, h -> + if (names.count { it.equals(names[i], ignoreCase = true) } > 1) "${names[i]} (${h.user}@${h.host})" else names[i] + } + return hosts.mapIndexed { i, h -> + val same = withAddress.indices.filter { withAddress[it].equals(withAddress[i], ignoreCase = true) } + ComputerLabel(h.id, if (same.size > 1) "${withAddress[i]} #${same.indexOf(i) + 1}" else withAddress[i]) + } + } + + /** Every computer's Inbox, merged ([InboxFeed.of]): newest first, each card with its computer. */ + fun feed(listings: List): InboxFeed = InboxFeed.of(listings) + + /** One computer's usage on the merged Usage tab: its accounts, under its label and `updatedAt`. */ + data class UsageSection(val computer: ComputerLabel, val usage: MirrorUsage, val status: AgentStatusFile) + + /** + * One section per computer that reports usage (docs/mobile-usage-inbox.md: "one section per paired + * connection that reports `usage`"), in [listings] order. A computer reports nothing when its + * mirror has no `usage` block or no account in it: a computer not listed yet, an older desktop, + * or one whose usage service has nothing cached. It gets no empty section. + */ + fun usage(listings: List): List = listings.mapNotNull { l -> + val status = l.snapshot.status ?: return@mapNotNull null + val usage = status.usage?.takeIf { it.accounts.isNotEmpty() } ?: return@mapNotNull null + UsageSection(l.computer, usage, status) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AllComputersTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AllComputersTest.kt new file mode 100644 index 0000000000..ea9a4b098b --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AllComputersTest.kt @@ -0,0 +1,403 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.AllComputers +import dev.nodeterm.protocol.model.ComputerLabel +import dev.nodeterm.protocol.model.ComputerListing +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxFeed +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.InboxNodeNow +import dev.nodeterm.protocol.model.MirrorInbox +import dev.nodeterm.protocol.model.MirrorUsage +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.UsageAccount +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Audit A55: iOS merges the Inbox ("merged events across connections, newest first") and Usage ("one + * section per paired connection that reports `usage`") across every paired computer + * (docs/mobile-usage-inbox.md), where Android showed one computer's, inside that computer's screen, + * and the computers list showed no needs-you count at all. The rules are [AllComputers] and + * [InboxFeed]; the screens are only type-checked, so what they rely on is pinned in their source. + */ +class AllComputersTest { + private fun event( + id: String, + ts: Long, + nodeId: String = "n1", + kind: InboxKind = InboxKind.APPROVAL, + resolved: Boolean = false + ) = InboxEvent( + id = id, ts = ts, nodeId = nodeId, agentId = "claude", sessionId = "s", kind = kind, + title = "t-$id", detail = null, interrupted = false, resolved = resolved, + options = emptyList(), multiSelect = false, pendingId = null + ) + + private fun now(activity: String?, context: Double? = null, updatedAt: Long = 1) = + InboxNodeNow(activity = activity, tool = null, contextPercent = context, prompt = null, updatedAt = updatedAt) + + private fun status( + events: List = emptyList(), + states: Map = emptyMap(), + inboxNodes: Map = emptyMap(), + usage: MirrorUsage? = null + ) = AgentStatusFile( + updatedAt = 1, + nodes = states.mapValuesTo(LinkedHashMap()) { (_, st) -> AgentNodeStatus(st, "claude", "s", null, false, 1, null) }, + settings = null, + usage = usage, + inbox = MirrorInbox(events, inboxNodes), + serverVersion = null + ) + + private fun snapshot(status: AgentStatusFile?) = ProjectsSnapshot(emptyList(), emptySet(), status, 1) + + private fun listing(hostId: String, status: AgentStatusFile?, label: String = "Computer $hostId") = + ComputerListing(ComputerLabel(hostId, label), snapshot(status)) + + private fun host(id: String, name: String, user: String = "me", address: String = "10.0.0.$id") = PairedHost( + id = id, name = name, host = address, port = 22, user = user, sshAvailable = true, + hostKeyB64 = null, relay = null, sshHostKeyFingerprint = null, pairedAt = 0 + ) + + private fun usage(updatedAt: Long, vararg accountIds: String?) = MirrorUsage( + updatedAt, + accountIds.map { UsageAccount(it, null, null, "claude", "ok", updatedAt, emptyList()) } + ) + + // ---- the count on each computer's row -------------------------------------------------------- + + @Test + fun `a computer needs you for its open approvals and questions, not for finished or resolved cards`() { + val s = snapshot( + status( + listOf( + event("a", 1, kind = InboxKind.APPROVAL), + event("q", 2, kind = InboxKind.QUESTION), + event("d", 3, kind = InboxKind.DONE), + event("r", 4, kind = InboxKind.APPROVAL, resolved = true) + ) + ) + ) + assertEquals(2, AllComputers.needsYou(s)) + // A computer not listed yet (no mirror) needs nothing, rather than failing the row. + assertEquals(0, AllComputers.needsYou(ProjectsSnapshot.EMPTY)) + assertEquals(0, AllComputers.needsYou(snapshot(null))) + } + + @Test + fun `the merged screen is offered once a second computer is paired`() { + assertFalse(AllComputers.offered(0)) + assertFalse(AllComputers.offered(1)) + assertTrue(AllComputers.offered(2)) + assertTrue(AllComputers.offered(5)) + } + + // ---- what each computer is called ------------------------------------------------------------ + + @Test + fun `computers are called by their pairing names, in pairing order`() { + val labels = AllComputers.labels(listOf(host("1", "Studio"), host("2", " Laptop "), host("3", ""))) + assertEquals( + listOf(ComputerLabel("1", "Studio"), ComputerLabel("2", "Laptop"), ComputerLabel("3", "Computer")), + labels + ) + } + + @Test + fun `two computers with one name are told apart by their address, and the same address by a number`() { + val labels = AllComputers.labels( + listOf( + host("1", "MacBook Pro", user = "ann", address = "10.0.0.5"), + host("2", "macbook pro", user = "bob", address = "10.0.0.9"), + host("3", "Studio"), + // The same computer paired twice: name and address agree. + host("4", "Mini", user = "me", address = "mini.local"), + host("5", "Mini", user = "me", address = "mini.local") + ) + ).map { it.label } + assertEquals( + listOf( + "MacBook Pro (ann@10.0.0.5)", + "macbook pro (bob@10.0.0.9)", + "Studio", + "Mini (me@mini.local) #1", + "Mini (me@mini.local) #2" + ), + labels + ) + assertEquals(labels.size, labels.map { it.lowercase() }.toSet().size, "every label is distinct") + } + + // ---- the merged Inbox ------------------------------------------------------------------------- + + @Test + fun `events of every computer are merged newest first, each card keeping its computer`() { + val a = listing("a", status(listOf(event("a1", 100), event("a2", 300), event("a3", 500)))) + val b = listing("b", status(listOf(event("b1", 200), event("b2", 400)))) + val feed = AllComputers.feed(listOf(a, b)) + // Interleaved by time, not one computer's cards then the other's. + assertEquals(listOf("a3", "b2", "a2", "b1", "a1"), feed.actionable.map { it.event.id }) + assertEquals(listOf("a", "b", "a", "b", "a"), feed.actionable.map { it.hostId }) + // Each card carries its own computer's listing (its titles, context ring and answers). + for (item in feed.actionable) { + assertEquals(if (item.hostId == "a") a else b, item.from) + assertEquals("Computer ${item.hostId}", item.from.computer.label) + } + } + + @Test + fun `open cards come first and the archive under them, each newest first across computers`() { + val a = listing( + "a", + status(listOf(event("a-done", 900, kind = InboxKind.DONE), event("a-q", 100, kind = InboxKind.QUESTION))) + ) + val b = listing( + "b", + status(listOf(event("b-old", 50, resolved = true), event("b-new", 700, kind = InboxKind.APPROVAL))) + ) + val feed = AllComputers.feed(listOf(a, b)) + assertEquals(listOf("b-new", "a-q"), feed.actionable.map { it.event.id }) + assertEquals(listOf("a-done", "b-old"), feed.archived.map { it.event.id }) + // The count on the merged Inbox tab is every row's count, added up. + assertEquals(feed.actionable.size, listOf(a, b).sumOf { AllComputers.needsYou(it.snapshot) }) + } + + @Test + fun `the same event id on two computers is two cards, with two keys`() { + val a = listing("a", status(listOf(event("e1", 10)))) + val b = listing("b", status(listOf(event("e1", 20)))) + val feed = AllComputers.feed(listOf(a, b)) + assertEquals(2, feed.actionable.size) + assertEquals(listOf("b/e1", "a/e1"), feed.actionable.map { it.key }) + } + + @Test + fun `cards at the same moment keep the computers' order, then each mirror's own`() { + val a = listing("a", status(listOf(event("a1", 10), event("a2", 10)))) + val b = listing("b", status(listOf(event("b1", 10)))) + assertEquals(listOf("a1", "a2", "b1"), AllComputers.feed(listOf(a, b)).actionable.map { it.event.id }) + assertEquals(listOf("b1", "a1", "a2"), AllComputers.feed(listOf(b, a)).actionable.map { it.event.id }) + } + + @Test + fun `a card's context ring is read from its own computer, even when another has the same node`() { + // A canvas committed to a repo cloned on two computers has the same node ids on both. + val a = listing("a", status(listOf(event("a1", 10, nodeId = "n1")), inboxNodes = mapOf("n1" to now(null, context = 12.0)))) + val b = listing("b", status(listOf(event("b1", 20, nodeId = "n1")), inboxNodes = mapOf("n1" to now(null, context = 87.0)))) + val byId = AllComputers.feed(listOf(a, b)).actionable.associateBy { it.event.id } + assertEquals(12.0, byId.getValue("a1").contextPercent) + assertEquals(87.0, byId.getValue("b1").contextPercent) + } + + @Test + fun `working sessions of every computer are listed in the computers' order, not reshuffled by time`() { + val a = listing( + "a", + status( + states = linkedMapOf("w1" to AgentState.WORKING, "idle" to AgentState.DONE, "quiet" to AgentState.WORKING), + inboxNodes = mapOf( + "w1" to now("Editing a.kt", updatedAt = 5), + "idle" to now("Reading b.kt", updatedAt = 9), + // Working, but reporting nothing it is doing: no live card (as on a computer's own Inbox). + "quiet" to now(null, updatedAt = 9) + ) + ) + ) + val b = listing( + "b", + status( + states = linkedMapOf("w2" to AgentState.WORKING, "w1" to AgentState.WORKING), + inboxNodes = mapOf("w2" to now("Running npm test", updatedAt = 99), "w1" to now("Using Grep", updatedAt = 1)) + ) + ) + val feed = AllComputers.feed(listOf(a, b)) + assertEquals(listOf("a/w1", "b/w2", "b/w1"), feed.working.map { it.key }) + assertEquals(listOf("Editing a.kt", "Running npm test", "Using Grep"), feed.working.map { it.now.activity }) + } + + @Test + fun `a computer not listed yet adds nothing, and the feed of none is empty`() { + val a = listing("a", status(listOf(event("a1", 10)))) + val unlisted = ComputerListing(ComputerLabel("b", "B"), ProjectsSnapshot.EMPTY) + val feed = AllComputers.feed(listOf(a, unlisted)) + assertEquals(listOf("a/a1"), feed.actionable.map { it.key }) + assertTrue(AllComputers.feed(emptyList()).isEmpty) + assertTrue(AllComputers.feed(listOf(unlisted)).isEmpty) + assertFalse(feed.isEmpty) + } + + @Test + fun `one computer's Inbox is the same rule as the merged one`() { + // The computer's own tab draws InboxFeed.of(that computer): sorted newest first like before. + val a = listing( + "a", + status(listOf(event("old", 1), event("done", 3, kind = InboxKind.DONE), event("new", 2, kind = InboxKind.QUESTION))) + ) + val feed = InboxFeed.of(listOf(a)) + assertEquals(listOf("new", "old"), feed.actionable.map { it.event.id }) + assertEquals(listOf("done"), feed.archived.map { it.event.id }) + assertEquals(AllComputers.feed(listOf(a)), feed) + } + + // ---- the merged Usage ------------------------------------------------------------------------ + + @Test + fun `one usage section per computer that reports usage, in the computers' order`() { + val a = listing("a", status(usage = usage(1_000, null, "acct-1")), label = "Studio") + val none = listing("b", status(usage = null), label = "Old desktop") + val empty = listing("c", status(usage = usage(2_000)), label = "Nothing cached") + val unlisted = ComputerListing(ComputerLabel("d", "Not listed"), ProjectsSnapshot.EMPTY) + val e = listing("e", status(usage = usage(3_000, null)), label = "Laptop") + val sections = AllComputers.usage(listOf(a, none, empty, unlisted, e)) + assertEquals(listOf("Studio", "Laptop"), sections.map { it.computer.label }) + assertEquals(listOf(1_000L, 3_000L), sections.map { it.usage.updatedAt }) + assertEquals(listOf(listOf(null, "acct-1"), listOf(null)), sections.map { s -> s.usage.accounts.map { it.accountId } }) + // Each section names its accounts from its own computer's mirror. + assertEquals(a.snapshot.status, sections[0].status) + assertEquals(e.snapshot.status, sections[1].status) + } + + // ---- what the type-checked screens rely on ----------------------------------------------------- + + @Test + fun `each computer's row counts from the session's cached listing and dials nothing`() { + val src = AppSourcePins.ui("HostsScreen.kt") + val row = AppSourcePins.blockAfter(src, "}) { host ->") + AppSourcePins.assertInOrder( + row, + "val snapshot by session.snapshot.collectAsState()", + "val needsYou = AllComputers.needsYou(snapshot)", + "NeedsYouBadge(needsYou)" + ) + // Listing the computers must not connect to them: no connect, refresh or watch from this screen. + for (dial in listOf("ensureConnected", ".refresh(", "refreshNow", "startWatching", "connectionFor", "viaRelay")) { + assertFalse(src.contains(dial), "HostsScreen dials a computer ($dial)") + } + // The entry to the merged screen, offered by the rule, with the total from the same cached listings. + AppSourcePins.assertInOrder( + src, + "if (AllComputers.offered(hosts.size))", + "rememberListings(hosts).sumOf { AllComputers.needsYou(it.snapshot) }", + "nav.push(Route.AllComputers)" + ) + // The Inbox tab's count is the same definition. + assertTrue(AppSourcePins.ui("HostScreen.kt").contains("val needsYou = AllComputers.needsYou(snapshot)")) + // And the listings the screens read are the sessions' cached snapshots. + val listings = AppSourcePins.blockAfter(AppSourcePins.ui("AllComputersScreen.kt"), "internal fun rememberListings(") + assertTrue(listings.contains(".snapshot"), listings) + for (dial in listOf("ensureConnected", "refresh", "startWatching")) assertFalse(listings.contains(dial), listings) + } + + @Test + fun `opening the merged screen re-lists every computer through the gate, without lifting a held refusal`() { + val screen = AppSourcePins.ui("AllComputersScreen.kt") + // Every computer, through HostSession.refresh: refreshNow, its normal connect path, whose relay + // dial asks RelayApprovalGate. As the app's foreground refresh (AUTO): a first approval may show + // its code, but a computer whose approval was refused or unanswered keeps its hold. + assertTrue(screen.contains("fun refreshAll() = hostIds.forEach { graph.connections.session(it).refresh(Trigger.AUTO) }")) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(screen, "LifecycleStartEffect(hostIds) {\n uiScope.launch"), + "refreshAll()" + ) + val connections = AppSourcePins.app("conn/ConnectionManager.kt") + assertTrue(connections.contains("fun refresh(trigger: Trigger = Trigger.USER) {\n scope.launch { refreshNow(trigger) }")) + // A computer's own Try again is the user asking about it: USER lifts its hold. + val status = AppSourcePins.blockAfter(screen, "private fun ComputerStatus(") + AppSourcePins.assertInOrder( + status, + "is ConnState.AwaitingApproval -> ApprovalFor(computer, s.sas)", + "is ConnState.Failed -> Problem(\"${'$'}{computer.label}: ${'$'}{s.message}\") { session.refresh(Trigger.USER) }", + "relayApproval?.let" + ) + assertFalse(screen.contains("refresh()"), "a refresh on this screen with no trigger says nothing about who asked") + // Not polled: the merged screen does not watch the computers (the live 8 s refresh stays theirs). + assertFalse(screen.contains("startWatching"), "the merged screen polls every computer") + } + + @Test + fun `the merged Inbox shows every computer's Inbox while started, and draws through the shared list`() { + val screen = AppSourcePins.ui("AllComputersScreen.kt") + val inbox = AppSourcePins.blockAfter(screen, "private fun AllInbox(") + AppSourcePins.assertInOrder( + inbox, + "LifecycleStartEffect(hostIds)", + "hostIds.map { graph.connections.session(it).onScreen.showInbox() }", + "onStopOrDispose { showing.forEach { it.close() } }", + "InboxFeedList(nav, feed, showComputer = true" + ) + assertTrue(screen.contains("val feed = AllComputers.feed(listings)")) + assertTrue(screen.contains("AllUsage(AllComputers.usage(listings))")) + // The card composables are InboxTab's, not copies (no second EventCard, UsageCard or quick answer). + for (copy in listOf("fun EventCard(", "fun UsageCard(", "QuickActions.answer")) { + assertFalse(screen.contains(copy), "AllComputersScreen copies $copy") + } + assertTrue(AppSourcePins.blockAfter(screen, "private fun AllUsage(").contains("UsageCard(account, section.status)")) + val tab = AppSourcePins.ui("InboxTab.kt") + assertTrue(tab.contains("InboxFeedList(nav, feed, showComputer = false")) + assertTrue(tab.contains("val feed = InboxFeed.of(listOf(ComputerListing(ComputerLabel(hostId, name), snapshot)))")) + } + + @Test + fun `a merged card opens and answers on its own computer`() { + val list = AppSourcePins.blockAfter(AppSourcePins.ui("InboxTab.kt"), "internal fun InboxFeedList(") + // Open: the card's computer and the title from that computer's listing. + AppSourcePins.assertInOrder( + list, + "fun openOn(from: ComputerListing, nodeId: String) =", + "nav.push(Route.Terminal(from.computer.hostId, nodeId, feedTitle(from.snapshot, nodeId)))", + "fun runOn(" + ) + // Answer: that computer's session, its connection, and its relay leg for a node of its SSH projects. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(list, "fun runOn("), + "val session = graph.connections.session(from.computer.hostId)", + "block(session.ensureConnected())", + "block(session.viaRelay())", + "session.refreshNow()" + ) + // Every card binds its actions to ITS listing: the list takes no screen-wide computer or session. + assertTrue( + AppSourcePins.ui("InboxTab.kt").contains( + "internal fun InboxFeedList(nav: Navigator, feed: InboxFeed, showComputer: Boolean, emptyText: String)" + ) + ) + AppSourcePins.assertInOrder( + list, + "items(feed.actionable", + "onOpen = { openOn(item.from, ev.nodeId) }", + "open = { nodeId -> openOn(item.from, nodeId) }", + "run = { label, block, nodeId -> runOn(item.from, label, block, nodeId) }", + "items(feed.working", + "openOn(live.from, nodeId)", + "items(feed.archived", + "onOpen = { openOn(item.from, ev.nodeId) }" + ) + } + + @Test + fun `the route is saved in the back stack under a name of its own`() { + val main = AppSourcePins.app("MainActivity.kt") + assertTrue(main.contains("data object AllComputers : Route")) + assertTrue(main.contains("Route.AllComputers -> listOf(\"all\")")) + assertTrue(main.contains("\"all\" -> Route.AllComputers")) + assertTrue(main.contains("Route.AllComputers -> AllComputersScreen(nav)")) + // Every earlier route keeps its saved form, so a stack saved before this one decodes unchanged + // (and BackStack drops, with its key, an entry a build does not know: BackStackTest). + for (line in listOf( + "Route.Hosts -> listOf(\"hosts\")", + "is Route.PairHost -> listOfNotNull(\"pair\", r.code)", + "Route.Settings -> listOf(\"settings\")", + "is Route.Host -> listOf(\"host\", r.hostId, r.tab.toString())", + "is Route.Terminal -> listOf(\"terminal\", r.hostId, r.nodeId, r.title)", + "is Route.SourceControl -> listOf(\"git\", r.hostId, r.projectId)" + )) assertTrue(main.contains(line), "the saved form of a route changed: $line") + } +} diff --git a/docs/android.md b/docs/android.md index 9da97db1cb..1ccd79af51 100644 --- a/docs/android.md +++ b/docs/android.md @@ -379,7 +379,9 @@ prompt only once the hold ends; a terminal showing an overlay instead of its pan offer, approval code, lost view) hides nothing; and one still connecting leaves its session's events for a later listing, which the attach settles at once from the latest listing. Other computers are not polled while one is open, so theirs still come only from the background check, and the Settings -text, the README and the notifier's comment now say exactly that. A computer the user just left is +text, the README and the notifier's comment now say exactly that. The All computers screen (`A55`) +is not polled either: it re-lists every computer once when it opens (and on Refresh), and while its +Inbox tab is on screen every computer's Inbox counts as on screen. A computer the user just left is one of them: its connection can stay open until it drops or the background check closes it, and a change it pushes meanwhile is no longer re-listed (it used to be, and so announced live; the review of A73). What that push carried is not recorded as seen, so the background check announces it. The @@ -418,6 +420,35 @@ sends), the outcomes and the hand-off's encoding are unit-tested; the PendingInt work, its dial trigger and the tap's route are pinned in the source, and whether the actions show and answer on a phone is a device check. +`AllComputersTest` covers the All computers screen and the needs-you counts (`A55`). iOS merges its +Agents feed across every paired connection and shows one Usages section per connection that reports +usage (docs/mobile-usage-inbox.md); Android had one computer's Inbox and Usage, inside that +computer's screen, and the computers list showed no count. Now each computer's row shows how many of +its approvals and questions are open, read from the listing its session already holds (the open +screen's, the background check's, or the last one this process made), so the list dials nothing; +before a computer's first listing in a process its row shows no count. With two or more computers +paired, the list starts with "All computers": every computer's Inbox and Usage on one screen. The +rules are the pure `AllComputers` and `InboxFeed`: events of every computer newest first (a stable +sort, so the same moment keeps the computers' order), open cards above the archive, the working +sessions in the computers' order rather than by time (their time moves with every tool call), every +card keyed and labelled by its computer (a name two computers share gets `user@host`, and the same +computer paired twice a number), and one Usage section per computer whose mirror has accounts in its +`usage` block. A computer's own Inbox tab is the same `InboxFeed` of that one computer, so the two +cannot sort or count differently. Opening the screen (and coming back to it, or Refresh) re-lists +every paired computer once through its normal connect path, so a relay leg goes through +`RelayApprovalGate`; it is not polled after that. That re-list is the app's own foreground refresh +(`Trigger.AUTO`), not a tap on each computer: a computer that has never approved this phone shows its +approval code on this screen, labelled with its computer, but one whose approval was refused or went +unanswered keeps its hold, and says so with a Try again of its own (`Trigger.USER`). Lifting every +hold on every visit would raise the desktop's dialog again, each time, on a computer the user did not +ask about and may not be at (`A05`/`A23`). Open, Approve and Answer on a card go to the card's own +computer: its session and connection, and its relay leg for a node of that computer's SSH projects. +The cards are the Inbox tab's composables (`InboxFeedList`, `UsageCard`), not copies. The rules are +unit-tested; the screen, the row count and the route (saved in the back stack as `["all"]`, a name +of its own, so every earlier entry restores as before and a build that does not know it drops just +that entry) are type-checked and pinned in the source, and how the screen looks and answers on a +phone is a device check. + `PhoneIdentityTest` and `BackupRulesTest` cover what leaves the phone (`A51`). `allowBackup="false"` stops cloud backup, but an app that targets Android 12 or later is still copied by a device-to-device transfer unless its data extraction rules exclude it. The transfer carried @@ -715,6 +746,22 @@ later fix left to a device. the relay, a new approval's notification offers Open rather than Approve, and nothing appears on the desktop. Tapping Approve twice quickly sends one answer. *(A25, A05, A06)* +### All computers + +60. Pair two computers. Each row of the computers list shows "N need you" once that computer has + been listed (opened, or checked in the background), and nothing before. "All computers" at the + top of the list opens one screen: its Inbox lists both computers' cards newest first, each + naming its computer; Approve, Answer and Open on a card act on that card's computer (try one on + each, through the relay and on the same network), and its Usage tab shows one section per + computer that reports usage, with the computer's name and when it was measured. Away from the + network of a computer that has never approved this phone through the relay, opening the screen + shows that computer's approval code under its name; deny it there, and opening the screen again + shows the refusal with a Try again and puts no dialog on that desktop until Try again is tapped. + With the screen on its Inbox tab, raise a permission prompt on one desktop and tap Refresh: its + card appears on top, and no notification follows, then or later. Open a terminal from a card and + come back, and kill the process in the background: the screen and its tab come back. *(A55, A05, + A22, A43, A73)* + ## Known gaps - **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's From 57804cfd79769f7f853432ed433792a64ee8dbd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:57:22 +0000 Subject: [PATCH 097/284] feat(android): dictate into the terminal's input bar (A59) The phone had no dictation: iOS and the desktop transcribe with on-device Whisper, and Android had only the keyboard's own voice typing. The input bar now has a mic beside Send that runs Android's SpeechRecognizer and writes what it hears into the draft. It never sends: the draft reaches the pane only on Send, the desktop's rule ("nothing auto-submits"). - protocol: Dictation, the pure state machine (idle, listening, partial, finishing, error; the final result is an event back to idle) and the text rules. Heard words are appended to the draft as it was when the dictation started (after one space, none after a trailing space or newline), each partial replaces the previous one, a blank final keeps the shown partial, and any other draft change (typing, Send's clear) ends the dictation so late results cannot bring back sent or deleted text. A second tap while finishing cancels, so a recognizer that never answers cannot keep the button stuck. One Failure per SpeechRecognizer.ERROR_* code (1-15) with a short message, UNKNOWN for any other. - app: DictationController drives a fresh recognizer per dictation, drops callbacks of a recognizer that is no longer current, and has no TerminalController (it cannot send). TerminalScreen asks for RECORD_AUDIO on the first tap, hides the mic when isRecognitionAvailable is false, cancels on screen stop and releases on dispose, and shows the failure with an OK. No EXTRA_LANGUAGE: the recognizer uses the phone's own language (the desktop also pins none by default); a picker would need the recognizer's own supported list. The mic icon's path is inlined (material-icons-core has none). - manifest: RECORD_AUDIO, microphone feature not required, and a entry for android.speech.RecognitionService, without which isRecognitionAvailable answers false on Android 11+. - docs: android.md describes it, adds device checklist item 61, and a Known gaps entry (not Whisper; /v1/transcribe does not exist on the backend yet; the keyboard's own voice typing also works). README gets a feature row and a Security note that the recognizer may send audio to its service. Verified: DictationTest (16 tests: the machine, the error codes, the codes read via javap from the android-all jar the type-check pins when it is in the Gradle cache, skipped otherwise, e.g. on CI; the app wiring and manifest pinned in source). Mutation-checked: Edited as a no-op, accumulating partials, no blank-final fallback, a wrong error code, join always adding a space, a final result that submits, Send not ending the dictation, EXTRA_LANGUAGE set, no , no cancel on stop, and unfiltered stale callbacks each turn a test red. Full protocol suite (466 tests) and the offline app type-check pass. protocol's test task now declares the app manifest and the type-check's build as inputs. Departures from the audit's proposal: no on-device whisper.cpp and no /v1/transcribe request (it does not exist yet, per the verifier); the gap is documented instead. Nothing here has run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BWEvmRW4fVGVfgFPdBvATa --- android/README.md | 10 +- android/app/src/main/AndroidManifest.xml | 13 + .../android/ui/DictationController.kt | 178 +++++++++ .../dev/nodeterm/android/ui/TerminalScreen.kt | 67 +++- android/protocol/build.gradle.kts | 4 + .../dev/nodeterm/protocol/model/Dictation.kt | 195 ++++++++++ .../dev/nodeterm/protocol/DictationTest.kt | 343 ++++++++++++++++++ docs/android.md | 54 +++ 8 files changed, 858 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/nodeterm/android/ui/DictationController.kt create mode 100644 android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Dictation.kt create mode 100644 android/protocol/src/test/kotlin/dev/nodeterm/protocol/DictationTest.kt diff --git a/android/README.md b/android/README.md index 5b3231e82e..4d18b73b09 100644 --- a/android/README.md +++ b/android/README.md @@ -26,6 +26,7 @@ written for it and tested where the layer allows, and the numbered | Late relay adoption (paired while remote access was off) | ✓ | Reads `~/.nodeterm/relay.json` over SSH, mints its own device token | | Sessions, grouped like the desktop sidebar | ✓ | Needs you / Running / Sleeping, activity + context % | | Terminal (co-attach to the live tmux session) | ✓ | xterm.js renderer, native input bar, special keys, swipe = tmux scroll. Tap a link (also one wrapped over several rows, or an OSC 8 link): the phone names its host and opens it only when you confirm, and only an http(s) one. The Copy chip opens a sheet of the screen's lines and links to select and copy or share. A copy the pane sends itself (OSC 52, e.g. vim's `"+y`) reaches the clipboard too, but tmux's copy-mode is out of easy reach on a touch screen | +| Dictation into the input bar | ✓ | A mic beside Send: Android's speech recognizer, in the phone's language, writes into the draft and never sends it. The microphone permission is asked on the first tap; no mic shows on a phone without a recognizer. Not the on-device Whisper of iOS and the desktop (see [Known gaps](../docs/android.md#known-gaps)); the keyboard's own voice typing works too | | Sleeping (Eco) session opened | ✓ | Through the relay the computer wakes it on open. Over SSH the phone offers the desktop's wake line (`--resume `, + permission mode for Claude only), while a shell owns the pane, typed only on a tap | | Cold-start resume offer (the computer rebooted) | relay | Offers the agent's own `--resume `; never types it unasked. Over SSH a session that is not running is never created (it would lack its hook environment): the phone offers to open it through the relay | | Sessions of the computer's SSH projects | relay | They run on another host; the computer attaches them over its SSH connection. Over direct SSH the phone offers the relay instead | @@ -103,8 +104,8 @@ are also debuggable, which hands the phone's pairing credentials to anyone with SSH tests need tmux and `script(1)` (util-linux on Linux, BSD on macOS) and skip without them. - **`app/`** — the Compose UI on top: pairing, the computers list (each with its needs-you count), a computer's Sessions / Board / Inbox / Usage tabs, the All computers screen (every computer's - Inbox and Usage), the terminal screen, a project's source control, settings, background - notifications. + Inbox and Usage), the terminal screen (with dictation into its input bar), a project's source + control, settings, background notifications. ## Notifications @@ -182,6 +183,11 @@ iOS app does receive the detail, in the push the desktop sends. relay with the old box key. The revoke unpinned that key, so the computer shows its approval dialog with a code before it lets it in: approve a phone there only while your own phone is showing the same code. +- Dictation (the terminal input bar's mic) goes through the phone's speech recognition service, + which on most phones is Google's and may send what you say to its servers; it is not the on-device + Whisper of the desktop and iOS. The microphone is used only after a tap on the mic, until the + sentence ends or the terminal screen leaves, and the app keeps no audio. Type anything you would + rather not send to that service. - Nothing of the app's goes into a backup or a phone-to-phone transfer. `allowBackup="false"` stops cloud backup, and the manifest's data extraction rules stop the Android 12+ device-to-device transfer, which ignores `allowBackup`. A new phone starts unpaired; pair it again. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 10ec350050..1cc087aa26 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,19 @@ + + + + + + + + + +