feat(android): pure-Kotlin foundation — AW0 scaffold + AW1 modules (218 tests green)
Implements the verifiable pure-Kotlin core of the Android client per
docs/ANDROID_CLIENT_PLAN.md, mirroring the iOS SPM package set as Gradle modules.
Built + reviewed via multi-agent workflow (explore→implement→verify→review),
then review findings fixed with regression tests.
Modules (all pure JVM, kotlin("jvm"); Android-framework modules scaffolded but
gated off in settings — no Android SDK in this env):
- :wire-protocol — frozen wire contract (sealed Client/ServerMessage, enums,
HostEndpoint CSWSH origin derivation, transport interfaces), hand-rolled codec
byte-identical to the server's JSON.stringify + tolerant kotlinx decode.
- :session-core — ReconnectMachine (1→2→4→8→16→30 backoff), PingScheduler,
GateTracker (two-line epoch guard), AwayDigest, UnreadLedger, TitleSanitizer,
KeyByteMap (byte-matches public/keybar.ts).
- :api-client — all REST routes, tolerant decode, Origin-iff-guarded, prefs
unknown-key preservation, pairing probe + host-tier classifier.
- :client-tls — pure half: PKCS#12 parse, X509KeyManager alias logic,
CertificateSummary, provider-agnostic wrong-passphrase classification.
- :test-support — FakeTransport / FakeHttpTransport / virtual-clock fakes.
Verify: ./gradlew clean test → 218 tests, 0 failures (wire-protocol 47,
session-core 60, api-client 69, client-tls 27, test-support 15).
Review (3 lenses, 8/10 each) findings fixed: sessionId JSON-injection escape,
CancellationException propagation in PingScheduler, PairingProbe close-on-cancel
leak, HostEndpoint trim, HostClassifier hoisted to :wire-protocol (type unified),
BouncyCastle-portable wrong-passphrase detection, lone-surrogate escaping.
Known gap (documented, deferred): /push/fcm-token has no server route yet —
plan task A33 (src/push/fcm.ts) delivers it.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
/**
|
||||
* Client -> server WS frames (FROZEN contract, plan §3.1). The Android analogue of
|
||||
* `src/types.ts:87-92` `ClientMessage` and iOS `WireProtocol.ClientMessage`.
|
||||
*
|
||||
* Immutable value types. Encoding to the wire is A4 (`MessageCodec`) — this file only
|
||||
* freezes the SHAPES so every other module reads stable names.
|
||||
*
|
||||
* Server-side validation the caller must respect (the server SILENTLY DISCARDS invalid
|
||||
* frames — `src/protocol.ts:45-86` — no error reply comes back):
|
||||
* - `attach` MUST be the FIRST frame on a connection (`src/server.ts:766-773`).
|
||||
* - `resize` cols/rows must be integers in [WireConstants.RESIZE_RANGE] (1..1000).
|
||||
* - `attach.cwd`, when present, must be an absolute path (leading '/').
|
||||
* - `input.data` is raw keyboard bytes, passed through VERBATIM — never filtered
|
||||
* (ARCHITECTURE invariant #9).
|
||||
*
|
||||
* DEVIATION NOTE (A2): the task brief listed `Attach(sessionId, cols, rows, cwd)`, but the
|
||||
* server `attach` frame (`src/protocol.ts` validateAttach, `src/types.ts:88`) and the iOS
|
||||
* `ClientMessage.attach(sessionId:cwd:)` carry NO cols/rows — the server hardcodes
|
||||
* DEFAULT_COLS/ROWS on attach (`src/server.ts:774-781`) and takes real dimensions from a
|
||||
* separate `resize` frame sent right after attach. Adding cols/rows here would make the
|
||||
* encoded attach frame differ from the web client and break A4's byte-equality vectors, so
|
||||
* the faithful wire contract (sessionId + optional cwd) is frozen instead.
|
||||
*/
|
||||
public sealed interface ClientMessage {
|
||||
/**
|
||||
* First frame. `sessionId == null` spawns a NEW session (A4 encodes this as an explicit
|
||||
* JSON `"sessionId":null` — the key is REQUIRED by the server, `src/protocol.ts:132-134`).
|
||||
* `cwd` = "new tab here" spawn directory (M6); appended only when [sessionId] is null.
|
||||
*/
|
||||
public data class Attach(val sessionId: String?, val cwd: String? = null) : ClientMessage
|
||||
|
||||
/** Raw keyboard bytes, verbatim (invariant #9 — no content filtering). */
|
||||
public data class Input(val data: String) : ClientMessage
|
||||
|
||||
/** Own message type so the server can `ioctl(TIOCSWINSZ)` -> SIGWINCH for TUIs. */
|
||||
public data class Resize(val cols: Int, val rows: Int) : ClientMessage
|
||||
|
||||
/**
|
||||
* Resolve a held permission gate with allow. [mode] is only meaningful for a `plan` gate
|
||||
* and is encoded as a TOP-LEVEL `mode` key — the server's WS wiring re-parses the raw
|
||||
* frame for it (`src/server.ts:91-102`). No [mode] = a plain approve.
|
||||
*/
|
||||
public data class Approve(val mode: ApproveMode? = null) : ClientMessage
|
||||
|
||||
/** Resolve a held permission gate with deny. */
|
||||
public data object Reject : ClientMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission mode written back when resolving a `plan` gate. Wire values mirror the server
|
||||
* whitelist `PermissionMode` (`src/types.ts:371`). The Kotlin analogue of Swift's
|
||||
* `enum ApproveMode: String` RawRepresentable — [wire] is the JSON string, [fromWire] the
|
||||
* reverse lookup.
|
||||
*
|
||||
* Note: the plan-gate three-way UI only ever sends [ACCEPT_EDITS] / [DEFAULT]; raw [AUTO] is
|
||||
* gated server-side by ALLOW_AUTO_MODE (default false) and downgraded to `default` otherwise.
|
||||
*/
|
||||
public enum class ApproveMode(public val wire: String) {
|
||||
DEFAULT("default"),
|
||||
ACCEPT_EDITS("acceptEdits"),
|
||||
PLAN("plan"),
|
||||
AUTO("auto");
|
||||
|
||||
public companion object {
|
||||
public fun fromWire(value: String): ApproveMode? = entries.firstOrNull { it.wire == value }
|
||||
}
|
||||
}
|
||||
|
||||
/** The `type` discriminator whitelist for client frames (`src/protocol.ts:27` ALLOWED_TYPES). */
|
||||
public enum class ClientMessageType(public val wire: String) {
|
||||
ATTACH("attach"),
|
||||
INPUT("input"),
|
||||
RESIZE("resize"),
|
||||
APPROVE("approve"),
|
||||
REJECT("reject");
|
||||
|
||||
public companion object {
|
||||
public fun fromWire(value: String): ClientMessageType? = entries.firstOrNull { it.wire == value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* The four network tiers of the pairing/warning table (plan §5.4). The SINGLE canonical type —
|
||||
* hoisted into :wire-protocol so :api-client and :client-tls share ONE classifier (the two module
|
||||
* copies had drifted). Faithful port of iOS `APIClient.HostNetworkTier`; the UI layers
|
||||
* (PairingViewModel scheme+address 分层提示) consume ONE classification instead of re-deriving it.
|
||||
*
|
||||
* | tier | matches | §5.4 hint |
|
||||
* |---|---|---|
|
||||
* | [LOOPBACK] | localhost · 127.0.0.0/8 · ::1 | no warning |
|
||||
* | [PRIVATE_LAN] | RFC1918 (10/8, 172.16/12, 192.168/16) · 169.254/16 · mDNS .local | non-blocking ws:// cleartext notice |
|
||||
* | [TAILSCALE] | CGNAT 100.64.0.0/10 · MagicDNS *.ts.net | no cleartext warning (WireGuard-encrypted) |
|
||||
* | [PUBLIC] | everything else (incl. out-of-range / malformed — fail-safe) | strongest blocking warning |
|
||||
*/
|
||||
public enum class HostNetworkTier {
|
||||
LOOPBACK,
|
||||
PRIVATE_LAN,
|
||||
TAILSCALE,
|
||||
PUBLIC,
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure, stateless classifier over a URL host string (plan §5.4 tiers). Faithful port of iOS
|
||||
* `HostClassifier`.
|
||||
*
|
||||
* IPv6 note: only loopback `::1` is tiered; other IPv6 literals (incl. ULA `fd00::/8`) fall to
|
||||
* [HostNetworkTier.PUBLIC] — fail-safe over-warning, matching v1 scope (the app dials IPv4 LAN /
|
||||
* CGNAT / MagicDNS targets). The finer IPv6 handling lives in the UI layer if ever needed.
|
||||
*/
|
||||
public object HostClassifier {
|
||||
private const val MAGIC_DNS_SUFFIX = ".ts.net"
|
||||
private const val MDNS_SUFFIX = ".local"
|
||||
private val LOOPBACK_NAMES: Set<String> = setOf("localhost", "::1", "[::1]")
|
||||
private const val IPV4_OCTET_COUNT = 4
|
||||
private val IPV4_OCTET_RANGE = 0..255
|
||||
|
||||
/**
|
||||
* Classify a bare host string (as returned by URL host parsing — no scheme, no port).
|
||||
* Unknown/malformed input is [HostNetworkTier.PUBLIC] by design (fail-safe).
|
||||
*/
|
||||
public fun classify(host: String): HostNetworkTier {
|
||||
val lowered = host.lowercase()
|
||||
if (lowered in LOOPBACK_NAMES) return HostNetworkTier.LOOPBACK
|
||||
if (lowered.endsWith(MAGIC_DNS_SUFFIX)) return HostNetworkTier.TAILSCALE
|
||||
if (lowered.endsWith(MDNS_SUFFIX)) return HostNetworkTier.PRIVATE_LAN
|
||||
val octets = ipv4Octets(lowered) ?: return HostNetworkTier.PUBLIC
|
||||
return classifyIpv4(octets)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for callers holding a [HostEndpoint] (the VM's shape). A `baseUrl` that no longer
|
||||
* yields a host classifies as [HostNetworkTier.PUBLIC] (fail-safe).
|
||||
*/
|
||||
public fun classify(endpoint: HostEndpoint): HostNetworkTier = classify(hostOf(endpoint))
|
||||
|
||||
/**
|
||||
* Extract the bare host from a (validated) [HostEndpoint]. Shared by [classify] and pairing-error
|
||||
* diagnosis so host derivation happens in ONE place. Trims defensively (never trust at a
|
||||
* boundary) and returns "" on any parse failure (→ PUBLIC, fail-safe).
|
||||
*/
|
||||
public fun hostOf(endpoint: HostEndpoint): String =
|
||||
runCatching { URI(endpoint.baseUrl.trim()).host }.getOrNull() ?: ""
|
||||
|
||||
private fun classifyIpv4(octets: List<Int>): HostNetworkTier {
|
||||
val first = octets[0]
|
||||
val second = octets[1]
|
||||
return when {
|
||||
first == 127 -> HostNetworkTier.LOOPBACK // loopback 127/8
|
||||
first == 10 -> HostNetworkTier.PRIVATE_LAN // RFC1918 10/8
|
||||
first == 192 && second == 168 -> HostNetworkTier.PRIVATE_LAN // RFC1918 192.168/16
|
||||
first == 172 && second in 16..31 -> HostNetworkTier.PRIVATE_LAN // RFC1918 172.16/12
|
||||
first == 169 && second == 254 -> HostNetworkTier.PRIVATE_LAN // link-local 169.254/16
|
||||
first == 100 && second in 64..127 -> HostNetworkTier.TAILSCALE // CGNAT 100.64/10 (Tailscale)
|
||||
else -> HostNetworkTier.PUBLIC
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict dotted-quad parse; anything else (hostnames, IPv6, out-of-range octets, wrong arity)
|
||||
* → null. Public so pairing-error diagnosis can reuse the exact same rule.
|
||||
*/
|
||||
public fun ipv4Octets(host: String): List<Int>? {
|
||||
val parts = host.split(".")
|
||||
if (parts.size != IPV4_OCTET_COUNT) return null
|
||||
val octets = parts.map { it.toIntOrNull() ?: return null }
|
||||
if (octets.any { it !in IPV4_OCTET_RANGE }) return null
|
||||
return octets
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* A paired web-terminal host (FROZEN contract, plan §3.1). The SINGLE point of derivation for
|
||||
* the `Origin` header and the WS URL — hand-assembling either anywhere else is a review
|
||||
* CRITICAL (CSWSH defence, TECH_DOC §7). Android analogue of iOS `WireProtocol.HostEndpoint`.
|
||||
*
|
||||
* Derivations are computed once by [fromBaseUrl] and stored immutably:
|
||||
* - [originHeader] = `<scheme>://<host>[:<port>]`, omitting the scheme's default port
|
||||
* (http/80, https/443), scheme+host lowercased, IPv6 bracketed — byte-identical to the
|
||||
* browser's Origin serialization. The server re-parses BOTH sides via `new URL()` and
|
||||
* compares protocol/hostname/port (`src/http/origin.ts:31-51`), so this must match one of
|
||||
* the server's NIC-derived allowed origins exactly or the WS upgrade 401s.
|
||||
* - [wsUrl] = same host+port as dialed, scheme http->ws / https->wss, path replaced by
|
||||
* [WireConstants.WS_PATH]; query/fragment/credentials dropped. The dialed port is kept
|
||||
* verbatim here (unlike the origin, which drops default ports).
|
||||
*
|
||||
* Pure Kotlin/JVM only — `java.net.URI` is JVM stdlib parsing, not an Android import.
|
||||
* Persistence (A12 host-registry) stores the [baseUrl] string and reconstructs via
|
||||
* [fromBaseUrl], re-validating on load (untrusted at rest).
|
||||
*/
|
||||
public class HostEndpoint private constructor(
|
||||
/** The URL the user dialed (`http(s)://<host>[:<port>]`). Path/query/fragment/credentials it carried are ignored by the derivations. */
|
||||
public val baseUrl: String,
|
||||
/** Derived WS endpoint (`ws(s)://…/term`). Read-only. */
|
||||
public val wsUrl: String,
|
||||
/** Derived `Origin` header value — see class doc. Never hand-assemble elsewhere. */
|
||||
public val originHeader: String,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is HostEndpoint &&
|
||||
baseUrl == other.baseUrl &&
|
||||
wsUrl == other.wsUrl &&
|
||||
originHeader == other.originHeader
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = baseUrl.hashCode()
|
||||
result = 31 * result + wsUrl.hashCode()
|
||||
result = 31 * result + originHeader.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String = "HostEndpoint(baseUrl=$baseUrl, wsUrl=$wsUrl, originHeader=$originHeader)"
|
||||
|
||||
public companion object {
|
||||
private val WS_SCHEME_BY_HTTP: Map<String, String> = mapOf("http" to "ws", "https" to "wss")
|
||||
private val DEFAULT_PORT_BY_SCHEME: Map<String, Int> = mapOf("http" to 80, "https" to 443)
|
||||
|
||||
/**
|
||||
* Validating factory: returns null unless [baseUrl] parses as an http(s) URL with a
|
||||
* non-empty host. QR-scan / manual-entry payloads are untrusted external input — reject
|
||||
* early (plan §5).
|
||||
*/
|
||||
public fun fromBaseUrl(baseUrl: String): HostEndpoint? {
|
||||
// Trim ONCE, here, and store the trimmed value: downstream re-parsers (PairingProbe
|
||||
// httpBaseUrl, HostClassifier.hostOf) do `URI(endpoint.baseUrl)` and would otherwise
|
||||
// throw / misclassify on stray surrounding whitespace from a QR scan or manual entry.
|
||||
val trimmed = baseUrl.trim()
|
||||
val uri = try {
|
||||
URI(trimmed)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
val wsScheme = WS_SCHEME_BY_HTTP[scheme] ?: return null // rejects non-http(s)
|
||||
val host = uri.host ?: return null
|
||||
if (host.isEmpty()) return null
|
||||
val port: Int? = uri.port.takeIf { it != -1 }
|
||||
|
||||
return HostEndpoint(
|
||||
baseUrl = trimmed,
|
||||
wsUrl = deriveWsUrl(wsScheme, host, port),
|
||||
originHeader = deriveOrigin(scheme, host.lowercase(), port),
|
||||
)
|
||||
}
|
||||
|
||||
private fun deriveOrigin(scheme: String, host: String, port: Int?): String {
|
||||
val serializedHost = bracketIfIpv6(host)
|
||||
val defaultPort = DEFAULT_PORT_BY_SCHEME[scheme]
|
||||
return if (port == null || port == defaultPort) {
|
||||
"$scheme://$serializedHost"
|
||||
} else {
|
||||
"$scheme://$serializedHost:$port"
|
||||
}
|
||||
}
|
||||
|
||||
private fun deriveWsUrl(wsScheme: String, host: String, port: Int?): String {
|
||||
val serializedHost = bracketIfIpv6(host)
|
||||
val portPart = if (port == null) "" else ":$port"
|
||||
return "$wsScheme://$serializedHost$portPart${WireConstants.WS_PATH}"
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv6 literals need brackets in an Origin / URL. `URI.getHost()` has returned them both
|
||||
* bare and pre-bracketed across JVMs — wrap idempotently.
|
||||
*/
|
||||
private fun bracketIfIpv6(host: String): String =
|
||||
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
/**
|
||||
* Thin HTTP I/O boundary (FROZEN contract, plan §3.1). The Android analogue of iOS
|
||||
* `WireProtocol.HTTPTransport`. `:api-client` (A8/A9) builds [HttpRequest]s (stamping `Origin`
|
||||
* IFF the route is a mutating/guarded one, plan §4.3) and sends them through this seam;
|
||||
* `:transport-okhttp` (A7) implements it over OkHttp, the fake in `:test-support` (A3) queues
|
||||
* canned responses. Pure interface — the request/response DTOs are OkHttp-free so both sides
|
||||
* share them without an Android dependency.
|
||||
*/
|
||||
public interface HttpTransport {
|
||||
/**
|
||||
* Perform one HTTP exchange. Implementations throw on transport-level failure; a non-2xx
|
||||
* status is RETURNED (not thrown) — classification (e.g. `PairingError`) is the caller's job.
|
||||
*/
|
||||
public suspend fun send(request: HttpRequest): HttpResponse
|
||||
}
|
||||
|
||||
/** HTTP verbs used by the client surface (`src/http` routes: GET reads, POST/PUT/DELETE guarded). */
|
||||
public enum class HttpMethod {
|
||||
GET,
|
||||
POST,
|
||||
PUT,
|
||||
DELETE,
|
||||
}
|
||||
|
||||
/**
|
||||
* A pure HTTP request DTO built by `:api-client`. [headers] carries `Origin` only for guarded
|
||||
* routes (plan §4.3 铁律). [body] is opaque bytes (JSON for the mutating routes); null for GET.
|
||||
*
|
||||
* NOTE: [body] is a `ByteArray`, so generated equality is by reference — these are transient
|
||||
* I/O carriers, not value-equality keys.
|
||||
*/
|
||||
public data class HttpRequest(
|
||||
val method: HttpMethod,
|
||||
val url: String,
|
||||
val headers: Map<String, String> = emptyMap(),
|
||||
val body: ByteArray? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A pure HTTP response DTO. [status] is the HTTP status code (204/404/403/429 all meaningful
|
||||
* to callers — see plan §4.2/§4.3); [body] is the raw bytes (may be empty for 204).
|
||||
*
|
||||
* NOTE: [body] is a `ByteArray`, so generated equality is by reference — see [HttpRequest].
|
||||
*/
|
||||
public data class HttpResponse(
|
||||
val status: Int,
|
||||
val body: ByteArray,
|
||||
val headers: Map<String, String> = emptyMap(),
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
/**
|
||||
* Pure codec for the WS text-frame protocol (frozen contract, plan §3.1). NEVER throws in
|
||||
* either direction — the Android analogue of iOS `WireProtocol.MessageCodec`.
|
||||
*
|
||||
* - [encode] client → server. HAND-ROLLED so the bytes are IDENTICAL to the web
|
||||
* client / server `JSON.stringify` (explicit `sessionId:null`, top-level
|
||||
* `approve.mode`, JS control-char escaping). Every [ClientMessage] has a
|
||||
* representation, so encode is total.
|
||||
* - [decodeServer] server → client (UNTRUSTED). Tolerant kotlinx.serialization decode into a
|
||||
* [ServerMessage]; a malformed / unknown / wrong-typed-required frame → null
|
||||
* (drop + count, never crash — mirrors the server's own resilience).
|
||||
* - [decodeClient] the boundary validator — the Android mirror of the server's
|
||||
* `parseClientMessage` (type whitelist, `resize` 1..1000, `input.data`
|
||||
* passthrough, `attach.sessionId` UUID-v4 / explicit-null). Invalid → null.
|
||||
* Exact inverse of [encode] for round-trip verification.
|
||||
*/
|
||||
public object MessageCodec {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
// ─── encode (client → server) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encode a [ClientMessage] as a JSON text frame the server's `parseClientMessage` accepts,
|
||||
* BYTE-IDENTICAL to the web client. String escaping matches `JSON.stringify` (short escapes
|
||||
* for `\b \t \n \f \r`, `\u00xx` lowercase-hex for the other C0 control bytes); `attach`
|
||||
* always carries an explicit `sessionId` key (null when absent); UUIDs are lowercased;
|
||||
* `approve.mode` is a TOP-LEVEL key (the server re-reads the raw frame for it).
|
||||
*/
|
||||
public fun encode(message: ClientMessage): String = when (message) {
|
||||
is ClientMessage.Attach -> encodeAttach(message.sessionId, message.cwd)
|
||||
is ClientMessage.Input -> """{"type":"input","data":${jsonString(message.data)}}"""
|
||||
is ClientMessage.Resize -> """{"type":"resize","cols":${message.cols},"rows":${message.rows}}"""
|
||||
is ClientMessage.Approve ->
|
||||
message.mode?.let { """{"type":"approve","mode":"${it.wire}"}""" } ?: """{"type":"approve"}"""
|
||||
ClientMessage.Reject -> """{"type":"reject"}"""
|
||||
}
|
||||
|
||||
private fun encodeAttach(sessionId: String?, cwd: String?): String {
|
||||
// The sessionId is escaped via jsonString (like every other string field) so encode stays
|
||||
// TOTAL and byte-identical to JSON.stringify — never interpolate an id raw (JSON injection).
|
||||
val sid = if (sessionId != null) jsonString(sessionId.lowercase()) else "null"
|
||||
val cwdPart = if (cwd != null) ",\"cwd\":${jsonString(cwd)}" else ""
|
||||
return """{"type":"attach","sessionId":$sid$cwdPart}"""
|
||||
}
|
||||
|
||||
/** JSON string literal with `JSON.stringify`-identical escaping (iterates UTF-16 units). */
|
||||
private fun jsonString(value: String): String {
|
||||
val sb = StringBuilder(value.length + 2)
|
||||
sb.append('"')
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val c = value[i]
|
||||
when (c) {
|
||||
'"' -> sb.append("\\\"")
|
||||
'\\' -> sb.append("\\\\")
|
||||
'\b' -> sb.append("\\b") // 0x08
|
||||
'\t' -> sb.append("\\t") // 0x09
|
||||
'\n' -> sb.append("\\n") // 0x0A
|
||||
'\u000C' -> sb.append("\\f") // 0x0C
|
||||
'\r' -> sb.append("\\r") // 0x0D
|
||||
else ->
|
||||
when {
|
||||
c.code < 0x20 ->
|
||||
sb.append("\\u").append(c.code.toString(16).padStart(4, '0'))
|
||||
// Valid high+low surrogate pair: emit both UTF-16 units verbatim
|
||||
// (JSON.stringify keeps the astral character as-is).
|
||||
c.isHighSurrogate() && i + 1 < value.length && value[i + 1].isLowSurrogate() -> {
|
||||
sb.append(c).append(value[i + 1])
|
||||
i++ // consume the low surrogate too
|
||||
}
|
||||
// Lone surrogate (a high without a low, or a bare low): ES2019
|
||||
// well-formed JSON.stringify emits \udXXX lowercase-hex.
|
||||
c.isSurrogate() ->
|
||||
sb.append("\\u").append(c.code.toString(16).padStart(4, '0'))
|
||||
else -> sb.append(c)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
sb.append('"')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
// ─── decodeServer (server → client, untrusted) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Decode an untrusted server text frame into a [ServerMessage], or null to DROP it (bad
|
||||
* JSON, non-object, unknown `type`, or a missing/wrong-typed REQUIRED field). Wrong-typed
|
||||
* OPTIONAL fields are tolerated as absent (mirrors the server's tolerant parsing). Unknown
|
||||
* keys are ignored. Never throws.
|
||||
*/
|
||||
public fun decodeServer(raw: String): ServerMessage? {
|
||||
val root = parseObject(raw) ?: return null
|
||||
val type = root.stringField("type") ?: return null
|
||||
return when (ServerMessageType.fromWire(type)) {
|
||||
ServerMessageType.ATTACHED -> decodeAttached(root)
|
||||
ServerMessageType.OUTPUT -> root.stringField("data")?.let { ServerMessage.Output(it) }
|
||||
ServerMessageType.EXIT -> decodeExit(root)
|
||||
ServerMessageType.STATUS -> decodeStatus(root)
|
||||
ServerMessageType.TELEMETRY -> decodeTelemetry(root)
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAttached(obj: JsonObject): ServerMessage? {
|
||||
val raw = obj.stringField("sessionId") ?: return null
|
||||
if (!Validation.isValidSessionId(raw)) return null
|
||||
return ServerMessage.Attached(raw.lowercase())
|
||||
}
|
||||
|
||||
private fun decodeExit(obj: JsonObject): ServerMessage? {
|
||||
val code = obj.intField("code") ?: return null
|
||||
return ServerMessage.Exit(code, obj.stringField("reason"))
|
||||
}
|
||||
|
||||
private fun decodeStatus(obj: JsonObject): ServerMessage? {
|
||||
val status = obj.stringField("status")?.let { ClaudeStatus.fromWire(it) } ?: return null
|
||||
val detail = obj.stringField("detail")
|
||||
val pending = obj.boolField("pending") ?: false
|
||||
val gate = obj.stringField("gate")?.let { GateKind.fromWire(it) }
|
||||
return ServerMessage.Status(status, detail, pending, gate)
|
||||
}
|
||||
|
||||
private fun decodeTelemetry(obj: JsonObject): ServerMessage? {
|
||||
val tel = obj["telemetry"] as? JsonObject ?: return null
|
||||
val at = tel.longField("at") ?: return null // only required field
|
||||
return ServerMessage.Telemetry(
|
||||
StatusTelemetry(
|
||||
contextUsedPct = tel.doubleField("contextUsedPct"),
|
||||
costUsd = tel.doubleField("costUsd"),
|
||||
linesAdded = tel.intField("linesAdded"),
|
||||
linesRemoved = tel.intField("linesRemoved"),
|
||||
model = tel.stringField("model"),
|
||||
effort = tel.stringField("effort"),
|
||||
pr = tel.decodeNested("pr", PrInfo.serializer()),
|
||||
rate = tel.decodeNested("rate", RateInfo.serializer()),
|
||||
at = at,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── decodeClient (boundary validator — server-parseClientMessage mirror) ─────
|
||||
|
||||
/**
|
||||
* Validate + decode a client text frame the way the server's `parseClientMessage` would
|
||||
* ACCEPT/REJECT it: `type` in [ClientMessageType]; `resize` cols/rows non-string integers in
|
||||
* 1..1000; `input.data` a string (passed through VERBATIM); `attach.sessionId` present as
|
||||
* explicit null or a UUID-v4; `attach.cwd` (when present) an absolute path. Invalid → null.
|
||||
* The exact inverse of [encode], so `decodeClient(encode(m)) == m` for canonical `m`.
|
||||
*/
|
||||
public fun decodeClient(raw: String): ClientMessage? {
|
||||
val root = parseObject(raw) ?: return null
|
||||
val type = root.stringField("type") ?: return null
|
||||
return when (ClientMessageType.fromWire(type)) {
|
||||
ClientMessageType.ATTACH -> decodeAttach(root)
|
||||
ClientMessageType.INPUT -> root.stringField("data")?.let { ClientMessage.Input(it) }
|
||||
ClientMessageType.RESIZE -> decodeResize(root)
|
||||
ClientMessageType.APPROVE ->
|
||||
ClientMessage.Approve(root.stringField("mode")?.let { ApproveMode.fromWire(it) })
|
||||
ClientMessageType.REJECT -> ClientMessage.Reject
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeResize(obj: JsonObject): ClientMessage? {
|
||||
val cols = obj.intField("cols")?.takeIf { it in WireConstants.RESIZE_RANGE } ?: return null
|
||||
val rows = obj.intField("rows")?.takeIf { it in WireConstants.RESIZE_RANGE } ?: return null
|
||||
return ClientMessage.Resize(cols, rows)
|
||||
}
|
||||
|
||||
private fun decodeAttach(obj: JsonObject): ClientMessage? {
|
||||
if ("sessionId" !in obj) return null // the key is REQUIRED (present as null or uuid)
|
||||
|
||||
val cwd: String? =
|
||||
obj["cwd"]?.let { el ->
|
||||
val c = (el as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
|
||||
if (!Validation.isAbsoluteCwd(c)) return null
|
||||
c
|
||||
}
|
||||
|
||||
val sidEl = obj.getValue("sessionId")
|
||||
if (sidEl is JsonNull) return ClientMessage.Attach(null, cwd)
|
||||
val sid = (sidEl as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null
|
||||
if (!Validation.isValidSessionId(sid)) return null
|
||||
return ClientMessage.Attach(sid.lowercase(), cwd)
|
||||
}
|
||||
|
||||
// ─── shared JSON helpers (tolerant, never throw) ─────────────────────────────
|
||||
|
||||
private fun parseObject(raw: String): JsonObject? =
|
||||
runCatching { json.parseToJsonElement(raw) }.getOrNull() as? JsonObject
|
||||
|
||||
/** A JSON STRING value only (a number/bool/null/object → null → treated as absent). */
|
||||
private fun JsonObject.stringField(key: String): String? =
|
||||
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
/** A JSON non-string INTEGER only (`"80"`, `80.5`, null → null). */
|
||||
private fun JsonObject.intField(key: String): Int? =
|
||||
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull
|
||||
|
||||
/** A JSON non-string integral LONG only (for the millisecond `at` timestamp). */
|
||||
private fun JsonObject.longField(key: String): Long? =
|
||||
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull
|
||||
|
||||
/** A JSON non-string NUMBER as Double (a quoted number/other → null → treated as absent). */
|
||||
private fun JsonObject.doubleField(key: String): Double? =
|
||||
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull
|
||||
|
||||
/** A JSON BOOLEAN only (a quoted "true" / other → null → caller defaults to false). */
|
||||
private fun JsonObject.boolField(key: String): Boolean? =
|
||||
(this[key] as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull
|
||||
|
||||
/** Decode a nested @Serializable object tolerantly: any decode failure → null (absent). */
|
||||
private fun <T> JsonObject.decodeNested(
|
||||
key: String,
|
||||
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
|
||||
): T? = this[key]?.let { runCatching { json.decodeFromJsonElement(deserializer, it) }.getOrNull() }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
/**
|
||||
* Server -> client WS frames (FROZEN contract, plan §3.1). The Android analogue of
|
||||
* `src/types.ts:109-120` `ServerMessage` and iOS `WireProtocol.ServerMessage`.
|
||||
*
|
||||
* Every frame is UNTRUSTED input: A4's `MessageCodec.decodeServer` maps a raw text frame to
|
||||
* one of these or to `null` (drop the frame) — an undecodable frame is counted and dropped,
|
||||
* NEVER a crash (`src/protocol.ts` resilience). This file only freezes the SHAPES.
|
||||
*/
|
||||
public sealed interface ServerMessage {
|
||||
/**
|
||||
* Attach confirmation. ALWAYS adopt the server-issued id — attaching with an unknown UUID
|
||||
* yields a fresh session id (`src/session/manager.ts`). [sessionId] is the raw string here;
|
||||
* A4's decode enforces the UUID-v4 shape before constructing this.
|
||||
*/
|
||||
public data class Attached(val sessionId: String) : ServerMessage
|
||||
|
||||
/** Opaque ANSI/UTF-8 bytes; ring-buffer replay and the live stream share this shape. */
|
||||
public data class Output(val data: String) : ServerMessage
|
||||
|
||||
/**
|
||||
* Shell exit (terminal). `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) means the
|
||||
* spawn never succeeded (M4) and [reason] is required server-side in that case.
|
||||
*/
|
||||
public data class Exit(val code: Int, val reason: String? = null) : ServerMessage
|
||||
|
||||
/**
|
||||
* Claude Code activity derived from hooks (H2/H3/B4). [pending] = a tool approval is held
|
||||
* server-side and the client may approve/reject; [gate] says which kind. An absent
|
||||
* `pending` decodes as false; an unrecognized `gate` decodes as null (tolerated as absent
|
||||
* so the pending signal is never lost).
|
||||
*/
|
||||
public data class Status(
|
||||
val status: ClaudeStatus,
|
||||
val detail: String? = null,
|
||||
val pending: Boolean = false,
|
||||
val gate: GateKind? = null,
|
||||
) : ServerMessage
|
||||
|
||||
/** Latest statusLine telemetry broadcast (B2). */
|
||||
public data class Telemetry(val telemetry: StatusTelemetry) : ServerMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code activity (`src/types.ts:97` `ClaudeStatus`). `unknown` = no hook signal yet;
|
||||
* `stuck` (A5) = output silent past STUCK_TTL while not idle/exited.
|
||||
*/
|
||||
public enum class ClaudeStatus(public val wire: String) {
|
||||
WORKING("working"),
|
||||
WAITING("waiting"),
|
||||
IDLE("idle"),
|
||||
UNKNOWN("unknown"),
|
||||
STUCK("stuck");
|
||||
|
||||
public companion object {
|
||||
public fun fromWire(value: String): ClaudeStatus? = entries.firstOrNull { it.wire == value }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which held approval a `status` carries (`src/types.ts:101` `PermissionGate`). `plan` = an
|
||||
* ExitPlanMode three-way gate; `tool` = an ordinary tool gate.
|
||||
*/
|
||||
public enum class GateKind(public val wire: String) {
|
||||
TOOL("tool"),
|
||||
PLAN("plan");
|
||||
|
||||
public companion object {
|
||||
public fun fromWire(value: String): GateKind? = entries.firstOrNull { it.wire == value }
|
||||
}
|
||||
}
|
||||
|
||||
/** The `type` discriminator whitelist for server frames (`src/types.ts:109-120`). */
|
||||
public enum class ServerMessageType(public val wire: String) {
|
||||
ATTACHED("attached"),
|
||||
OUTPUT("output"),
|
||||
EXIT("exit"),
|
||||
STATUS("status"),
|
||||
TELEMETRY("telemetry");
|
||||
|
||||
public companion object {
|
||||
public fun fromWire(value: String): ServerMessageType? = entries.firstOrNull { it.wire == value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Per-session statusLine telemetry (B2), broadcast in a `telemetry` frame. FROZEN shape,
|
||||
* mirrors `src/types.ts:412-422` `StatusTelemetry` and iOS `WireProtocol.StatusTelemetry`.
|
||||
*
|
||||
* Every metric is OPTIONAL; [at] (server receive time, ms since epoch) is REQUIRED — a frame
|
||||
* without it is dropped by A4's decoder. Property names match the server JSON keys exactly so
|
||||
* the tolerant `Json` decode configured in A4 maps them directly. `@Serializable` here only
|
||||
* freezes the SHAPE; the tolerant-decode CONFIG (ignoreUnknownKeys / wrong-typed-optional ->
|
||||
* absent) lives in A4.
|
||||
*/
|
||||
@Serializable
|
||||
public data class StatusTelemetry(
|
||||
val contextUsedPct: Double? = null,
|
||||
val costUsd: Double? = null,
|
||||
val linesAdded: Int? = null,
|
||||
val linesRemoved: Int? = null,
|
||||
val model: String? = null,
|
||||
val effort: String? = null,
|
||||
val pr: PrInfo? = null,
|
||||
val rate: RateInfo? = null,
|
||||
/** Server receive timestamp (ms since epoch). REQUIRED — frames without it are dropped. */
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
/** Pull-request summary carried in [StatusTelemetry.pr] (`src/types.ts:419`). */
|
||||
@Serializable
|
||||
public data class PrInfo(
|
||||
val number: Int,
|
||||
val url: String,
|
||||
val reviewState: String? = null,
|
||||
)
|
||||
|
||||
/** Rate-limit summary carried in [StatusTelemetry.rate] (`src/types.ts:420`). */
|
||||
@Serializable
|
||||
public data class RateInfo(
|
||||
val fiveHourPct: Double? = null,
|
||||
val sevenDayPct: Double? = null,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* The ONLY WS I/O boundary (FROZEN contract, plan §3.1/§3.2). The Android analogue of iOS
|
||||
* `WireProtocol.TermTransport`.
|
||||
*
|
||||
* `:transport-okhttp` (A7) and the `FakeTransport` in `:test-support` (A3) both implement this;
|
||||
* `SessionEngine` (A14) cannot tell them apart. Pure interface — NO OkHttp/Android imports.
|
||||
*/
|
||||
public interface TermTransport {
|
||||
/**
|
||||
* Open a WS connection to [HostEndpoint.wsUrl], stamping `Origin: endpoint.originHeader` on
|
||||
* the upgrade (CSWSH defence, plan §5.1). Throws on connect-time failure (the underlying
|
||||
* error is rethrown verbatim so the pairing probe, A9, can classify POSIX/TLS codes).
|
||||
*/
|
||||
public suspend fun connect(endpoint: HostEndpoint): TransportConnection
|
||||
}
|
||||
|
||||
/**
|
||||
* One live WS connection, as an immutable capability handle (FROZEN contract). The Android
|
||||
* analogue of iOS `WireProtocol.TransportConnection` (Swift's closure struct becomes an
|
||||
* interface here so both the OkHttp impl and the fake can implement it).
|
||||
*/
|
||||
public interface TransportConnection {
|
||||
/**
|
||||
* Server JSON text frames, in arrival order. Normal completion of the flow = a clean close;
|
||||
* an exception thrown by the flow = a transport error. The two MUST stay distinguishable
|
||||
* (the engine treats clean-close vs error differently, A14).
|
||||
*/
|
||||
public val frames: Flow<String>
|
||||
|
||||
/** Send one client JSON text frame (produced by A4's `MessageCodec.encode`). */
|
||||
public suspend fun send(frame: String)
|
||||
|
||||
/** Close the connection (client detach — the server-side PTY keeps running). */
|
||||
public suspend fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* One live connection's ping capability (see [PingableTermTransport]). Android analogue of the
|
||||
* iOS SessionCore-internal `ConnectionPinger`.
|
||||
*/
|
||||
public interface ConnectionPinger {
|
||||
/**
|
||||
* Send one WS ping; returns true iff the pong arrived in time (false = a miss, counted by
|
||||
* the PingScheduler against [Tunables.PONG_MISS_LIMIT]).
|
||||
*/
|
||||
public suspend fun ping(): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A [TermTransport] that also exposes a per-connection ping hook, so the pure `PingScheduler`
|
||||
* (A5) can drive keep-alive pings only through transports that support it. Android analogue of
|
||||
* the iOS `transport as? any PingableTermTransport` downcast: `SessionEngine` opens the
|
||||
* connection via [connectPingable] when the transport implements this, otherwise falls back to
|
||||
* plain [TermTransport.connect]. The `FakeTransport` (A3) intentionally does NOT implement this,
|
||||
* so engine tests drive the PingScheduler through an injected closure instead.
|
||||
*/
|
||||
public interface PingableTermTransport : TermTransport {
|
||||
/**
|
||||
* Like [connect], but also hands back the ping hook for that same connection (for
|
||||
* PingScheduler wiring).
|
||||
*/
|
||||
public suspend fun connectPingable(endpoint: HostEndpoint): PingableConnection
|
||||
}
|
||||
|
||||
/** The frozen [TransportConnection] PLUS its ping capability (iOS: a `(connection, pinger)` tuple). */
|
||||
public data class PingableConnection(
|
||||
val connection: TransportConnection,
|
||||
val pinger: ConnectionPinger,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One activity-timeline entry from `GET /live-sessions/:id/events` (FROZEN shape, plan §3.1;
|
||||
* mirrors `src/types.ts:434-439` and iOS `WireProtocol.TimelineEvent`).
|
||||
*
|
||||
* The server derives [eventClass] semantically (`src/types.ts:429`: tool/waiting/done/stuck/
|
||||
* user) — the client never re-derives it from hook names. [eventClass] stays a raw String so
|
||||
* the SHAPE decodes even for future/unknown classes; consumers drop unknowns via
|
||||
* [hasKnownClass]. The tolerant list decode (per-element drop, non-array -> empty) is A8.
|
||||
*/
|
||||
@Serializable
|
||||
public data class TimelineEvent(
|
||||
/** Server ingest timestamp (ms since epoch). */
|
||||
val at: Long,
|
||||
/** Server-derived semantic class; see [KNOWN_CLASSES]. JSON key is `class`. */
|
||||
@SerialName("class") val eventClass: String,
|
||||
/** Sanitized tool name (server-side: <=200 chars, control chars stripped). */
|
||||
val toolName: String? = null,
|
||||
/** Server-derived human phrase ("ran Bash", "edited 3 files"). */
|
||||
val label: String,
|
||||
) {
|
||||
/** True when [eventClass] is one the server currently emits. */
|
||||
public val hasKnownClass: Boolean get() = eventClass in KNOWN_CLASSES
|
||||
|
||||
public companion object {
|
||||
/** The server's `TimelineClass` union (`src/types.ts:429`). */
|
||||
public val KNOWN_CLASSES: Set<String> = setOf("tool", "waiting", "done", "stuck", "user")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Client-side tuning constants (FROZEN contract, plan §3.2.1). The Android analogue of iOS
|
||||
* `WireProtocol.Tunables`. Downstream modules (`:session-core` PingScheduler/AwayDigest/
|
||||
* TitleSanitizer, `:transport-okhttp`, `:api-client`) read these — adding/changing one is a
|
||||
* coordination point. All named; no magic numbers anywhere downstream.
|
||||
*/
|
||||
public object Tunables {
|
||||
/**
|
||||
* WS keep-alive ping period. OkHttp's `pingInterval` could drive this, but the miss policy
|
||||
* ([PONG_MISS_LIMIT]) is driven by a `PingScheduler` on an injected clock so a half-dead
|
||||
* connection ("looks connected but dead") is detected deterministically in tests.
|
||||
*/
|
||||
public val PING_INTERVAL: Duration = 25.seconds
|
||||
|
||||
/**
|
||||
* Consecutive missed pongs after which the connection is declared dead: 1 miss is
|
||||
* tolerated, the 2nd is a disconnect signal. Any answered ping resets the counter.
|
||||
*/
|
||||
public const val PONG_MISS_LIMIT: Int = 2
|
||||
|
||||
/** Foreground `/live-sessions` polling period (mirrors `public/launcher.ts` REFRESH_MS). */
|
||||
public val LIST_POLL_INTERVAL: Duration = 5.seconds
|
||||
|
||||
/**
|
||||
* Telemetry chips grey out when [StatusTelemetry.at] is older than this. Mirrors the server
|
||||
* default STATUSLINE_TTL_MS (`src/config.ts`); the server value is env-overridable at
|
||||
* runtime while the client bakes the default — accepted drift (plan §3.2.1).
|
||||
*/
|
||||
public const val TELEMETRY_STALE_TTL_MS: Long = 30_000L
|
||||
|
||||
/** Away-digest banner auto-fade delay. */
|
||||
public val DIGEST_FADE_DELAY: Duration = 8.seconds
|
||||
|
||||
/**
|
||||
* Max session-title length after sanitisation; OSC titles are host/attacker-controlled
|
||||
* input (consumed by `:session-core` TitleSanitizer, A6).
|
||||
*/
|
||||
public const val TITLE_MAX_LENGTH: Int = 256
|
||||
|
||||
/**
|
||||
* Overall pairing-probe deadline: if either probe step hangs past this the probe resolves
|
||||
* `timeout` (`:api-client` pairing, A9).
|
||||
*/
|
||||
public val PAIRING_PROBE_TIMEOUT: Duration = 10.seconds
|
||||
|
||||
/**
|
||||
* Client WS frame cap. The default (1 MiB on most stacks) is too small: ring-buffer replay
|
||||
* arrives as ONE full-snapshot frame and JSON escaping inflates control bytes 1-6x, so the
|
||||
* worst case is ~6 x SCROLLBACK_BYTES (default 2 MiB) plus envelope -> 16 MiB.
|
||||
*
|
||||
* COUPLING WARNING: SCROLLBACK_BYTES is a server env knob the client cannot discover at
|
||||
* runtime. If the host raises it past ~2.7 MB (or replay is extremely escape-dense), the
|
||||
* receive fails and MUST be classified as the non-retryable `replayTooLarge` failure —
|
||||
* never fed into the backoff reconnect loop (plan §3.2 / §9 R6).
|
||||
*/
|
||||
public const val MAX_WS_MESSAGE_BYTES: Long = 16L * 1024 * 1024
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
/**
|
||||
* Boundary validation — the Android analogue of the server's `parseClientMessage` guards
|
||||
* (`src/protocol.ts`) and iOS `WireProtocol.Validation`. Pure functions, NEVER throw.
|
||||
*
|
||||
* These are the primitives A4's [MessageCodec] uses at the trust boundary; they are also
|
||||
* exposed so a caller can validate BEFORE sending (the server SILENTLY DISCARDS invalid
|
||||
* frames — no error reply comes back, `src/protocol.ts:45-86`).
|
||||
*/
|
||||
public object Validation {
|
||||
/**
|
||||
* Byte-identical port of the server's `SESSION_ID_RE`
|
||||
* (`src/protocol.ts:22-23`, exported there; housed here for the Android build):
|
||||
* `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$` case-insensitive.
|
||||
*
|
||||
* UUID v4 — 8-4-4-4-12 hex groups, version nibble `4`, variant nibble `8/9/a/b`, `/i`
|
||||
* (M7). `crypto.randomUUID()` values pass; strings like `"abc123"` are rejected.
|
||||
*/
|
||||
public val SESSION_ID_RE: Regex =
|
||||
Regex(
|
||||
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
/** True when [candidate] is a valid UUID-v4 session id (whole-string match, M7). */
|
||||
public fun isValidSessionId(candidate: String): Boolean = SESSION_ID_RE.matches(candidate)
|
||||
|
||||
/**
|
||||
* Mirrors the server's `isValidDimension` range check for `resize`: both cols and rows
|
||||
* must fall in [WireConstants.RESIZE_RANGE] (1..1000). Frames outside the range are
|
||||
* silently dropped by the server — validate before sending (`src/protocol.ts:113-115`).
|
||||
*/
|
||||
public fun isValidResize(cols: Int, rows: Int): Boolean =
|
||||
cols in WireConstants.RESIZE_RANGE && rows in WireConstants.RESIZE_RANGE
|
||||
|
||||
/**
|
||||
* Mirrors the server's `attach.cwd` check: must be an absolute path (leading `/`). Deeper
|
||||
* normalisation stays server-side (`src/protocol.ts:142-149`, Sec L2).
|
||||
*/
|
||||
public fun isAbsoluteCwd(candidate: String): Boolean = candidate.startsWith("/")
|
||||
|
||||
/**
|
||||
* Client-frame `type` whitelist (the server's `ALLOWED_TYPES`, `src/protocol.ts:27`).
|
||||
* Delegates to the frozen [ClientMessageType] enum so the whitelist has ONE source.
|
||||
*/
|
||||
public fun isAllowedClientType(type: String): Boolean = ClientMessageType.fromWire(type) != null
|
||||
|
||||
/** Server-frame `type` whitelist (`src/types.ts:109-120`), the [ServerMessageType] enum. */
|
||||
public fun isAllowedServerType(type: String): Boolean = ServerMessageType.fromWire(type) != null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
/**
|
||||
* Wire-level constants shared with the server (FROZEN contract, plan §3.1). The Android
|
||||
* analogue of iOS `WireProtocol.WireConstants`. No magic numbers downstream.
|
||||
*/
|
||||
public object WireConstants {
|
||||
/** WS endpoint path (`src/config.ts` DEFAULT_WS_PATH). Stamped into [HostEndpoint.wsUrl]. */
|
||||
public const val WS_PATH: String = "/term"
|
||||
|
||||
/**
|
||||
* Soft-reset prefix (ESC `[0m`) the server prepends to ring-buffer replay as a safety net
|
||||
* (`src/types.ts:167-170` / M2). Clients may use it to recognise the replay boundary;
|
||||
* NEVER strip it — it is valid ANSI the emulator must consume.
|
||||
*/
|
||||
public const val REPLAY_SOFT_RESET_PREFIX: String = "\u001B[0m"
|
||||
|
||||
/**
|
||||
* `exit.code` value meaning the PTY spawn never succeeded (M4); `exit.reason` is required
|
||||
* server-side in that case. Not a retryable state.
|
||||
*/
|
||||
public const val SPAWN_FAILED_EXIT_CODE: Int = -1
|
||||
|
||||
/** Valid `resize` cols/rows range, inclusive (`src/protocol.ts:113-115`). */
|
||||
public val RESIZE_RANGE: IntRange = 1..1000
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* [HostEndpoint.fromBaseUrl] is the SINGLE derivation point for the stored `baseUrl` and the
|
||||
* Origin/WS derivations. These vectors lock the trim fix: the stored `baseUrl` must carry no
|
||||
* surrounding whitespace, so every downstream `URI(endpoint.baseUrl)` re-parse succeeds.
|
||||
*/
|
||||
class HostEndpointTest {
|
||||
@Test
|
||||
fun `fromBaseUrl stores a trimmed baseUrl so downstream URI re-parsing never sees whitespace`() {
|
||||
val padded = requireNotNull(HostEndpoint.fromBaseUrl(" http://192.168.1.5:3000 "))
|
||||
val clean = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
|
||||
|
||||
assertEquals("http://192.168.1.5:3000", padded.baseUrl)
|
||||
assertEquals(padded.baseUrl, padded.baseUrl.trim(), "stored baseUrl has no surrounding whitespace")
|
||||
// The padded input yields an endpoint identical to the trimmed-input variant.
|
||||
assertEquals(clean, padded)
|
||||
// And a downstream re-parse (PairingProbe.httpBaseUrl / HostClassifier.hostOf) now succeeds.
|
||||
assertEquals("192.168.1.5", URI(padded.baseUrl).host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trimming does not disturb the Origin and WS derivations`() {
|
||||
val padded = requireNotNull(HostEndpoint.fromBaseUrl(" https://mac.tailnet.ts.net "))
|
||||
|
||||
assertEquals("https://mac.tailnet.ts.net", padded.baseUrl)
|
||||
assertEquals("https://mac.tailnet.ts.net", padded.originHeader)
|
||||
assertEquals("wss://mac.tailnet.ts.net/term", padded.wsUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A4 boundary-validation vectors: [MessageCodec.decodeClient] is the Android mirror of the
|
||||
* server's `parseClientMessage` accept/reject decision, and the exact inverse of
|
||||
* [MessageCodec.encode]. Accept/reject vectors transcribed from `test/protocol.test.ts` /
|
||||
* the iOS `ServerVectorTests` server-mirror; the round-trip block proves encode/decode symmetry.
|
||||
*/
|
||||
class MessageCodecDecodeClientTest {
|
||||
private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
|
||||
// ─── accept: valid client frames decode to the right message ─────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes attach with explicit null and with a uuid`() {
|
||||
assertEquals(
|
||||
ClientMessage.Attach(null),
|
||||
MessageCodec.decodeClient("""{"type":"attach","sessionId":null}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Attach(validUuid),
|
||||
MessageCodec.decodeClient("""{"type":"attach","sessionId":"$validUuid"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes attach with an absolute cwd and normalizes an uppercase sessionId`() {
|
||||
assertEquals(
|
||||
ClientMessage.Attach(null, "/Users/dev/proj"),
|
||||
MessageCodec.decodeClient("""{"type":"attach","sessionId":null,"cwd":"/Users/dev/proj"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Attach(validUuid),
|
||||
MessageCodec.decodeClient("""{"type":"attach","sessionId":"F47AC10B-58CC-4372-A567-0E02B2C3D479"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes input resize approve and reject, ignoring unknown keys`() {
|
||||
assertEquals(ClientMessage.Input("ls -la"), MessageCodec.decodeClient("""{"type":"input","data":"ls -la"}"""))
|
||||
assertEquals(ClientMessage.Resize(1, 1), MessageCodec.decodeClient("""{"type":"resize","cols":1,"rows":1}"""))
|
||||
assertEquals(
|
||||
ClientMessage.Resize(1000, 1000),
|
||||
MessageCodec.decodeClient("""{"type":"resize","cols":1000,"rows":1000}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Resize(80, 40),
|
||||
MessageCodec.decodeClient("""{"type":"resize","cols":80,"rows":40,"extra":"ignored"}"""),
|
||||
)
|
||||
assertEquals(ClientMessage.Approve(), MessageCodec.decodeClient("""{"type":"approve"}"""))
|
||||
assertEquals(ClientMessage.Reject, MessageCodec.decodeClient("""{"type":"reject"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recovers a known approve mode and tolerates an unknown one as absent`() {
|
||||
assertEquals(
|
||||
ClientMessage.Approve(ApproveMode.ACCEPT_EDITS),
|
||||
MessageCodec.decodeClient("""{"type":"approve","mode":"acceptEdits"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Approve(ApproveMode.DEFAULT),
|
||||
MessageCodec.decodeClient("""{"type":"approve","mode":"default"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ClientMessage.Approve(null),
|
||||
MessageCodec.decodeClient("""{"type":"approve","mode":"alien"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── reject: invalid client frames -> null ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `rejects every invalid client frame as null`() {
|
||||
val invalid = listOf(
|
||||
"not json at all", "", "null", "[]", "42",
|
||||
"""{"type":"ping"}""", """{"data":"hello"}""", """{"type":42}""",
|
||||
"""{"type":"resize","cols":0,"rows":40}""",
|
||||
"""{"type":"resize","cols":1001,"rows":40}""",
|
||||
"""{"type":"resize","cols":80,"rows":0}""",
|
||||
"""{"type":"resize","cols":80,"rows":1001}""",
|
||||
"""{"type":"resize","cols":80.5,"rows":40}""",
|
||||
"""{"type":"resize","cols":80,"rows":24.9}""",
|
||||
"""{"type":"resize","cols":"80","rows":40}""",
|
||||
"""{"type":"resize","rows":40}""",
|
||||
"""{"type":"resize","cols":80}""",
|
||||
"""{"type":"input","data":42}""",
|
||||
"""{"type":"input","data":null}""",
|
||||
"""{"type":"input"}""",
|
||||
"""{"type":"input","data":{}}""",
|
||||
"""{"type":"attach","sessionId":"abc123"}""",
|
||||
"""{"type":"attach","sessionId":""}""",
|
||||
"""{"type":"attach","sessionId":42}""",
|
||||
"""{"type":"attach"}""",
|
||||
"""{"type":"attach","sessionId":null,"cwd":"relative"}""",
|
||||
"""{"type":"attach","sessionId":null,"cwd":42}""",
|
||||
// the removed 'blur' type must be rejected
|
||||
"""{"type":"blur"}""",
|
||||
)
|
||||
for (frame in invalid) {
|
||||
assertNull(MessageCodec.decodeClient(frame), "should reject: $frame")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── round-trip: decodeClient(encode(m)) == m ────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `encode then decodeClient is the identity for canonical messages`() {
|
||||
val messages = listOf(
|
||||
ClientMessage.Attach(null),
|
||||
ClientMessage.Attach(validUuid),
|
||||
ClientMessage.Attach(null, "/Users/dev/proj"),
|
||||
ClientMessage.Attach(validUuid, "/srv/app"),
|
||||
ClientMessage.Input("ls -la"),
|
||||
// control bytes + quote/backslash survive the round trip
|
||||
ClientMessage.Input("hi" + Char(0x0D) + Char(0x1B) + "[A"),
|
||||
ClientMessage.Input("a\"b\\c"),
|
||||
ClientMessage.Resize(80, 24),
|
||||
ClientMessage.Resize(1, 1),
|
||||
ClientMessage.Resize(1000, 1000),
|
||||
ClientMessage.Approve(null),
|
||||
ClientMessage.Approve(ApproveMode.DEFAULT),
|
||||
ClientMessage.Approve(ApproveMode.ACCEPT_EDITS),
|
||||
ClientMessage.Approve(ApproveMode.PLAN),
|
||||
ClientMessage.Approve(ApproveMode.AUTO),
|
||||
ClientMessage.Reject,
|
||||
)
|
||||
for (m in messages) {
|
||||
assertEquals(m, MessageCodec.decodeClient(MessageCodec.encode(m)), "round-trip: $m")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* A4 decode vectors: [MessageCodec.decodeServer] maps an UNTRUSTED server text frame to a
|
||||
* [ServerMessage] or null (drop). Transcribed from the iOS `ServerVectorTests` + the server's
|
||||
* own `test/protocol.test.ts`. It must never throw, tolerate unknown keys, treat wrong-typed
|
||||
* OPTIONAL fields as absent, and drop on any missing/wrong-typed REQUIRED field.
|
||||
*
|
||||
* Control characters in expected VALUES are built with `Char(code)` (never raw source bytes);
|
||||
* JSON escapes inside frame literals use `\\uXXXX`.
|
||||
*/
|
||||
class MessageCodecDecodeServerTest {
|
||||
private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
private val esc = Char(0x1B).toString()
|
||||
|
||||
// ─── attached ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes attached and normalizes an uppercase uuid to lowercase`() {
|
||||
// Arrange / Act / Assert
|
||||
assertEquals(
|
||||
ServerMessage.Attached(validUuid),
|
||||
MessageCodec.decodeServer("""{"type":"attached","sessionId":"$validUuid"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
ServerMessage.Attached(validUuid),
|
||||
MessageCodec.decodeServer("""{"type":"attached","sessionId":"F47AC10B-58CC-4372-A567-0E02B2C3D479"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── output ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes output with ansi bytes verbatim into data`() {
|
||||
// Arrange / Act
|
||||
val decoded = MessageCodec.decodeServer("""{"type":"output","data":"\u001b[1;32mHello\u001b[0m"}""")
|
||||
|
||||
// Assert
|
||||
assertEquals(ServerMessage.Output(esc + "[1;32mHello" + esc + "[0m"), decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores unknown top-level keys on a server frame`() {
|
||||
// Arrange / Act / Assert — ignoreUnknownKeys tolerance
|
||||
assertEquals(
|
||||
ServerMessage.Output("hi"),
|
||||
MessageCodec.decodeServer("""{"type":"output","data":"hi","extra":"ignored"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── exit ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes exit with code only and with code plus reason`() {
|
||||
// Arrange / Act / Assert
|
||||
assertEquals(ServerMessage.Exit(0, null), MessageCodec.decodeServer("""{"type":"exit","code":0}"""))
|
||||
|
||||
val spawnFail = MessageCodec.decodeServer("""{"type":"exit","code":-1,"reason":"spawn failed: /bin/badshell"}""")
|
||||
assertEquals(ServerMessage.Exit(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn failed: /bin/badshell"), spawnFail)
|
||||
}
|
||||
|
||||
// ─── status ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes every ClaudeStatus with safe defaults for detail pending gate`() {
|
||||
for (raw in listOf("working", "waiting", "idle", "unknown", "stuck")) {
|
||||
// Act
|
||||
val decoded = MessageCodec.decodeServer("""{"type":"status","status":"$raw"}""")
|
||||
|
||||
// Assert
|
||||
val expected = ServerMessage.Status(ClaudeStatus.fromWire(raw)!!, null, false, null)
|
||||
assertEquals(expected, decoded, "status=$raw")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes status carrying detail pending and a tool or plan gate`() {
|
||||
assertEquals(
|
||||
ServerMessage.Status(ClaudeStatus.WAITING, "Bash", true, GateKind.TOOL),
|
||||
MessageCodec.decodeServer(
|
||||
"""{"type":"status","status":"waiting","detail":"Bash","pending":true,"gate":"tool"}""",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
ServerMessage.Status(ClaudeStatus.WAITING, null, true, GateKind.PLAN),
|
||||
MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true,"gate":"plan"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tolerates an unknown gate value and a non-boolean pending`() {
|
||||
// Unknown gate -> null but the pending signal is kept (frame not dropped).
|
||||
assertEquals(
|
||||
ServerMessage.Status(ClaudeStatus.WAITING, null, true, null),
|
||||
MessageCodec.decodeServer("""{"type":"status","status":"waiting","pending":true,"gate":"alien"}"""),
|
||||
)
|
||||
// Non-boolean pending -> safe default false.
|
||||
assertEquals(
|
||||
ServerMessage.Status(ClaudeStatus.WORKING, null, false, null),
|
||||
MessageCodec.decodeServer("""{"type":"status","status":"working","pending":"yes"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodes a full telemetry sample field by field`() {
|
||||
// Arrange
|
||||
val frame =
|
||||
"{\"type\":\"telemetry\",\"telemetry\":{" +
|
||||
"\"contextUsedPct\":42.5,\"costUsd\":1.23," +
|
||||
"\"linesAdded\":10,\"linesRemoved\":2," +
|
||||
"\"model\":\"Fable 5\",\"effort\":\"high\"," +
|
||||
"\"pr\":{\"number\":7,\"url\":\"https://github.com/x/y/pull/7\",\"reviewState\":\"APPROVED\"}," +
|
||||
"\"rate\":{\"fiveHourPct\":12.5,\"sevenDayPct\":33},\"at\":1751600000000}}"
|
||||
|
||||
// Act
|
||||
val decoded = MessageCodec.decodeServer(frame)
|
||||
|
||||
// Assert
|
||||
val expected = ServerMessage.Telemetry(
|
||||
StatusTelemetry(
|
||||
contextUsedPct = 42.5,
|
||||
costUsd = 1.23,
|
||||
linesAdded = 10,
|
||||
linesRemoved = 2,
|
||||
model = "Fable 5",
|
||||
effort = "high",
|
||||
pr = PrInfo(7, "https://github.com/x/y/pull/7", "APPROVED"),
|
||||
rate = RateInfo(12.5, 33.0),
|
||||
at = 1_751_600_000_000L,
|
||||
),
|
||||
)
|
||||
assertEquals(expected, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes telemetry with only the required at field`() {
|
||||
assertEquals(
|
||||
ServerMessage.Telemetry(StatusTelemetry(at = 123L)),
|
||||
MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":123}}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tolerates wrong-typed and unknown optional telemetry fields`() {
|
||||
// costUsd string + pr non-object are treated as absent; the frame is kept.
|
||||
assertEquals(
|
||||
ServerMessage.Telemetry(StatusTelemetry(at = 5L)),
|
||||
MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":5,"costUsd":"lots","pr":42}}"""),
|
||||
)
|
||||
// Unknown nested key ignored.
|
||||
assertEquals(
|
||||
ServerMessage.Telemetry(StatusTelemetry(at = 9L)),
|
||||
MessageCodec.decodeServer("""{"type":"telemetry","telemetry":{"at":9,"mystery":true}}"""),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── invalid frames -> null (never throws) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `drops every malformed server frame as null`() {
|
||||
val invalid = listOf(
|
||||
"not json at all", "", "null", "[]", "42", "true",
|
||||
"""{"type":"ping"}""", """{"data":"hello"}""", """{"type":42}""",
|
||||
// attached: non-uuid / v1 / missing / wrong-typed (M7)
|
||||
"""{"type":"attached","sessionId":"abc123"}""",
|
||||
"""{"type":"attached","sessionId":"550e8400-e29b-11d4-a716-446655440000"}""",
|
||||
"""{"type":"attached"}""",
|
||||
"""{"type":"attached","sessionId":42}""",
|
||||
// output: data missing / wrong-typed
|
||||
"""{"type":"output"}""",
|
||||
"""{"type":"output","data":42}""",
|
||||
// exit: code missing / string / non-integer
|
||||
"""{"type":"exit"}""",
|
||||
"""{"type":"exit","code":"0"}""",
|
||||
"""{"type":"exit","code":1.5}""",
|
||||
// status: status missing / unknown value / wrong-typed
|
||||
"""{"type":"status"}""",
|
||||
"""{"type":"status","status":"sleeping"}""",
|
||||
"""{"type":"status","status":42}""",
|
||||
// telemetry: key missing / non-object / at missing / at wrong-typed
|
||||
"""{"type":"telemetry"}""",
|
||||
"""{"type":"telemetry","telemetry":"x"}""",
|
||||
"""{"type":"telemetry","telemetry":{"costUsd":1}}""",
|
||||
"""{"type":"telemetry","telemetry":{"at":"now"}}""",
|
||||
)
|
||||
|
||||
for (frame in invalid) {
|
||||
assertNull(MessageCodec.decodeServer(frame), "should drop: $frame")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `treats client frame types as non-server frames`() {
|
||||
val clientFrames = listOf(
|
||||
ClientMessage.Attach(null),
|
||||
ClientMessage.Input("x"),
|
||||
ClientMessage.Resize(80, 24),
|
||||
ClientMessage.Approve(ApproveMode.PLAN),
|
||||
ClientMessage.Reject,
|
||||
)
|
||||
for (m in clientFrames) {
|
||||
assertNull(MessageCodec.decodeServer(MessageCodec.encode(m)), "client frame not a server frame: $m")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── fuzz: never throws on hostile input ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `survives random byte fuzz without throwing`() {
|
||||
val rng = Random(0xDEADBEEF)
|
||||
repeat(300) {
|
||||
val len = rng.nextInt(0, 81)
|
||||
val bytes = ByteArray(len) { rng.nextInt(0, 256).toByte() }
|
||||
val text = String(bytes, Charsets.UTF_8)
|
||||
assertDoesNotThrow { MessageCodec.decodeServer(text) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `survives single-byte mutations of valid frames without throwing`() {
|
||||
val rng = Random(0xFEEDFACE)
|
||||
val seeds = listOf(
|
||||
"""{"type":"attached","sessionId":"$validUuid"}""",
|
||||
"""{"type":"output","data":"\u001b[1;32mHello\u001b[0m"}""",
|
||||
"""{"type":"exit","code":-1,"reason":"spawn failed"}""",
|
||||
"""{"type":"status","status":"waiting","pending":true,"gate":"plan"}""",
|
||||
"""{"type":"telemetry","telemetry":{"at":1751600000000,"costUsd":1.5}}""",
|
||||
)
|
||||
for (seed in seeds) {
|
||||
repeat(40) {
|
||||
val bytes = seed.toByteArray(Charsets.UTF_8)
|
||||
bytes[rng.nextInt(0, bytes.size)] = rng.nextInt(0, 256).toByte()
|
||||
assertDoesNotThrow { MessageCodec.decodeServer(String(bytes, Charsets.UTF_8)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A4 golden vectors: [MessageCodec.encode] must be BYTE-IDENTICAL to the web client /
|
||||
* server `JSON.stringify` for every [ClientMessage]. Expected strings transcribed from
|
||||
* `src/protocol.ts` / `public/terminal-session.ts` and cross-checked against the iOS
|
||||
* `CodecRoundtripTests` (same wire). Any drift here is a cross-implementation break.
|
||||
*
|
||||
* Control bytes are written as explicit `\u` escapes (never raw bytes) so the vectors stay
|
||||
* copy-safe.
|
||||
*/
|
||||
class MessageCodecEncodeTest {
|
||||
private val validUuid = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
|
||||
@Test
|
||||
fun `attach without sessionId still carries explicit sessionId null`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = null))
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"attach","sessionId":null}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach with uuid serializes lowercased uuid string`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = validUuid))
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"attach","sessionId":"$validUuid"}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach lowercases an uppercase uuid so the wire is canonical`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(
|
||||
ClientMessage.Attach(sessionId = "F47AC10B-58CC-4372-A567-0E02B2C3D479"),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"attach","sessionId":"$validUuid"}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach cwd key appears only when present, after sessionId`() {
|
||||
// Arrange / Act
|
||||
val withCwd = MessageCodec.encode(ClientMessage.Attach(sessionId = null, cwd = "/Users/dev/proj"))
|
||||
val withoutCwd = MessageCodec.encode(ClientMessage.Attach(sessionId = null))
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"attach","sessionId":null,"cwd":"/Users/dev/proj"}""", withCwd)
|
||||
assertFalse(withoutCwd.contains("cwd"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input passes raw keyboard bytes through, escaping control bytes like JSON stringify`() {
|
||||
// Arrange — Esc / Up / ^C / CR / Tab / Shift+Tab verbatim vector (test/protocol.test.ts)
|
||||
val raw = "\u001B[A\u0003\r\t\u001B[Z"
|
||||
|
||||
// Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Input(raw))
|
||||
|
||||
// Assert — ESC/^C -> \u001b / \u0003, CR -> \r, Tab -> \t
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\u001b[A\\u0003\\r\\t\\u001b[Z\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input escapes quote and backslash and control bytes exactly like JSON stringify`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("hi\r\u001B[A\"\\"))
|
||||
|
||||
// Assert — byte-identical to the JS client's JSON.stringify output
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"hi\\r\\u001b[A\\\"\\\\\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input uses the JSON short escapes for backspace tab newline formfeed carriage-return`() {
|
||||
// Arrange / Act
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("\u0008\u0009\u000A\u000C\u000D"))
|
||||
|
||||
// Assert
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\b\\t\\n\\f\\r\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input long-form-escapes the other C0 control bytes as lowercase hex`() {
|
||||
// Arrange — NUL / BEL / unit-separator have no short escape
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("\u0000\u0007\u001F"))
|
||||
|
||||
// Assert
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\u0000\\u0007\\u001f\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input leaves ordinary and non-ascii characters unescaped`() {
|
||||
// Arrange / Act — '/' is NOT escaped by JSON.stringify; unicode >= 0x20 passes through
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("a/b 日本語 é"))
|
||||
|
||||
// Assert
|
||||
assertEquals("""{"type":"input","data":"a/b 日本語 é"}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach escapes a sessionId containing a quote like JSON stringify`() {
|
||||
// A canonical id is a UUID, but encode must stay TOTAL + byte-identical to JSON.stringify
|
||||
// for ANY string — defence against JSON injection via an unescaped id (the encodeAttach fix).
|
||||
val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = "x\"y"))
|
||||
|
||||
assertEquals("""{"type":"attach","sessionId":"x\"y"}""", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach escapes a sessionId containing a backslash like JSON stringify`() {
|
||||
val frame = MessageCodec.encode(ClientMessage.Attach(sessionId = "a\\b"))
|
||||
|
||||
// JSON output is {"type":"attach","sessionId":"a\\b"}
|
||||
assertEquals("{\"type\":\"attach\",\"sessionId\":\"a\\\\b\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input escapes a lone high surrogate as lowercase u-escape like JSON stringify`() {
|
||||
// node: JSON.stringify("\uD800x") === "\"\\ud800x\"" (ES2019 well-formed JSON.stringify)
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("\uD800x"))
|
||||
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\ud800x\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input escapes a lone low surrogate as lowercase u-escape`() {
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("\uDC00"))
|
||||
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"\\udc00\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input keeps a valid surrogate pair verbatim like JSON stringify`() {
|
||||
// 😀 = U+1F600 = high D83D + low DE00; JSON.stringify emits the astral char unescaped.
|
||||
val frame = MessageCodec.encode(ClientMessage.Input("😀"))
|
||||
|
||||
assertEquals("{\"type\":\"input\",\"data\":\"😀\"}", frame)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resize emits bare integer cols and rows in order`() {
|
||||
// Arrange / Act / Assert
|
||||
assertEquals("""{"type":"resize","cols":120,"rows":40}""", MessageCodec.encode(ClientMessage.Resize(120, 40)))
|
||||
assertEquals("""{"type":"resize","cols":1,"rows":1}""", MessageCodec.encode(ClientMessage.Resize(1, 1)))
|
||||
assertEquals(
|
||||
"""{"type":"resize","cols":1000,"rows":1000}""",
|
||||
MessageCodec.encode(ClientMessage.Resize(1000, 1000)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approve without mode is a bare frame, reject is a bare frame`() {
|
||||
// Arrange / Act / Assert
|
||||
assertEquals("""{"type":"approve"}""", MessageCodec.encode(ClientMessage.Approve()))
|
||||
assertEquals("""{"type":"reject"}""", MessageCodec.encode(ClientMessage.Reject))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approve mode is a top-level key with the server whitelist spelling`() {
|
||||
// Arrange / Act / Assert — every ApproveMode wire value, mode at the top level
|
||||
assertEquals(
|
||||
"""{"type":"approve","mode":"default"}""",
|
||||
MessageCodec.encode(ClientMessage.Approve(ApproveMode.DEFAULT)),
|
||||
)
|
||||
assertEquals(
|
||||
"""{"type":"approve","mode":"acceptEdits"}""",
|
||||
MessageCodec.encode(ClientMessage.Approve(ApproveMode.ACCEPT_EDITS)),
|
||||
)
|
||||
assertEquals(
|
||||
"""{"type":"approve","mode":"plan"}""",
|
||||
MessageCodec.encode(ClientMessage.Approve(ApproveMode.PLAN)),
|
||||
)
|
||||
assertEquals(
|
||||
"""{"type":"approve","mode":"auto"}""",
|
||||
MessageCodec.encode(ClientMessage.Approve(ApproveMode.AUTO)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package wang.yaojia.webterm.wire
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A4 boundary-validation primitives. Session-id vectors are the M7 UUID-v4 set from
|
||||
* `test/protocol.test.ts` / iOS `ServerVectorTests`; resize/cwd/type-whitelist mirror
|
||||
* `src/protocol.ts`.
|
||||
*/
|
||||
class ValidationTest {
|
||||
// ─── isValidSessionId (SESSION_ID_RE, M7) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `accepts valid uuid v4 including uppercase hex and variant 8`() {
|
||||
assertTrue(Validation.isValidSessionId("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||
assertTrue(Validation.isValidSessionId("F47AC10B-58CC-4372-A567-0E02B2C3D479"))
|
||||
assertTrue(Validation.isValidSessionId("550e8400-e29b-41d4-8716-446655440000"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects non-uuid empty wrong-version wrong-variant and malformed ids`() {
|
||||
val invalid = listOf(
|
||||
"abc123",
|
||||
"",
|
||||
"550e8400-e29b-11d4-a716-446655440000", // version nibble = 1 (v1)
|
||||
"f47ac10b-58cc-4372-c567-0e02b2c3d479", // variant 'c' (not 8/9/a/b)
|
||||
"f47ac10b58cc4372a5670e02b2c3d479", // no separators
|
||||
"f47ac10b-58cc-4372-a567-0e02b2c3d47", // one digit short
|
||||
"g47ac10b-58cc-4372-a567-0e02b2c3d479", // illegal char 'g'
|
||||
)
|
||||
for (candidate in invalid) {
|
||||
assertFalse(Validation.isValidSessionId(candidate), "should reject: $candidate")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exposes the session-id regex for downstream reuse`() {
|
||||
assertTrue(Validation.SESSION_ID_RE.matches("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||
assertFalse(Validation.SESSION_ID_RE.matches("nope"))
|
||||
}
|
||||
|
||||
// ─── isValidResize ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `accepts resize dimensions on the inclusive 1 to 1000 boundary`() {
|
||||
assertTrue(Validation.isValidResize(1, 1))
|
||||
assertTrue(Validation.isValidResize(1000, 1000))
|
||||
assertTrue(Validation.isValidResize(120, 40))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects resize dimensions outside 1 to 1000`() {
|
||||
assertFalse(Validation.isValidResize(0, 40))
|
||||
assertFalse(Validation.isValidResize(1001, 40))
|
||||
assertFalse(Validation.isValidResize(80, 0))
|
||||
assertFalse(Validation.isValidResize(80, 1001))
|
||||
}
|
||||
|
||||
// ─── isAbsoluteCwd ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `accepts absolute cwd and rejects relative or empty`() {
|
||||
assertTrue(Validation.isAbsoluteCwd("/a"))
|
||||
assertFalse(Validation.isAbsoluteCwd("a"))
|
||||
assertFalse(Validation.isAbsoluteCwd(""))
|
||||
}
|
||||
|
||||
// ─── type whitelists ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `whitelists exactly the client frame types`() {
|
||||
for (t in listOf("attach", "input", "resize", "approve", "reject")) {
|
||||
assertTrue(Validation.isAllowedClientType(t), "client type: $t")
|
||||
}
|
||||
for (t in listOf("blur", "ping", "attached", "")) {
|
||||
assertFalse(Validation.isAllowedClientType(t), "not a client type: $t")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whitelists exactly the server frame types`() {
|
||||
for (t in listOf("attached", "output", "exit", "status", "telemetry")) {
|
||||
assertTrue(Validation.isAllowedServerType(t), "server type: $t")
|
||||
}
|
||||
for (t in listOf("input", "blur", "approve", "")) {
|
||||
assertFalse(Validation.isAllowedServerType(t), "not a server type: $t")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user