Merge ios-completion: device builds unblocked, access token on both clients, P2 wave

Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 deletions

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.api.routes.Endpoints
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
*/
public sealed interface AuthProbeResult {
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
public data object Accepted : AuthProbeResult
/**
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
*/
public data object AuthDisabled : AuthProbeResult
/** **401** — wrong token. UI: "访问令牌不正确". */
public data object InvalidToken : AuthProbeResult
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
public data object RateLimited : AuthProbeResult
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
public data object Malformed : AuthProbeResult
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
public data class Unexpected(val status: Int) : AuthProbeResult
}
/**
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
*
* Transport-level failures propagate unwrapped so the caller can classify them with
* [PairingError.classify] (same convention as [runPairingProbe]).
*
* Never logs the token; the token appears only in the JSON body, never in the URL.
*/
public suspend fun probeAccessToken(
endpoint: HostEndpoint,
http: HttpTransport,
token: String,
): AuthProbeResult {
// Boundary validation first: a token outside the server's charset/length can never be correct, and
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
val response = http.send(request)
return classifyAuthResponse(response)
}
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
HTTP_NO_CONTENT ->
// The ONE discriminator between "token correct" and "this host has no auth at all".
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
else -> AuthProbeResult.Unexpected(response.status)
}
/**
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
* nothing about our token.
*/
private fun HttpResponse.carriesAuthCookie(): Boolean =
headers.entries.any { (name, value) ->
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
}
/**
* Sentinel status for "the request could not even be built" — unreachable for a validated
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
*/
private const val UNBUILDABLE_REQUEST = 0
private const val SET_COOKIE_HEADER = "Set-Cookie"
private const val HTTP_NO_CONTENT = 204
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_TOO_MANY_REQUESTS = 429

View File

@@ -52,6 +52,17 @@ public sealed interface PairingError {
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError
/**
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
*
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
*/
public data object AccessTokenRequired : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError

View File

@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
/**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
http: HttpTransport,
ws: TermTransport,
timeout: Duration?,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
?: PairingProbeResult.Failure(PairingError.Timeout)
}
@@ -75,10 +79,16 @@ private suspend fun performProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
tokens: AccessTokenSource,
): PairingProbeResult {
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
probeReachability(endpoint, http)?.let { return it }
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
// wants an access token we do not have.
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected.
@@ -105,7 +115,7 @@ private suspend fun performProbe(
return try {
when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
}
} finally {
withContext(NonCancellable) { runCatching { connection.close() } }
@@ -120,14 +130,20 @@ private suspend fun performProbe(
private suspend fun probeReachability(
endpoint: HostEndpoint,
http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult.Failure? {
val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
if (response.status == HTTP_UNAUTHORIZED) {
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
sessionId: String,
endpoint: HostEndpoint,
http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult {
val response = try {
http.send(
HttpRequest(
method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
),
)
} catch (cancel: CancellationException) {
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
}
return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
)
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
return "$scheme://$host$portPart"
}
/**
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
* derived from the single point ([AuthCookie]), never hand-assembled.
*/
private fun authHeaders(token: String?): Map<String, String> =
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404

View File

@@ -24,6 +24,7 @@ import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
@@ -41,10 +42,18 @@ import java.util.UUID
*
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
*
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
* an unauthenticated LAN host behaves exactly as before. A **401** becomes the typed
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
*/
public class ApiClient(
public val endpoint: HostEndpoint,
private val http: HttpTransport,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) {
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
@@ -343,9 +352,27 @@ public class ApiClient(
// ── Internals ──────────────────────────────────────────────────────────────────────────
/**
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
* status mapping — otherwise a 401 on a read route would degrade into a generic status error and
* the UI would never learn to ask for the token (ios-completion §1.1).
*
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
* family): there a 401 is the server classifying a HOST-side git credential failure
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
*/
private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
return http.send(request)
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
?: throw ApiClientError.InvalidRequest
val response = http.send(request)
if (response.status == HttpStatus.UNAUTHORIZED &&
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
) {
throw ApiClientError.Unauthorized
}
return response
}
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */

View File

@@ -20,6 +20,15 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/**
* **401** from a route whose 401 can only be the access-token gate (RO or guarded): this host has
* `WEBTERM_TOKEN` set and our request carried no cookie / a wrong one (ios-completion §1.1).
* Distinct from [Forbidden] (that is the Origin guard) — the UI must send the user to enter or fix
* the host's access token. The git-write family is excluded (it defines its own 401 — see
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
*/
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
/** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
@@ -10,6 +11,7 @@ internal object HttpStatus {
const val OK = 200
const val NO_CONTENT = 204
const val BAD_REQUEST = 400
const val UNAUTHORIZED = 401
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val CONFLICT = 409
@@ -27,6 +29,12 @@ internal object HttpStatus {
internal object HeaderName {
const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type"
/**
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
*/
const val ACCEPT = "Accept"
}
internal object ContentType {
@@ -47,6 +55,23 @@ internal enum class OriginPolicy {
GUARDED,
}
/**
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
* exception is visible instead of hidden in a call site. The Android analogue of iOS
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
*/
internal enum class UnauthorizedPolicy {
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
ACCESS_TOKEN_GATE,
/**
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
* family, where the server classifies a HOST-side git credential failure as
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
*/
ROUTE_DEFINED,
}
/**
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
@@ -58,19 +83,37 @@ internal class ApiRoute(
val originPolicy: OriginPolicy,
val body: ByteArray? = null,
val percentEncodedQuery: String? = null,
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
val accept: String? = null,
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
) {
/**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
*
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
* ones included, because the server's access-token gate sits in front of the whole app. A null
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
* The token is secret material: it is only ever placed in this header — never in [path], never in
* [percentEncodedQuery], never logged.
*/
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
val headers = LinkedHashMap<String, String>()
if (originPolicy == OriginPolicy.GUARDED) {
headers[HeaderName.ORIGIN] = endpoint.originHeader
}
if (accessToken != null) {
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
}
if (accept != null) {
headers[HeaderName.ACCEPT] = accept
}
if (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
}

View File

@@ -134,7 +134,40 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
/**
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
*
* Two non-obvious requirements, both server-driven (`src/server.ts`):
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
* auth cookie (the token is in the body — this IS the login).
*
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
*/
fun auth(token: String): ApiRoute {
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
return ApiRoute(
HttpMethod.POST,
AUTH_PATH,
OriginPolicy.GUARDED,
body = body,
accept = ContentType.JSON,
)
}
private const val AUTH_PATH = "/auth"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
//
// Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in
// `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and
// `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so
// they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface
// as a worktree failure instead of the token flow.
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
@@ -162,11 +195,11 @@ internal object Endpoints {
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
gitWriteRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/**
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
@@ -176,11 +209,11 @@ internal object Endpoints {
* It is NOT a pull: no working tree, no index, no merge.
*/
fun gitFetch(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
@@ -212,17 +245,48 @@ internal object Endpoints {
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]). */
/**
* Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]).
*
* The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route
* whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree
* trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute].
*/
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
return ApiRoute(
method,
path,
OriginPolicy.GUARDED,
body = body,
unauthorizedPolicy = unauthorizedPolicy,
)
}
/**
* Build a GUARDED **git-ops** route with a `ModelJson`-encoded JSON body (Origin stamped in
* [ApiRoute]).
*
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
* must display verbatim instead of the access-token copy (mirrors iOS `gitOpsRoute`).
*
* Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`.
* The worktree trio must NOT be built here (F5): its handler has no 401 of its own.
*/
private fun <T> gitWriteRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED)
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
private const val GIT_LOG_MAX = 50
@@ -259,6 +323,10 @@ internal object Endpoints {
@Serializable
private data class FcmTokenBody(val token: String)
/** `POST /auth` body. Holds secret material — never log a request built from it. */
@Serializable
private data class AuthBody(val token: String)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.io.IOException
/**
* B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1).
*
* The four distinct server outcomes must stay distinguishable:
* | 204 **with** `Set-Cookie` | token correct → persist it |
* | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated |
* | 401 | the token is wrong → UI says "令牌不正确" |
* | 429 | rate-limited (10/min/IP) → UI says "尝试过多" |
*
* Plus the request shape the server needs: JSON body `{"token":"<t>"}` and an `Accept` that does NOT
* contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302
* instead of 204/401).
*/
class AuthProbeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun queueAuth(status: Int, headers: Map<String, String> = emptyMap()) {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers)
}
private val setCookieHeader = mapOf(
"Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict",
)
// ── the four frozen outcomes ────────────────────────────────────────────────────────────────
@Test
fun `204 with Set-Cookie means the token is correct`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `204 without Set-Cookie means the host has no auth configured`() = runTest {
queueAuth(204)
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)",
)
}
@Test
fun `401 means the token is wrong`() = runTest {
queueAuth(401)
assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `429 means rate-limited`() = runTest {
queueAuth(429)
assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `any other status is surfaced as Unexpected with the code`() = runTest {
queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success
assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN))
}
// ── request shape ───────────────────────────────────────────────────────────────────────────
@Test
fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest {
queueAuth(204, setCookieHeader)
probeAccessToken(endpoint, transport, TOKEN)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth", request.url)
assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString())
assertEquals("application/json", request.headers["Content-Type"])
val accept = request.headers["Accept"].orEmpty()
assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack")
assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401")
}
@Test
fun `the token never appears in the URL`() = runTest {
queueAuth(401)
probeAccessToken(endpoint, transport, TOKEN)
assertFalse(
transport.recordedRequests.single().url.contains(TOKEN),
"a secret must never travel in a URL (query strings land in logs/history)",
)
}
// ── boundary validation + transport failure ─────────────────────────────────────────────────
@Test
fun `a malformed token is rejected before any network IO`() = runTest {
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short"))
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here"))
assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network")
}
@Test
fun `a whitespace-padded token is trimmed once and accepted`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n"))
assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString())
}
@Test
fun `a transport failure propagates so the caller can classify it`() = runTest {
transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused"))
val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull()
assertTrue(thrown is IOException, "network classification stays with PairingError.classify")
}
@Test
fun `a Set-Cookie header is recognised case-insensitively`() = runTest {
queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/"))
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest {
queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/"))
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"only a webterm_auth cookie proves the token was accepted",
)
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* B5 · the two-step pairing probe under an access-token-protected host:
* - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the
* hand-written `Cookie: webterm_auth=<t>`;
* - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT
* "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask
* for the token instead of sending the user on a wild goose chase;
* - with no token configured nothing changes (no `Cookie` header at all).
*/
class PairingProbeTokenTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
const val SERVER_ID = "11111111-1111-4111-8111-111111111111"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val http = FakeHttpTransport()
private val ws = FakeTermTransport()
private fun scriptHappyPath() {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
}
private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens)
@Test
fun `both HTTP legs of the probe carry the auth cookie`() = runTest {
scriptHappyPath()
val result = probe(AccessTokenSource { TOKEN })
assertEquals(PairingProbeResult.Success(endpoint), result)
assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE")
http.recordedRequests.forEach { request ->
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
request.headers[AuthCookie.HEADER_NAME],
"every probe request must present the token",
)
}
}
@Test
fun `with no token the probe requests are byte-identical to before the feature`() = runTest {
scriptHappyPath()
probe(AccessTokenSource.NONE)
http.recordedRequests.forEach { request ->
assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME))
}
}
@Test
fun `a 401 on the reachability leg asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
@Test
fun `a 401 on the guarded kill leg also asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
}

View File

@@ -0,0 +1,207 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1):
* - `Cookie: webterm_auth=<t>` is stamped on **EVERY** route — read-only GETs included — because the
* server's auth gate runs in front of the whole app, not just the guarded routes;
* - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the
* Origin-iff-guarded 铁律 is untouched);
* - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical);
* - a **401** on a route whose 401 can only be the gate is the typed [ApiClientError.Unauthorized],
* never a generic status error, so the UI can route the user to re-enter the token — while the
* git-write family, which defines its own 401 (`src/http/git-ops.ts:108`), keeps the server's
* message (E1 fix; see the rewritten push case below).
*/
class AccessTokenCookieTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun clientWithToken(): ApiClient =
ApiClient(endpoint, transport, AccessTokenSource { TOKEN })
private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
private fun assertCookie(index: Int) {
val headers = transport.recordedRequests[index].headers
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
headers[AuthCookie.HEADER_NAME],
"every request must carry the hand-written auth cookie",
)
}
@Test
fun `a read-only GET carries the cookie and still no Origin`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithToken().liveSessions()
assertCookie(0)
assertFalse(
transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN),
"the cookie is additive — a RO route must still never carry Origin",
)
}
@Test
fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
assertCookie(0)
assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN])
}
@Test
fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/detail?path=%2Frepo",
body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(),
)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray())
val client = clientWithToken()
client.projectDetail("/repo")
client.killSession(ID)
client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create())
assertEquals(3, transport.recordedRequests.size)
transport.recordedRequests.indices.forEach { assertCookie(it) }
}
@Test
fun `no configured token means no Cookie header at all`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithoutToken().liveSessions()
assertFalse(
transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME),
"an unauthenticated host must see a byte-identical request to before the feature",
)
}
@Test
fun `the token is re-read per request so a token stored later takes effect`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
var stored: String? = null
val client = ApiClient(endpoint, transport, AccessTokenSource { stored })
client.liveSessions()
stored = TOKEN
client.liveSessions()
assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME))
assertCookie(1)
}
@Test
fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val error = errorOf { clientWithoutToken().liveSessions() }
assertEquals(ApiClientError.Unauthorized, error)
assertTrue(
ApiClientError.Unauthorized.userMessage.isNotBlank(),
"the UI needs actionable copy for a missing token",
)
}
/**
* E1 · REWRITTEN (this case previously asserted the opposite and pinned a real defect).
*
* `POST /projects/git/push` defines its OWN 401: `src/http/git-ops.ts:108` classifies a HOST-side
* git credential failure ("could not read Username", "permission denied (publickey)", …) as
* `{status:401, error:"Push authentication required on the host."}`. Swallowing that into
* [ApiClientError.Unauthorized] tells the user their *access token* is broken and sends them to
* rotate a secret that is fine, while hiding the real cause. iOS gets this right via
* `unauthorizedPolicy: .routeDefined` (APIClient/GitWrite.swift), and plan §4 item 49 requires the
* distinction — so the route's own message must reach [GitWriteOutcome.Rejected] verbatim.
*/
@Test
fun `a 401 on git push is the host's own git-credential failure, surfaced verbatim`() = runTest {
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/projects/git/push",
status = 401,
body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray(),
)
val outcome = clientWithToken().gitPush("/repo")
assertEquals(GitWriteOutcome.Rejected(401, "Push authentication required on the host."), outcome)
}
@Test
fun `the route-defined 401 covers the whole git-ops family, not just push`() = runTest {
val body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray()
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", status = 401, body = body)
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", status = 401, body = body)
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/fetch", status = 401, body = body)
val client = clientWithToken()
assertEquals(401, (client.gitStage("/repo", listOf("a.txt"), true) as GitWriteOutcome.Rejected).status)
assertEquals(401, (client.gitCommit("/repo", "msg") as GitWriteOutcome.Rejected).status)
assertEquals(401, (client.gitFetch("/repo") as GitWriteOutcome.Rejected).status)
}
/**
* F5 · the worktree trio is NOT part of that family. `src/http/worktrees.ts` emits no 401 (403
* kill-switch / 400 / 404 / 500 only) and `src/server.ts:1182-1260` adds none, so the ONLY 401 those
* routes can ever see is the access-token gate. Reading it as a git rejection stranded the user on a
* "worktree failed" message instead of the token flow.
*/
@Test
fun `a 401 on a worktree write is the access-token gate, not a git rejection`() = runTest {
val gateBody = """{"error":"authentication required"}""".toByteArray()
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", status = 401, body = gateBody)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", status = 401, body = gateBody)
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/projects/worktree/prune",
status = 401,
body = gateBody,
)
val client = clientWithToken()
assertEquals(ApiClientError.Unauthorized, errorOf { client.createWorktree("/repo", "feat/x") })
assertEquals(ApiClientError.Unauthorized, errorOf { client.removeWorktree("/repo", "/repo/wt", false) })
assertEquals(ApiClientError.Unauthorized, errorOf { client.pruneWorktrees("/repo") })
}
@Test
fun `a git write with no server message still reports the status, never the token copy`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
val outcome = clientWithoutToken().gitPush("/repo")
assertEquals(GitWriteOutcome.Rejected(401, null), outcome)
}
}

View File

@@ -158,4 +158,50 @@ class GitRouteShapeTest {
client.gitPush("/r")
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
}
/**
* E1 (narrowed, F5) · The 401 split declared AT the route (mirrors iOS `UnauthorizedPolicy`).
*
* **Route-defined 401 = exactly the four `/projects/git/…` writes, and only because of the ONE
* server line that can produce it:** `src/http/git-ops.ts:108` turns a HOST-side git credential
* failure into `{status:401, error:"Push authentication required on the host."}`. That classifier is
* reached only from `stageFiles`/`commit`/`push`/`fetch`.
*
* **The worktree trio is NOT route-defined.** `createWorktree`/`removeWorktree`/`pruneWorktrees`
* land in `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500),
* and `src/server.ts:1182-1260` adds none. Pinning them ROUTE_DEFINED meant that on a token-gated
* host the GATE's 401 surfaced as a git rejection instead of the token flow.
*
* Every other 401 the server can emit belongs to the gate: `src/server.ts:425` (`/auth` invalid),
* `:472` (the gate itself) and `:1453`/`:1463` (the WS upgrade) — none of which is a route here.
*/
@Test
fun `route-defined 401 is exactly the four git-ops writes, everything else is the gate`() {
val gitOpsWrites = listOf(
Endpoints.gitStage("/r", listOf("f"), true),
Endpoints.gitCommit("/r", "m"),
Endpoints.gitPush("/r"),
Endpoints.gitFetch("/r"),
)
val gateRoutes = listOf(
// The worktree trio: guarded writes, but the server has no 401 of its own for them.
Endpoints.createWorktree("/r", "b", null),
Endpoints.removeWorktree("/r", "/r/x", false),
Endpoints.pruneWorktrees("/r"),
Endpoints.liveSessions(),
Endpoints.projects(),
Endpoints.projectLog("/r", null),
Endpoints.killSession(UUID.randomUUID()),
Endpoints.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()),
)
assertTrue(gitOpsWrites.all { it.unauthorizedPolicy == UnauthorizedPolicy.ROUTE_DEFINED })
gateRoutes.forEach {
assertEquals(
UnauthorizedPolicy.ACCESS_TOKEN_GATE,
it.unauthorizedPolicy,
"${it.method} ${it.path} has no server-side 401 of its own — a 401 there IS the token gate",
)
}
}
}