feat(android): access-token support — same frozen contract as iOS
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written Cookie header on every request and on the WS upgrade (no CookieJar, matching the frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401 upgrade as a terminal state with no reconnect loop.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ import wang.yaojia.webterm.api.models.UiConfig
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
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
|
||||
@@ -36,10 +37,17 @@ 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** from any route becomes the typed
|
||||
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token.
|
||||
*/
|
||||
public class ApiClient(
|
||||
public val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
) {
|
||||
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -249,9 +257,18 @@ 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 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).
|
||||
*/
|
||||
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) throw ApiClientError.Unauthorized
|
||||
return response
|
||||
}
|
||||
|
||||
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||
|
||||
@@ -20,6 +20,13 @@ 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 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.
|
||||
*/
|
||||
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
|
||||
|
||||
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||
|
||||
|
||||
@@ -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 TOO_MANY_REQUESTS = 429
|
||||
@@ -24,6 +26,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 {
|
||||
@@ -55,19 +63,35 @@ 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,
|
||||
) {
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -114,6 +114,33 @@ 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) ────────────────────────────────────────
|
||||
|
||||
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||
@@ -199,6 +226,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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user