feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
@@ -41,8 +41,9 @@ import java.util.UUID
|
||||
* **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** from any route becomes the typed
|
||||
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the 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,
|
||||
@@ -260,14 +261,23 @@ public class ApiClient(
|
||||
/**
|
||||
* 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 guarded git write would degrade a 401 into a generic `Rejected`
|
||||
* outcome and the UI would never learn to ask for the token (ios-completion §1.1).
|
||||
* 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, tokens.tokenFor(endpoint))
|
||||
?: throw ApiClientError.InvalidRequest
|
||||
val response = http.send(request)
|
||||
if (response.status == HttpStatus.UNAUTHORIZED) throw ApiClientError.Unauthorized
|
||||
if (response.status == HttpStatus.UNAUTHORIZED &&
|
||||
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
|
||||
) {
|
||||
throw ApiClientError.Unauthorized
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
|
||||
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
||||
|
||||
/**
|
||||
* **401** from ANY route (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.
|
||||
* **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("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
|
||||
|
||||
|
||||
@@ -52,6 +52,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
|
||||
@@ -65,6 +82,8 @@ internal class ApiRoute(
|
||||
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
|
||||
|
||||
@@ -179,7 +179,14 @@ internal object Endpoints {
|
||||
fun gitPush(path: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||
|
||||
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
|
||||
/**
|
||||
* Build a GUARDED git-write 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 `gitWriteRoute`).
|
||||
*/
|
||||
private fun <T> jsonBodyRoute(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
@@ -187,7 +194,13 @@ internal object Endpoints {
|
||||
value: T,
|
||||
): 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.ROUTE_DEFINED,
|
||||
)
|
||||
}
|
||||
|
||||
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
||||
|
||||
Reference in New Issue
Block a user