Rename setups and add provider reauthentication

This commit is contained in:
iris committed 2026-09-12 22:56:43 -04:00
1 parent e9a0f1b9da
commit 7d9df5d572
36 files changed
+1866 -684

No files matched your search

@@ -28,7 +28,7 @@ class ApiException(message: String, val status: Int? = null, cause: Throwable? =
Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
* Runs one request against the backend, with the pinned TLS machine, the bearer token, and the
* failure translation every call needs. [readBody] gets the connected, already-status-checked
* connection.
*
@@ -122,12 +122,12 @@ data class SessionSummary(
val id: String,
/**
* Id of the machine this session runs on. Only ever used to *address* that machine -- to pick
* this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a
* reader sees, and holding both invites showing the wrong one.
* this session's row out of the per-machine usage snapshots. Never shown; [machineName] is what
* a reader sees, and holding both invites showing the wrong one.
*/
val setup: String,
/** The machine's current label. This is the one to display; [setup] is never shown. */
val setupName: String,
val machine: String,
/** The machine's current label. This is the one to display; [machine] is never shown. */
val machineName: String,
val provider: String,
val title: String,
val model: String?,
@@ -238,10 +238,10 @@ data class SessionSummary(
private fun parseSession(session: JSONObject) =
SessionSummary(
id = session.getString("id"),
setup = session.getString("setup"),
machine = session.getString("machine"),
keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false),
ownTranscriptName = session.optString("ownTranscriptName").ifEmpty { null },
setupName = session.getString("setupName"),
machineName = session.getString("machineName"),
provider = session.getString("provider"),
title = session.getString("title"),
model = session.optString("model").ifEmpty { null },
@@ -328,7 +328,8 @@ fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: Li
) {}
}
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// What the server offers, so the spawn screen has no hardcoded lists: a machine added to the
// server's
// config.ron appears here with no app rebuild.
//
// One list rather than two. A provider only exists on a machine that has it installed, so offering
@@ -345,9 +346,9 @@ data class Provider(
* A machine, and what it can run. [address] is absent for the backend itself.
*
* [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that
* refers to a setup uses the id and everything a person reads uses the name.
* refers to a machine uses the id and everything a person reads uses the name.
*/
data class Setup(
data class Machine(
val id: String,
val name: String,
val address: String?,
@@ -366,16 +367,91 @@ private fun parseProvider(provider: JSONObject): Provider {
)
}
private fun parseSetup(setup: JSONObject) =
Setup(
id = setup.getString("id"),
name = setup.getString("name"),
address = setup.optString("address").ifEmpty { null },
providers = setup.getJSONArray("providers").mapObjects(::parseProvider),
private fun parseMachine(machine: JSONObject) =
Machine(
id = machine.getString("id"),
name = machine.getString("name"),
address = machine.optString("address").ifEmpty { null },
providers = machine.getJSONArray("providers").mapObjects(::parseProvider),
)
fun fetchSetups(settings: ServerSettings): List<Setup> =
requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) }
fun fetchMachines(settings: ServerSettings): List<Machine> =
requestFromServer(settings, "/machines") { it.jsonObjects(::parseMachine) }
/** One CLI-owned provider sign-in. The browser URL and pasted code are never persisted. */
data class ProviderLogin(
val attempt: String,
val state: String,
val authorizationUrl: String?,
val detail: String?,
)
private fun parseProviderLogin(login: JSONObject) =
ProviderLogin(
attempt = login.getString("attempt"),
state = login.getString("state"),
authorizationUrl = login.optString("authorizationUrl").ifEmpty { null },
detail = login.optString("detail").ifEmpty { null },
)
private fun providerLoginPath(machine: String, provider: String) =
"/machines/${machine.urlEncoded()}/providers/${provider.urlEncoded()}/auth"
fun startProviderLogin(
settings: ServerSettings,
machine: String,
provider: String,
): ProviderLogin =
requestFromServer(
settings,
providerLoginPath(machine, provider),
method = "POST",
readTimeoutMs = 25_000,
) {
parseProviderLogin(it.jsonObject())
}
fun fetchProviderLogin(
settings: ServerSettings,
machine: String,
provider: String,
attempt: String,
): ProviderLogin =
requestFromServer(
settings,
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}",
) {
parseProviderLogin(it.jsonObject())
}
fun submitProviderLoginCode(
settings: ServerSettings,
machine: String,
provider: String,
attempt: String,
code: String,
): ProviderLogin =
requestFromServer(
settings,
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}/code",
method = "POST",
jsonBody = JSONObject().put("code", code).toString(),
) {
parseProviderLogin(it.jsonObject())
}
fun cancelProviderLogin(
settings: ServerSettings,
machine: String,
provider: String,
attempt: String,
) {
requestFromServer(
settings,
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}",
method = "DELETE",
) {}
}
/**
* A Claude Code session already on a machine, which can be continued here.
@@ -426,7 +502,7 @@ data class Importable(
)
/**
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
* One frame of `GET /machines/{id}/importable/events`: an operation starting, finishing or failing.
*
* [operation] is only set by a start and [message] only by a failure -- the three states are every
* way an operation can be, and each carries exactly what that state knows.
@@ -461,8 +537,8 @@ fun parseImportableChange(payload: String): ImportableChange? =
* reading every transcript Claude Code has ever written: about four seconds against a gigabyte of
* them before the tunnel adds anything. A timeout is for a server that has stopped answering.
*/
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
fun fetchImportable(settings: ServerSettings, machine: String): List<Importable> =
requestFromServer(settings, "/machines/$machine/importable", readTimeoutMs = 60000) {
it.jsonObjects { session ->
Importable(
id = session.getString("id"),
@@ -515,10 +591,10 @@ private fun SshDetails.toJson() =
}
/** What a machine turns out to have, without saving anything. */
fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
fun probeMachine(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
requestFromServer(
settings,
"/setups/probe",
"/machines/probe",
method = "POST",
jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(),
readTimeoutMs = 40000,
@@ -526,10 +602,10 @@ fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
it.jsonObjects(::parseProvider)
}
fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
fun addMachine(settings: ServerSettings, name: String, ssh: SshDetails?): Machine =
requestFromServer(
settings,
"/setups",
"/machines",
method = "POST",
jsonBody =
JSONObject()
@@ -538,19 +614,19 @@ fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
.toString(),
readTimeoutMs = 40000,
) {
parseSetup(it.jsonObject())
parseMachine(it.jsonObject())
}
/** Renames a machine, and optionally asks it again what it has. */
fun updateSetup(
fun updateMachine(
settings: ServerSettings,
id: String,
name: String? = null,
rediscover: Boolean = false,
): Setup =
): Machine =
requestFromServer(
settings,
"/setups/${id.urlEncoded()}",
"/machines/${id.urlEncoded()}",
method = "PUT",
jsonBody =
JSONObject()
@@ -561,20 +637,20 @@ fun updateSetup(
.toString(),
readTimeoutMs = 40000,
) {
parseSetup(it.jsonObject())
parseMachine(it.jsonObject())
}
fun deleteSetup(settings: ServerSettings, id: String) {
requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {}
fun deleteMachine(settings: ServerSettings, id: String) {
requestFromServer(settings, "/machines/${id.urlEncoded()}", method = "DELETE") {}
}
/**
* Spawns a session and returns it as the list would show it. [setup] names the machine and
* Spawns a session and returns it as the list would show it. [machine] names the machine and
* [provider] one of the things that machine offers.
*/
fun spawnSession(
settings: ServerSettings,
setup: String,
machine: String,
provider: String,
title: String,
model: String? = null,
@@ -592,7 +668,7 @@ fun spawnSession(
method = "POST",
jsonBody =
JSONObject()
.put("setup", setup)
.put("machine", machine)
.put("provider", provider)
.put("title", title)
.apply {
@@ -705,7 +781,7 @@ fun uploadAttachment(
}
/**
* One entry of a directory on the machine a setup names.
* One entry of a directory on a configured machine.
*
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
* still says it is one. Neither is worked out here -- the machine answers both.
@@ -762,11 +838,11 @@ sealed class FileContent {
/** What a file is after a write, so the editor's precondition is fresh without a second read. */
data class Written(val size: Long, val modified: Long, val sha256: String)
/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */
fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
/** Everything in [path] on the machine [machine] names, and what [path] resolved to. */
fun fetchDir(settings: ServerSettings, machine: String, path: String): Listing =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}",
"/machines/${machine.urlEncoded()}/dir?path=${path.urlEncoded()}",
readTimeoutMs = 30000,
) { connection ->
val body = connection.jsonObject()
@@ -786,10 +862,10 @@ fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
}
/** One file's content, or which of the reasons there is none to show. */
fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent =
fun fetchFile(settings: ServerSettings, machine: String, path: String): FileContent =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
"/machines/${machine.urlEncoded()}/file?path=${path.urlEncoded()}",
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
// of it moves. Well clear of that rather than just above it.
readTimeoutMs = 60000,
@@ -826,14 +902,14 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
*/
fun writeFile(
settings: ServerSettings,
setup: String,
machine: String,
path: String,
content: String,
ifSha256: String,
): Written =
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
"/machines/${machine.urlEncoded()}/file",
method = "PUT",
jsonBody =
JSONObject()
@@ -848,10 +924,10 @@ fun writeFile(
}
/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */
fun createFile(settings: ServerSettings, setup: String, path: String) {
fun createFile(settings: ServerSettings, machine: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/file",
"/machines/${machine.urlEncoded()}/file",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
@@ -859,10 +935,10 @@ fun createFile(settings: ServerSettings, setup: String, path: String) {
}
/** Creates a directory, with the same refusal as [createFile]. */
fun createDir(settings: ServerSettings, setup: String, path: String) {
fun createDir(settings: ServerSettings, machine: String, path: String) {
requestFromServer(
settings,
"/setups/${setup.urlEncoded()}/dir",
"/machines/${machine.urlEncoded()}/dir",
method = "POST",
jsonBody = JSONObject().put("path", path).toString(),
readTimeoutMs = 30000,
@@ -893,22 +969,23 @@ data class UsageWindow(
data class UsageSnapshot(
val provider: String,
/** Stable id of the machine these numbers belong to. */
val setup: String,
val machine: String,
/** That machine's current label. */
val setupName: String,
val machineName: String,
/** Provider-specific billing pool, such as Codex's regular or Luna Reserve pool. */
val limitId: String?,
/** Provider-specific human-facing pool name, when supplied. */
val limitName: String?,
/**
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
* What came back: "ok", "notLoggedIn", "authenticating", "loginRequired", "unreachable" or
* "failed".
*
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
* machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
* a healthy setup read as broken.
* Named states rather than a flag, because the screen has to treat them differently.
* "notLoggedIn" is a machine somebody chose not to put an account on -- a fact, not a fault.
* Collapsing them made a healthy machine read as broken.
*/
val state: String,
/** Why, for the two states that are faults. Absent otherwise. */
/** Why, for states that have a useful explanation. Absent otherwise. */
val detail: String?,
val windows: List<UsageWindow>,
)
@@ -919,8 +996,8 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
connection.jsonObjects { snapshot ->
UsageSnapshot(
provider = snapshot.getString("provider"),
setup = snapshot.optString("setup"),
setupName = snapshot.optString("setupName"),
machine = snapshot.optString("machine"),
machineName = snapshot.optString("machineName"),
limitId = snapshot.optString("limitId").ifEmpty { null },
limitName = snapshot.optString("limitName").ifEmpty { null },
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would
@@ -1000,10 +1077,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
* One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
* a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
*/
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
fun deleteImportable(settings: ServerSettings, machine: String, sessionIds: List<String>) {
requestFromServer(
settings,
"/setups/$setup/importable/delete",
"/machines/$machine/importable/delete",
method = "POST",
jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(),
) {}
@@ -1019,7 +1096,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
*/
fun startImport(
settings: ServerSettings,
setup: String,
machine: String,
sessionIds: List<String>,
provider: String,
permissionMode: String? = null,
@@ -1034,7 +1111,7 @@ fun startImport(
}
requestFromServer(
settings,
"/setups/$setup/importable/import",
"/machines/$machine/importable/import",
method = "POST",
jsonBody = body.toString(),
) {}
@@ -1297,8 +1374,8 @@ private fun parseDownload(o: JSONObject) =
* offering the backend's would name files that are not there, turning a choice that cannot work
* into a session that fails when it tries to load one.
*/
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
fun fetchMachineModels(settings: ServerSettings, machineId: String): List<LocalModel> =
requestFromServer(settings, "/machines/${machineId.urlEncoded()}/models") { connection ->
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
LocalModel(
key = m.getString("key"),
@@ -1312,12 +1389,12 @@ fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel
/** The current model catalog for one CLI provider on the machine where it runs. */
fun fetchProviderModels(
settings: ServerSettings,
setupId: String,
machineId: String,
provider: String,
): List<String> =
requestFromServer(
settings,
"/setups/${setupId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
"/machines/${machineId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
) { connection ->
JSONArray(connection.inputStream.bufferedReader().readText()).strings()
}