feat(android): close the audit's remaining parity gaps

Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.

CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.

Two real defects surfaced while building it:

  renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
  but /device/:id/renew returns only {cert, caChain, notAfter}; only
  /device/enroll echoes deviceId. Every silent renewal would have thrown
  MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
  path segment we addressed.

  And because no renew 201 carries renewAfter, a client that persisted it would
  rotate exactly once and then report not-due until the cert died. That is why
  renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
  control plane's own formula. This is a latent iOS defect that Android now
  avoids rather than inherits.

/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.

Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.

FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.

UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.

COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.

HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.

/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.

Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
This commit is contained in:
Yaojia Wang
2026-07-30 15:37:09 +02:00
parent 8075d2c671
commit 390dd11202
70 changed files with 8096 additions and 141 deletions

View File

@@ -15,8 +15,11 @@ import wang.yaojia.webterm.wire.HttpTransport
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
* notBefore, notAfter, renewAfter }`
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain,
* notAfter }`. The server schema is `.strict()`; NO
* keyAlg/subdomain/deviceName.
* `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert,
* caChain, notAfter }`. The EXPIRED leaf's escape hatch.
*
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
@@ -29,8 +32,12 @@ public class DeviceEnrollmentClient(
baseUrl: String,
private val http: HttpTransport,
) {
/** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */
private val base: String = baseUrl.trim().trimEnd('/')
/**
* Base control-plane URL with any trailing slash removed, so `baseUrl + path` is well-formed.
* Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew
* against that same one (a URL is not a credential).
*/
public val baseUrl: String = baseUrl.trim().trimEnd('/')
/**
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
@@ -98,7 +105,38 @@ public class DeviceEnrollmentClient(
)
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
// The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we
// addressed the request to is the authoritative one.
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
}
/**
* Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the
* BODY plus a fresh [csrDer] over the SAME hardware key.
*
* **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it
* renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired
* client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca`
* tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a
* PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature,
* which the control plane checks together with `CSR key == registered key`.
*
* No bearer is accepted on this path at all — the body is the whole credential story. Everything
* else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse,
* `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only
* `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401.
*/
public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult {
if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) {
throw DeviceEnrollmentError.InvalidRequest
}
val body = EnrollJson.encodeToString(
RecoverRequestBody.serializer(),
RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)),
)
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover"
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null))
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
}
// ── Request/response plumbing ────────────────────────────────────────────────────────────
@@ -114,7 +152,7 @@ public class DeviceEnrollmentClient(
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
// string must never emit a bare "Bearer " header.
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
return HttpRequest(method = method, url = baseUrl + path, headers = headers, body = jsonBody)
}
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
@@ -127,6 +165,24 @@ public class DeviceEnrollmentClient(
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
}
/**
* Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId]
* the request was addressed to. `renewAfter` is normally absent here — the rotation policy
* re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`).
*/
private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult =
EnrollmentResult(
deviceId = deviceId,
certificate = decodeDerOrThrow(dto.cert),
caChain = dto.caChain.map { decodeDerOrThrow(it) },
notBefore = parseInstantOrNull(dto.notBefore),
notAfter = parseInstantOrNull(dto.notAfter),
renewAfter = parseInstantOrNull(dto.renewAfter),
)
private fun decodeDerOrThrow(base64Der: String): ByteArray =
decodeBase64OrNull(base64Der) ?: throw DeviceEnrollmentError.MalformedResponse
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
val chain = dto.caChain.map { entry ->

View File

@@ -97,6 +97,19 @@ internal data class EnrollRequestBody(
@Serializable
internal data class RenewRequestBody(val csr: String)
/**
* The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key.
*
* The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator
* forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`,
* and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is
* lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR
* proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the
* handshake used to prove it. The server schema is `.strict()` on these two keys.
*/
@Serializable
internal data class RecoverRequestBody(val cert: String, val csr: String)
@Serializable
internal data class LoginResponseDto(
val enrollToken: String,
@@ -114,6 +127,25 @@ internal data class EnrollResponseDto(
val renewAfter: String? = null,
)
/**
* The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY.
*
* Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor
* `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs
* `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required,
* failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it
* is the path segment the request was addressed to, which is the authoritative value anyway), and the
* absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`.
*/
@Serializable
internal data class RenewResponseDto(
val cert: String,
val caChain: List<String> = emptyList(),
val notBefore: String? = null,
val notAfter: String? = null,
val renewAfter: String? = null,
)
@Serializable
internal data class ErrorDto(val error: String? = null)

View File

@@ -0,0 +1,150 @@
package wang.yaojia.webterm.api.enroll
import java.time.Duration
import java.time.Instant
/**
* Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue
* of iOS `DeviceRenewalState`.
*
* [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and
* [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the
* non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves
* AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log.
*/
public data class DeviceRenewalState(
/** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */
val deviceId: String,
val notAfter: Instant?,
val renewAfter: Instant?,
) {
/**
* Is the leaf due for renewal as of [now]? A missing [renewAfter] NEVER triggers (fail-safe — the
* TLS stack is the real gate; the scheduler only pre-empts expiry). Mirrors
* [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`.
*/
public fun isRenewalDue(now: Instant): Boolean {
val due = renewAfter ?: return false
return !now.isBefore(due) // now >= renewAfter
}
}
/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */
public enum class RotationDecision {
/** Nothing to do: the renew window has not opened (the cheap, common case). */
NOT_DUE,
/** An attempt IS due but the previous one failed too recently — wait, do not hammer. */
BACKING_OFF,
/** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */
RENEW,
/**
* EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance,
* so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`).
*/
RECOVER,
/** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */
RE_ENROLL_REQUIRED,
}
/**
* The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
* `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers
* renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit
* test rather than a device observation.
*
* Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone
* track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded):
* - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator
* forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal.
* - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever
* (the host-track agent logged the same failure 6380 times before this rule existed).
*/
public object RotationPolicy {
/**
* The control plane issues `renewAfter = notBefore + 2/3 · lifetime`
* (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the
* fraction as exact integer duration math so [renewAfterFor] lands on the same instant.
*/
private const val RENEW_FRACTION_NUMERATOR: Long = 2L
private const val RENEW_FRACTION_DENOMINATOR: Long = 3L
/**
* How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover`
* (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking
* outside it is pointless: the server answers 401.
*/
public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30)
/**
* Minimum gap after a FAILED attempt before another is made. A pass runs on every app
* foreground, and the control plane rate-limits renewals to 30/hour per identity, so an
* un-throttled retry converts one transient failure into a 429 storm.
*/
public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15)
/**
* Derive the advisory renew instant from the leaf's own validity window.
*
* This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and
* `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns
* `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and
* never recomputed it would rotate exactly once and then report "not due" until the cert died.
*
* Returns null when either bound is unknown or the window is not a lifetime (notAfter <=
* notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as
* never-due rather than inventing a past instant that would renew-storm.
*/
public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? {
if (notBefore == null || notAfter == null) return null
if (!notBefore.isBefore(notAfter)) return null
val lifetime = Duration.between(notBefore, notAfter)
return notBefore.plus(
lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR),
)
}
/**
* The whole decision table, in priority order:
* 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it
* outranks the backoff because no amount of waiting can help),
* 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] →
* RENEW, else NOT_DUE,
* 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle),
* 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF].
*
* @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded).
*/
public fun decide(
state: DeviceRenewalState,
now: Instant,
lastFailureAt: Instant? = null,
expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE,
failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF,
): RotationDecision {
require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" }
require(!failureBackoff.isNegative) { "failureBackoff must not be negative" }
val notAfter = state.notAfter
// Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this
// strictly stronger than "expired", so it needs no separate expiry guard.
if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) {
return RotationDecision.RE_ENROLL_REQUIRED
}
val isExpired = notAfter != null && now.isAfter(notAfter)
val desired = when {
isExpired -> RotationDecision.RECOVER
state.isRenewalDue(now) -> RotationDecision.RENEW
else -> RotationDecision.NOT_DUE
}
if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE
if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) {
return RotationDecision.BACKING_OFF
}
return desired
}
}

View File

@@ -42,4 +42,9 @@ public data class LiveSessionInfo(
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
val lastOutputAt: Long? = null,
/** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge
* renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when
* the queue is empty; the badge shows only for a positive value. Decoded tolerantly
* ([LossyIntSerializer]) so a garbled value can never hide a running session. */
@Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null,
)

View File

@@ -0,0 +1,77 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next
* time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`).
*
* A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and
* later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it —
* the only transformation is the server appending `\r` when the caller passed `appendEnter = true`.
*/
/**
* `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts
* (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue
* instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`).
*/
@Serializable
public data class QueueSnapshot(
/** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for
* rendering and [length] only as the badge number the server itself broadcasts. */
val length: Int = 0,
/** The queued prompts in FIFO order, verbatim. */
val items: List<String> = emptyList(),
)
/**
* Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for
* both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled]
* are simply unreachable for the DELETE, whose handler neither validates text nor consults the
* `QUEUE_ENABLED` kill-switch.
*
* Every variant maps to different UI behaviour, which is why they are typed rather than folded into a
* status code:
* - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge.
* - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first;
* the server never silently drops.
* - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client
* deliberately does NOT pre-validate size, which would invent a policy it cannot know).
* - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry.
* - [SessionGone] — 404, unknown or already-exited session.
* - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**;
* a retry loop just burns the same budget a real enqueue needs.
* - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may
* be `null` when the body is empty, e.g. the Origin guard's bare 403).
*/
public sealed interface QueueWriteOutcome {
/** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */
public data class Ok(val length: Int) : QueueWriteOutcome
/** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */
public data object Full : QueueWriteOutcome
/** 413 — the prompt exceeds the server's per-item byte cap. */
public data object TooLarge : QueueWriteOutcome
/** 503 — the queue feature is switched off on this host. */
public data object Disabled : QueueWriteOutcome
/** 404 — the session is unknown or has already exited. */
public data object SessionGone : QueueWriteOutcome
/** 429 — shared per-IP queue bucket. Do NOT auto-retry. */
public data object RateLimited : QueueWriteOutcome
/** Any other failure, with the server's inert message (null when unparseable/empty). */
public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome
}
/**
* Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it
* succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or
* `queue` WS frame re-establishes the true depth.
*/
internal fun decodeQueueDepth(bytes: ByteArray): Int =
LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0

View File

@@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
@@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
@@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
}
/**
* A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing
* the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g.
* `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of
* a badge. Required numeric fields deliberately keep the strict built-in behaviour.
*/
internal object LossyIntSerializer : KSerializer<Int?> {
private val delegate: KSerializer<Int?> = Int.serializer().nullable
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value)
override fun deserialize(decoder: Decoder): Int? {
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null
return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull
}
}
/**
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,

View File

@@ -11,6 +11,9 @@ import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.QueueSnapshot
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.api.models.decodeQueueDepth
import wang.yaojia.webterm.api.models.FetchResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
@@ -137,6 +140,19 @@ public class ApiClient(
}
}
/**
* `GET /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts,
* verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is
* gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue
* (claiming "nothing queued" when entries exist would mislead a cancel-all decision).
*/
public suspend fun queue(id: UUID): QueueSnapshot {
val response = perform(Endpoints.queue(id))
requireOk(response)
return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs {
@@ -182,6 +198,52 @@ public class ApiClient(
}
}
// ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ──────────────────────
/**
* `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY
* the next time Claude goes idle. Works with zero tabs attached, which is the whole point.
*
* [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag
* the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is
* rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked
* client-side (the byte cap is server config) — an over-cap prompt comes back as
* [QueueWriteOutcome.TooLarge].
*/
public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome {
if (text.isEmpty()) throw ApiClientError.QueueTextEmpty
return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter))
}
/**
* `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 →
* [QueueWriteOutcome.Ok] with depth 0.
*/
public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id))
/**
* Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one
* demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared
* `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are
* POST-specific — the DELETE handler cannot produce them.
*/
private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body))
HttpStatus.CONFLICT -> QueueWriteOutcome.Full
HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge
HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled
HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone
HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited
// `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with
// the git ones (400/403 here) — reused rather than duplicated.
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body))
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */

View File

@@ -44,6 +44,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s
* as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the
* [ProjectPathInvalid] precedent. */
public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")

View File

@@ -12,8 +12,11 @@ internal object HttpStatus {
const val BAD_REQUEST = 400
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val CONFLICT = 409
const val PAYLOAD_TOO_LARGE = 413
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
const val SERVICE_UNAVAILABLE = 503
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400

View File

@@ -12,10 +12,10 @@ import java.util.UUID
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
* pair (this client uses FCM, not APNs/VAPID).
*
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
* · `POST|DELETE /push/fcm-token`
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
* · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
* · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token`
*
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
@@ -70,6 +70,13 @@ internal object Endpoints {
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
/**
* `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the
* server stamps no Origin guard on it and neither may we.
*/
fun queue(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY)
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute =
ApiRoute(
@@ -175,6 +182,36 @@ internal object Endpoints {
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
/**
* `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json,
* 16 kb limit).
*
* [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string
* encoding is the only transformation, and it round-trips byte-exactly through `express.json`.
* Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the
* trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append
* one itself.
*/
fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute =
jsonBodyRoute(
HttpMethod.POST,
queuePath(id),
EnqueueBody.serializer(),
EnqueueBody(text, appendEnter),
)
/**
* `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike
* `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id`
* and clears the whole FIFO, so a body would be dead weight the server never parses.
*/
fun clearQueue(id: UUID): ApiRoute =
ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED)
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
private fun <T> jsonBodyRoute(
method: HttpMethod,
@@ -242,4 +279,7 @@ internal object Endpoints {
@kotlinx.serialization.Serializable
private data class FetchBody(val path: String)
@Serializable
private data class EnqueueBody(val text: String, val appendEnter: Boolean)
}

View File

@@ -253,6 +253,137 @@ class DeviceEnrollmentClientTest {
assertTrue(transport.recordedRequests.isEmpty())
}
@Test
fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest {
// THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT
// echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see
// control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required)
// made every silent renewal fail as MalformedResponse. The device id comes from the path we
// called, which is authoritative anyway.
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201,
body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
)
val result = client.renew("dev-9", byteArrayOf(0x02))
assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called")
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
assertNull(result.notBefore, "the renew 201 carries no notBefore")
assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it")
assertEquals(1, result.caChain.size)
}
// ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ──────────
@Test
fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
)
val expiredLeaf = byteArrayOf(0x30, 0x11)
val csr = byteArrayOf(0x30, 0x22)
val result = client.recover("dev-9", expiredLeaf, csr)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/device/dev-9/recover", request.url)
// Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate
// cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the
// lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature.
assertNull(request.headers["Authorization"], "recovery carries no bearer")
val obj = bodyObject(request)
assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}")
assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content)
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
assertEquals("dev-9", result.deviceId)
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
}
@Test
fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201,
body = """{"cert":"MAEC","caChain":[]}""".toByteArray(),
)
client.recover("a/b", byteArrayOf(1), byteArrayOf(2))
assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url)
}
@Test
fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest {
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(),
)
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(),
)
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(),
)
assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed")
}
@Test
fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest {
// Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised
// error must carry the STATUS + the server's `error` code only — never the submitted cert or
// CSR bytes, which would put credential material into a crash log.
val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33)
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401,
body = """{"error":"rejected"}""".toByteArray(),
)
val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
val rendered = "${error!!.message} $error"
assertFalse(
rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)),
"the raised error must not carry the certificate bytes",
)
assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering")
}
@Test
fun recoverSurfacesA403ForARevokedDevice() = runTest {
// Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim.
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403,
body = """{"error":"rejected"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.Http(403, "rejected"),
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
)
}
@Test
fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
body = """{"cert":"@@not-base64@@"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.MalformedResponse,
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
)
}
@Test
fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest {
// The rotation driver must renew against the SAME control plane the device enrolled with; the
// enroller persists this alongside the enrollment record.
assertEquals(BASE, client.baseUrl)
assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed")
}
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
@Test

View File

@@ -0,0 +1,228 @@
package wang.yaojia.webterm.api.enroll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Duration
import java.time.Instant
/**
* The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
* `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no
* keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds
* the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the
* terminal beyond-grace state.
*/
class RotationPolicyTest {
private companion object {
val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z")
val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z")
// notBefore + 2/3 · 61d lifetime — the value the control plane itself computes.
val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z")
fun state(
notAfter: Instant? = NOT_AFTER,
renewAfter: Instant? = RENEW_AFTER,
): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter)
}
// ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ──
@Test
fun renewAfterIsTwoThirdsThroughTheLeafLifetime() {
// The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts
// DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone,
// because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}`
// ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever.
assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER))
}
@Test
fun renewAfterIsNullWhenEitherBoundIsUnknown() {
assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER))
assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null))
}
@Test
fun renewAfterIsNullForANonsensicalWindow() {
// notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS
// stack is the real gate) instead of inventing a past instant that would renew-storm.
assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE))
}
// ── the wait branch ────────────────────────────────────────────────────────────────────────
@Test
fun aLeafInsideItsRenewWindowIsNotDue() {
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)),
)
}
@Test
fun aMissingRenewAfterNeverTriggers() {
// iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate
// and the scheduler only pre-empts expiry.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)),
)
assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1)))
}
@Test
fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() {
// A cert whose expiry could not be read must not be treated as expired (that would push a
// working device into recovery); it is judged solely on the advisory renew timing.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)),
)
assertEquals(
RotationDecision.RENEW,
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER),
)
}
// ── the renew branch ───────────────────────────────────────────────────────────────────────
@Test
fun renewFiresAtTheRenewAfterBoundary() {
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER))
assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)")
}
@Test
fun renewStillFiresAtTheLastInstantBeforeExpiry() {
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER))
}
// ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ───────────────
@Test
fun anExpiredLeafInsideTheGraceWindowRecovers() {
// mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so
// the only route left is the plain-HTTPS `/device/:id/recover`.
assertEquals(
RotationDecision.RECOVER,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))),
)
}
@Test
fun recoveryIsStillAvailableAtTheExactGraceBoundary() {
val grace = Duration.ofDays(30)
assertEquals(
RotationDecision.RECOVER,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace),
)
}
@Test
fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() {
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))),
)
}
@Test
fun aZeroGraceDisablesRecoveryEntirely() {
// Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e).
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(
state(),
now = NOT_AFTER.plusMillis(1),
expiredRecoveryGrace = Duration.ZERO,
),
)
}
@Test
fun theTerminalStateWinsOverTheFailureBackoff() {
// Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a
// backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode
// the agent's 6380 identical warnings came from).
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(
state(),
now = NOT_AFTER.plus(Duration.ofDays(31)),
lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1),
),
)
}
// ── the backoff branch (a failed attempt must not hammer the control plane) ─────────────────
@Test
fun aRecentFailureBacksOffInsteadOfRetryingImmediately() {
// Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity,
// so an un-throttled retry turns one failure into a 429 storm.
val now = RENEW_AFTER.plus(Duration.ofMinutes(1))
assertEquals(
RotationDecision.BACKING_OFF,
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))),
)
}
@Test
fun theBackoffExpiresAndTheRenewIsRetried() {
val now = RENEW_AFTER.plus(Duration.ofHours(1))
assertEquals(
RotationDecision.RENEW,
RotationPolicy.decide(
state(),
now = now,
lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF),
),
)
}
@Test
fun aRecentFailureAlsoThrottlesTheRecoveryPath() {
val now = NOT_AFTER.plus(Duration.ofDays(8))
assertEquals(
RotationDecision.BACKING_OFF,
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)),
)
}
@Test
fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() {
// NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(
state(),
now = RENEW_AFTER.minusSeconds(1),
lastFailureAt = RENEW_AFTER.minusSeconds(2),
),
)
}
// ── boundary validation ────────────────────────────────────────────────────────────────────
@Test
fun negativeDurationsAreRejectedAtTheBoundary() {
assertThrows(IllegalArgumentException::class.java) {
RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1))
}
assertThrows(IllegalArgumentException::class.java) {
RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1))
}
}
@Test
fun theStateCarriesNoCredentialInItsToString() {
// The rotation state is logged/surfaced; it must never be able to carry key or cert bytes.
val rendered = state().toString()
assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here")
assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering")
}
}

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
/**
* Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for
* the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown
* keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which
* are raw keyboard bytes, must come back verbatim.
*/
@DisplayName("W2 queue models — tolerant decode + verbatim items")
class PromptQueueTest {
private fun snapshot(json: String): QueueSnapshot? =
LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer())
@Test
fun `decodes length and items`() {
val s = snapshot("""{"length":2,"items":["first","second"]}""")!!
assertEquals(2, s.length)
assertEquals(listOf("first", "second"), s.items)
}
@Test
fun `queued items keep control characters and CJK verbatim`() {
//  + TAB + CR + CJK, exactly as the server stored the keystroke bytes.
val s = snapshot("""{"length":1,"items":["\t你好\r"]}""")!!
assertEquals("\t你好\r", s.items.single())
}
@Test
fun `an empty queue and unknown keys both decode`() {
val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!!
assertEquals(0, s.length)
assertTrue(s.items.isEmpty())
}
@Test
fun `missing fields fall back to an empty queue and a non-object body is null`() {
val s = snapshot("{}")!!
assertEquals(0, s.length)
assertTrue(s.items.isEmpty())
assertNull(snapshot("[]"))
assertNull(snapshot("garbage"))
}
// ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ─────────────────────
private val base =
"""{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24"""
private fun info(extra: String): LiveSessionInfo? =
LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer())
@Test
fun `queueLength decodes when present`() {
assertEquals(4, info(""","queueLength":4""")!!.queueLength)
}
@Test
fun `queueLength is null on a pre-W2 server that omits it`() {
assertNull(info("")!!.queueLength)
}
@Test
fun `a wrong-typed queueLength does not drop the whole session`() {
// A garbled value must not make a running session invisible — the field degrades to null.
assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength)
}
}

View File

@@ -0,0 +1,141 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* Status → outcome mapping for the W2 prompt queue, read off the server handlers
* (`src/server.ts` ~605/644/654):
*
* POST: 200 `{length}` → [QueueWriteOutcome.Ok] · 409 `{error:"full"}` → `Full` ·
* 413 → `TooLarge` · 503 `{error:"queue disabled"}` → `Disabled` ·
* 404 `{error:"unknown"|"exited"}` → `SessionGone` · 429 (empty) → `RateLimited` ·
* 400/403/anything else → `Rejected(status, safe error)`.
* DELETE: 200 `{length:0}` → `Ok(0)` · 404 → `SessionGone` · 403 → `Rejected`.
* GET: 200 → snapshot · 404 → [ApiClientError.SessionNotFound].
*
* 429 comes from the shared `QUEUE_RATE_MAX = 20`/min/IP bucket and is a DISTINCT outcome precisely
* so no caller auto-retries into it (the `GitWriteOutcome.RateLimited` precedent).
*/
@DisplayName("W2 queue — status mapping")
class ApiClientQueueTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private val queueUrl = "$BASE/live-sessions/$ID/queue"
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
private fun queuePost(status: Int, body: String = "") =
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, status = status, body = body.toByteArray())
private fun queueDelete(status: Int, body: String = "") =
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, status = status, body = body.toByteArray())
private suspend fun enqueue(text: String = "do the thing") = client.enqueueFollowup(ID, text, appendEnter = true)
// ── POST ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `a 200 yields Ok carrying the new depth`() = runTest {
queuePost(200, """{"length":3}""")
assertEquals(QueueWriteOutcome.Ok(3), enqueue())
}
@Test
fun `a 200 with a garbled body still succeeds with an unknown depth of zero`() = runTest {
queuePost(200, "not json")
assertEquals(QueueWriteOutcome.Ok(0), enqueue())
}
@Test
fun `409 full 413 too-large and 503 disabled are distinct typed outcomes`() = runTest {
queuePost(409, """{"error":"full"}""")
assertEquals(QueueWriteOutcome.Full, enqueue())
queuePost(413, """{"error":"text too large"}""")
assertEquals(QueueWriteOutcome.TooLarge, enqueue())
queuePost(503, """{"error":"queue disabled"}""")
assertEquals(QueueWriteOutcome.Disabled, enqueue())
}
@Test
fun `404 unknown or exited is SessionGone`() = runTest {
queuePost(404, """{"error":"unknown"}""")
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
queuePost(404, """{"error":"exited"}""")
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
}
@Test
fun `429 from the shared 20-per-minute bucket is RateLimited and nothing is retried`() = runTest {
queuePost(429)
assertEquals(QueueWriteOutcome.RateLimited, enqueue())
// Exactly ONE request went out — a rate-limited enqueue must never auto-retry.
assertEquals(1, transport.recordedRequests.size)
}
@Test
fun `400 and 403 surface Rejected with the server's safe message`() = runTest {
queuePost(400, """{"error":"text must be a non-empty string"}""")
assertEquals(QueueWriteOutcome.Rejected(400, "text must be a non-empty string"), enqueue())
// The Origin guard 403s with an EMPTY body — message degrades to null, never throws.
queuePost(403)
assertEquals(QueueWriteOutcome.Rejected(403, null), enqueue())
}
@Test
fun `an odd non-error status is UnexpectedStatus`() = runTest {
queuePost(302)
assertTrue(errorOf { enqueue() } is ApiClientError.UnexpectedStatus)
}
@Test
fun `an empty prompt is rejected before any network IO`() = runTest {
assertEquals(ApiClientError.QueueTextEmpty, errorOf { client.enqueueFollowup(ID, "", appendEnter = true) })
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty prompt")
}
// ── DELETE ────────────────────────────────────────────────────────────────────────────────
@Test
fun `clearQueue maps 200 404 403 and 429`() = runTest {
queueDelete(200, """{"length":0}""")
assertEquals(QueueWriteOutcome.Ok(0), client.clearQueue(ID))
queueDelete(404)
assertEquals(QueueWriteOutcome.SessionGone, client.clearQueue(ID))
queueDelete(403)
assertEquals(QueueWriteOutcome.Rejected(403, null), client.clearQueue(ID))
queueDelete(429)
assertEquals(QueueWriteOutcome.RateLimited, client.clearQueue(ID))
}
// ── GET ───────────────────────────────────────────────────────────────────────────────────
@Test
fun `queue maps 404 to SessionNotFound and a garbled 200 to InvalidResponseBody`() = runTest {
transport.queueSuccess(url = queueUrl, status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.queue(ID) })
transport.queueSuccess(url = queueUrl, body = "[]".toByteArray())
assertEquals(ApiClientError.InvalidResponseBody, errorOf { client.queue(ID) })
}
}

View File

@@ -0,0 +1,162 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the W2 prompt-queue trio
* (`src/server.ts` ~605/644/654):
*
* - `GET /live-sessions/:id/queue` — read-only ⇒ MUST NOT carry Origin;
* - `POST /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin + JSON body;
* - `DELETE /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin, and (unlike
* `DELETE /projects/worktree`, the precedent that DOES carry a body) MUST NOT carry a body — the
* server handler reads nothing but `:id` and clears the whole queue.
*
* A route reclassified read↔write turns this red instead of silently shipping either a CSWSH hole or a
* write the server 403s.
*
* The enqueued text is RAW KEYBOARD INPUT for a shell (invariant #9): it must survive the JSON body
* **verbatim** — no trim, no escape-rewrite, no Unicode normalisation, and no client-side `\r`
* (`appendEnter` is a flag the SERVER materialises).
*/
@DisplayName("W2 queue routes — shape, Origin policy, verbatim body")
class QueueRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private val queueUrl = "$BASE/live-sessions/$ID/queue"
private fun last(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(r: HttpRequest) =
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
private fun assertReadOnly(r: HttpRequest) =
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
/** Parse the recorded request body back out of JSON — proves round-trip byte-exactness without
* pinning any particular escaping strategy. */
private fun bodyText(r: HttpRequest): String? =
Json.parseToJsonElement(r.body!!.decodeToString()).jsonObject["text"]?.jsonPrimitive?.content
// ── GET — read-only, no Origin ────────────────────────────────────────────────────────────
@Test
fun `queue is a read-only GET with no Origin and no body`() = runTest {
transport.queueSuccess(url = queueUrl, body = """{"length":2,"items":["a","b"]}""".toByteArray())
val snapshot = client.queue(ID)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertEquals(queueUrl, r.url)
assertReadOnly(r)
assertNull(r.body, "a GET must not carry a body")
assertEquals(2, snapshot.length)
assertEquals(listOf("a", "b"), snapshot.items)
}
// ── POST — guarded, JSON body ─────────────────────────────────────────────────────────────
@Test
fun `enqueueFollowup is a guarded POST with a text-appendEnter body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, "run the tests", appendEnter = true)
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals(queueUrl, r.url)
assertGuarded(r)
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
assertEquals("""{"text":"run the tests","appendEnter":true}""", r.body?.decodeToString())
}
@Test
fun `appendEnter false is sent verbatim and the client never appends CR itself`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, "no enter", appendEnter = false)
assertEquals("""{"text":"no enter","appendEnter":false}""", last().body?.decodeToString())
assertEquals("no enter", bodyText(last()))
}
@Test
fun `control characters and CJK survive the body byte-exactly`() = runTest {
// ESC[A (up-arrow), a literal TAB, ETX (Ctrl-C), CR, a stray NUL, CJK, an emoji, and
// deliberate surrounding whitespace that must NOT be trimmed.
val raw = " \u001B[A\t\u0003 \u0000 \u4F60\u597D\uFF0C\u4E16\u754C \uD83C\uDF0F caf\u00E9\r\n "
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, raw, appendEnter = false)
assertEquals(raw, bodyText(last()), "queued prompt bytes must travel verbatim (invariant #9)")
}
// ── DELETE — guarded, and (unlike DELETE /projects/worktree) body-less ────────────────────
@Test
fun `clearQueue is a guarded DELETE with NO body`() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
client.clearQueue(ID)
val r = last()
assertEquals(HttpMethod.DELETE, r.method)
assertEquals(queueUrl, r.url)
assertGuarded(r)
assertNull(r.body, "DELETE /live-sessions/:id/queue takes no body (server reads only :id)")
assertFalse(r.headers.containsKey(HeaderName.CONTENT_TYPE), "no body ⇒ no Content-Type")
}
// ── the batch invariant ───────────────────────────────────────────────────────────────────
@Test
fun `only the two writes carry Origin (batch invariant)`() = runTest {
transport.queueSuccess(url = queueUrl, body = """{"length":0,"items":[]}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
client.queue(ID)
client.enqueueFollowup(ID, "x", appendEnter = true)
client.clearQueue(ID)
assertReadOnly(transport.recordedRequests[0])
assertGuarded(transport.recordedRequests[1])
assertGuarded(transport.recordedRequests[2])
assertNotNull(transport.recordedRequests[1].body)
}
@Test
fun `session id is serialized lowercase in the path`() = runTest {
val upper = UUID.fromString("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")
val url = "$BASE/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"
transport.queueSuccess(url = url, body = """{"length":0,"items":[]}""".toByteArray())
client.queue(upper)
assertTrue(last().url.endsWith("/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"))
}
}