From 7d9df5d5722aa981bd207849228296c533e386a0 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 12 Sep 2026 22:56:43 -0400 Subject: [PATCH] Rename setups and add provider reauthentication --- .claude/skills/ai-app-rigs/SKILL.md | 12 +- AGENTS.md | 10 +- EXPLORER.md | 26 +- PLAN.md | 86 +++- SUBAGENTS.md | 2 +- .../src/main/kotlin/com/example/aiapp/Api.kt | 207 +++++--- .../main/kotlin/com/example/aiapp/AppRoot.kt | 6 +- .../kotlin/com/example/aiapp/FilesScreen.kt | 36 +- .../kotlin/com/example/aiapp/ImportScreen.kt | 50 +- .../com/example/aiapp/ImportableStream.kt | 4 +- .../{SetupsScreen.kt => MachinesScreen.kt} | 106 ++-- .../kotlin/com/example/aiapp/MainScreen.kt | 4 +- .../com/example/aiapp/ProviderLoginDialog.kt | 215 ++++++++ .../com/example/aiapp/SessionListScreen.kt | 2 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 17 +- .../com/example/aiapp/SessionUsageBar.kt | 24 +- .../kotlin/com/example/aiapp/SpawnScreen.kt | 61 +-- .../kotlin/com/example/aiapp/UsageDialog.kt | 53 +- .../com/example/aiapp/SessionUsageTest.kt | 10 +- app/debug-transcript.sh | 4 +- app/run-android.sh | 2 +- app/ui-sandbox.sh | 23 +- server/src/config.rs | 116 +++-- server/src/files.rs | 4 +- server/src/{setups.rs => machines.rs} | 8 +- server/src/main.rs | 36 +- server/src/models.rs | 8 +- server/src/provider_auth.rs | 467 ++++++++++++++++++ server/src/resume.rs | 22 +- server/src/routes.rs | 373 +++++++++----- server/src/session/import.rs | 2 +- server/src/session/llama.rs | 2 +- server/src/session/mod.rs | 232 ++++----- server/src/session/pending.rs | 40 +- server/src/session/transport.rs | 10 +- server/src/usage.rs | 270 +++++++--- 36 files changed, 1866 insertions(+), 684 deletions(-) rename app/androidApp/src/main/kotlin/com/example/aiapp/{SetupsScreen.kt => MachinesScreen.kt} (82%) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ProviderLoginDialog.kt rename server/src/{setups.rs => machines.rs} (97%) create mode 100644 server/src/provider_auth.rs diff --git a/.claude/skills/ai-app-rigs/SKILL.md b/.claude/skills/ai-app-rigs/SKILL.md index d77a686..38a1d21 100644 --- a/.claude/skills/ai-app-rigs/SKILL.md +++ b/.claude/skills/ai-app-rigs/SKILL.md @@ -72,13 +72,15 @@ Each exists because something was invisible without it. `usage::Fixture`'s, since those are its states. With none set an echo session meters nothing, which is the ordinary case and draws no bar. - **A fake CLI exercises the process lifecycle without a token.** Point a - `claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and - `cat > /dev/null` — and it behaves the way the lifecycle code cares about: + `claude_cli` provider's `command` at a script that ordinarily runs + `cat > /dev/null` and it behaves the way the lifecycle code cares about: it holds the fifo open, records a real pid, writes nothing, and dies on a signal. So adopt, stop, restart and start are all drivable without a real `--resume` and without spending a turn on somebody's account. Reach for this when what is under test is *whether a process is running*, and for - `debug-transcript.sh` when it is *what the transcript draws*. + `debug-transcript.sh` when it is *what the transcript draws*. The sandbox's + version also handles `auth login`: it prints an inert Anthropic-shaped URL, + rejects any code except `sandbox-code`, and exits successfully for that one. - **`app/transcript-bench.sh`** is the standard scroll measurement: it opens the first session (or `-k` keeps the current screen), scrolls a fixed gesture loop, and prints the app's render report — the same one the in-app @@ -157,12 +159,12 @@ is how to tell the two apart in a hurry. There is no second machine, so **ssh this VM to itself**. That is set up too: the key is `~/.config/ai-app/ssh-self` (its public half is in `~/.ssh/authorized_keys`, labelled removable), and the real config carries a -setup called **"this vm over ssh"** — `bob@127.0.0.1` with that +machine called **"this vm over ssh"** — `bob@127.0.0.1` with that `identityFile` plus `options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]` so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the whole rig for "does a remote llama session work", since the far machine is -this one and the model file is the same file. For a throwaway setup of your +this one and the model file is the same file. For a throwaway machine of your own, point a provider's `command` at something harmless like `/bin/echo` rather than at `claude`: the transport is what is under test, the process exiting immediately is the signal, and it costs no tokens. The remote login diff --git a/AGENTS.md b/AGENTS.md index 81375b5..8c503a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Module-by-module intent is in PLAN.md's "Backend layout". - `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc comment is the HTTP table and the surface's source of truth. - **A llama.cpp session runs on whatever machine its setup names** (built + **A llama.cpp session runs on its configured machine** (built 2026-09-04, the last of phase 5): `Transport::reserve_port` returns the port the server binds *there* and the port that reaches it *here*, and `Launch::reaching` puts the `-L` tunnel on the connection already carrying @@ -43,7 +43,7 @@ Module-by-module intent is in PLAN.md's "Backend layout". because `llama-server` never reads the stdin whose closing ends a CLI and the same kill left it loaded on the far machine; the model is looked for on the machine that will serve it, so the spawn screen offers - `GET /setups/{id}/models` rather than the backend's own downloads; and the + `GET /machines/{id}/models` rather than the backend's own downloads; and the readiness poll watches the process as well as the port, since a model that will not load exits in a second and was being reported as "gave up after 300s". See PLAN.md's "Transport" and "llama-server management". @@ -53,7 +53,7 @@ Module-by-module intent is in PLAN.md's "Backend layout". protocol. - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs - (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE + (sessions, import, models, machines); `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and the Keystore-sealed token. - `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA @@ -64,7 +64,7 @@ Module-by-module intent is in PLAN.md's "Backend layout". build without it, since it is a path dependency, which is what keeps the two projects version-locked to the commit this repo pins. What deliberately did **not** move is the API surface and the config *schema*: routes, drivers, - sessions and setups are what makes this project itself. + sessions and machines are what makes this project itself. - `SUBAGENTS.md` — a session's subagents as transcripts of their own (`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt` and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the @@ -175,7 +175,7 @@ means here: - **`ai-server` belongs on the host in production.** That is where the LAN address the phone can reach is, and where WireGuard terminates. - `wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it + `wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it there with `sudo WG_ENDPOINT=`. - **The tunnel and the real phone can never terminate in the VM**, because nothing outside can open a connection into it. Phone bring-up is host work. diff --git a/EXPLORER.md b/EXPLORER.md index fff55e6..8871f76 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -14,7 +14,7 @@ AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` / ## What it is, in one paragraph A machine's filesystem, seen from the phone through the backend. The explorer -belongs to a **setup** (a machine), not to a session: a session only says +belongs to a **machine** (a machine), not to a session: a session only says where to start. Every operation — list, read, write, create — is one shell script run through `Transport`, exactly the way the import listing and the usage fetch already work, so the local and the ssh case are one @@ -25,16 +25,16 @@ message. The phone draws what came back. ### 1. Keyed on the machine, opened from the session -Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem +Routes live under `/machines/{id}/…`, beside `importable`, because a filesystem is a property of a machine. The session screen's folder button opens the -explorer with the session's setup and its `cwd`; a session with no `cwd` +explorer with the session's machine and its `cwd`; a session with no `cwd` opens at the machine's home, which the **machine** resolves (`cd` with no argument and `pwd -P`), never a path the phone guessed. Nothing in the -explorer knows what a session is, so a later entry point from the setups tab +explorer knows what a session is, so a later entry point from the machines tab is one more caller and no new code. Rejected: routes under `/sessions/{id}/`. The session would be a detour to -find the setup, and "browse this machine" from anywhere else would need a +find the machine, and "browse this machine" from anywhere else would need a session to exist first. ### 2. One shell script per operation, over `Transport`, on both transports @@ -68,7 +68,7 @@ Elsewhere the phone picks an **id** and the server resolves which file it names, so an enrolled token cannot become "read me an arbitrary file". The explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's Security section in these terms: the token already gates spawning a -bypass-permissions agent in any directory on any machine a setup names, and +bypass-permissions agent in any directory on any configured machine, and that agent can already read and write every file its user can. The explorer is a shorter path to authority the token already holds, not new authority. The import rule stands where it is, because there a path was unnecessary and @@ -89,7 +89,7 @@ is. The phone never resolves `..` itself. ### 5. A read is capped and typed, and every state it can be in has a word -`GET /setups/{id}/file` answers with one of `text` (content, size, mtime, +`GET /machines/{id}/file` answers with one of `text` (content, size, mtime, sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over `FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking at), or the machine's own error. @@ -102,7 +102,7 @@ what it is. ### 6. A write is conditional on what the reader saw -`PUT /setups/{id}/file` carries the sha256 the read reported. The script +`PUT /machines/{id}/file` carries the sha256 the read reported. The script compares it against the file as it is now and exits distinctly if it differs; the server answers **409**. Agents edit files while people read them; this is the common case, not the exotic one, and silently overwriting an agent's edit @@ -123,9 +123,9 @@ precondition is fresh without a second read. ### 7. Create refuses to overwrite -`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a +`POST /machines/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a name that exists fails with the shell's own message rather than truncating -somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same +somebody's file; `POST /machines/{id}/dir` is `mkdir --` with the same property. The modal names one thing in the current directory and has a switch for "directory"; a created file opens straight into edit mode, because an empty file is not something to look at. @@ -275,14 +275,14 @@ already are. **Moving it is where the no-coordinate-taps rule got enforced** ### 14. File links in a session open in the explorer A markdown destination that is an absolute path or a local `file:` URI opens that document in the -session's explorer, on the session's setup. A trailing editor line and optional column are removed; +session's explorer, on the session's machine. A trailing editor line and optional column are removed; the viewer opens the file but does not yet scroll to a line. Web links, relative links and `file:` URIs naming another host keep their ordinary external behaviour. The distinction is deliberately narrow: a relative link might be a web reference, and the phone must not silently reinterpret it as a path on another machine. -The markdown link handler is provided around the session rather than taught about setups. That -keeps the renderer reusable and makes the explorer's existing setup target the one navigation path. +The markdown link handler is provided around the session rather than taught about machines. That +keeps the renderer reusable and makes the explorer's existing machine target the one navigation path. ## HTTP surface diff --git a/PLAN.md b/PLAN.md index eb317dd..5434aae 100644 --- a/PLAN.md +++ b/PLAN.md @@ -33,7 +33,7 @@ backend (Rust/Axum, desktop) │ ├─ LlamaDriver (llama-server over HTTP) │ └─ EchoDriver (the test rig) │ each driver's process is spawned through a Transport, - │ locally or as `ssh host …`, decided by the setup it names + │ locally or as `ssh host …`, decided by the configured machine ├─ usage.rs (provider usage meters, per machine) ├─ models.rs (HuggingFace browsing and GGUF downloads) ├─ files.rs (the file explorer's half of the backend) @@ -42,11 +42,22 @@ backend (Rust/Axum, desktop) ## Architecture -### Setups and providers (2026-08-28) +### Machines and providers (2026-08-28; terminology corrected 2026-09-12) -**A setup is a machine, and it carries the providers that machine has.** -Optional ssh details, plus the list of what can be run there. Spawning is -two choices in order: pick a setup, then pick one of its providers. +**A machine is an execution environment, and it carries the providers that +environment has.** It may be a physical computer, a VM, or an SSH target; +"host" is reserved for the address used to reach one. A session is therefore +the pair of a machine and one provider installed there. Optional ssh details, +plus the list of what can be run there. Spawning is two choices in order: pick +a machine, then pick one of its providers. + +The first implementation called this whole object a "setup", even though the +word was originally meant to name the machine/provider pair. The public model, +Machines tab, source names and HTTP surface now consistently say `machine` and +`/machines`. Existing RON accepts `setups` and a session's `setup` as read-only +aliases so an update cannot orphan configured environments or conversations; +the next write uses `machines` and `machine`. There is deliberately no legacy +HTTP alias: the server and APK are versioned together. This replaced an independent providers × hosts cross-product, because the two axes are not independent: a provider is only real on a machine where @@ -55,7 +66,7 @@ work — `claude-cli` on a machine with no `claude`, and every provider paired with a host the driver ignores (`EchoDriver` takes no host, so "Run on" was a control that silently did nothing). -- **Echo is seeded, not implicit.** It lives in the setup with no ssh, +- **Echo is seeded, not implicit.** It lives in the machine with no ssh, because it runs in-process and has no transport to cross. It is written into `config.ron` on first run rather than conjured at read time — a provider nobody can see in the file is one nobody can edit from the phone. @@ -78,7 +89,7 @@ axum 0.8, axum-server + rustls, tokio, serde, clap, tracing. Rust edition comment is the surface's source of truth**; this file does not repeat it. - `auth.rs` — the bearer-token middleware. - `config.rs` — the persisted schema. -- `setups.rs` — machines and provider discovery. +- `machines.rs` — machines and provider discovery. - `files.rs` — the file explorer (`EXPLORER.md`). - `usage.rs` — provider usage polling, per machine. - `models.rs` — HuggingFace browsing and GGUF downloads. @@ -223,16 +234,16 @@ is applied once Codex supplies that turn's id, so it cannot leak forward and hide a later failure. Codex subscription limits come from the CLI's `account/rateLimits/read` -app-server request on the machine whose setup runs Codex. This keeps login and +app-server request on the machine that runs Codex. This keeps login and token refresh inside the CLI. Its primary and secondary windows are normalized into the existing usage snapshot shape, under provider name `codex`, so the phone and auto-resume need no Codex branch. This is the CLI's local protocol and is treated defensively for the same reason as Claude's undocumented usage endpoint: missing fields or a refusal degrade to an unavailable snapshot. -The model picker asks the selected setup's Codex app-server for `model/list` +The model picker asks the selected machine's Codex app-server for `model/list` when it is opened. The catalog is account- and CLI-version-specific, so it is -never copied into the app or inferred from another setup; a lookup failure is +never copied into the app or inferred from another machine; a lookup failure is shown as unavailable while the free-text escape remains. Permission choices are likewise reported per provider: Codex offers its read-only, workspace-write and full-access modes, while Claude keeps its own modes. @@ -249,7 +260,7 @@ not exist on that machine. `thread/tokenUsage/updated.last.inputTokens` is the measured context (cached input is already included), while `last.totalTokens` remains the turn's usage. If an older common transcript has no such event yet, the server seeds the same measurement from the last `token_count` in Codex's -own rollout, including when that rollout is on an SSH setup. +own rollout, including when that rollout is on an SSH machine. App-server's `item/agentMessage/delta` notifications are provisional: safety buffering can revise their words before `item/completed` supplies the durable @@ -270,7 +281,7 @@ deliberate and easy to undo by accident: when the process restarts. That leaves the Claude driver as the odd one out rather than this one — the CLI's memory is a cache in front of the same transcript. Resolve any inconsistency in this direction. -- **A llama session runs on whatever machine its setup names** (2026-09-04, +- **A llama session runs on its configured machine** (2026-09-04, the last of phase 5). A transport is "run this" plus "reach this port", and the second half is `Transport::reserve_port` — the port the server binds *there* and the port that reaches it *here*, the same number locally — @@ -281,12 +292,12 @@ deliberate and easy to undo by accident: avoids racing the bind anyway; a collision is not silent, since the server fails to bind and the readiness poll reports what its log said. - **The model file lives on the machine that serves it** (2026-09-04). Each - setup names its own models directory (`SshConfig::models_dir`, default + machine has its own models directory (`SshConfig::models_dir`, default `~/.local/share/ai-app/models` expanded *there*), and a spawn resolves the key on that machine — one round trip answering "at /abs/path" or "missing", so a model that is not there is refused at the spawn rather than becoming a server that never becomes ready. The spawn screen offers - `GET /setups/{id}/models`, that machine's list, rather than `GET /models`, + `GET /machines/{id}/models`, that machine's list, rather than `GET /models`, which is this backend's downloads. Downloading *to* another machine is deliberately not built: a multi-gigabyte transfer with no progress anywhere, and the file gets there however anything else on that machine @@ -344,7 +355,7 @@ deliberate and easy to undo by accident: upload is streamed to disk under the session's attachments on this machine, and the message ends with `Attached file: /abs/path`. For a session on another machine the upload also copies the file there in the same request, - over one `ssh` invocation, landing in the setup's `attachmentsDir` if set, + over one `ssh` invocation, landing in the machine's `attachmentsDir` if set, else the session's cwd, else the login home. The resolved remote path is recorded beside the file (`.remote`) and is what the driver names. A copy that fails fails the upload, so no message ever names a file that is @@ -611,7 +622,7 @@ prompt id, and the adopted copy billed for re-reading all of it. It ended at the account's session limit. **Importing and deleting run on the server, and a batch is handed over in one -call.** `POST /setups/{id}/importable/{delete,import}` each take a list of +call.** `POST /machines/{id}/importable/{delete,import}` each take a list of ids, answer 202, and do the work in spawned tasks — the phone that asked is free to leave, and used to cancel its own batch by doing so. A list rather than a route per session because one request per row made a handover only as @@ -679,15 +690,36 @@ account being billed — and in the layout this aims at, `ai-server` is on the host, the host has no `claude`, and the CLI machine is a remote. So credentials are read through the session `Transport` (`$HOME` expanded by the far shell, because a path built locally is the wrong home), one snapshot per -setup that offers Claude. The HTTP call stays on the backend, so the far end +machine that offers Claude. The HTTP call stays on the backend, so the far end needs nothing but a shell. -The snapshot says which of four things happened rather than carrying a flag -and a message: `ok`, `notLoggedIn`, `unreachable`, `failed`. `notLoggedIn` is +The snapshot says what happened rather than carrying a flag and a message: +`ok`, `notLoggedIn`, `authenticating`, `loginRequired`, `unreachable`, or +`failed`. `notLoggedIn` is the one that matters — a machine nobody put an account on is working as -configured, and collapsing it into an error string made a healthy setup read +configured, and collapsing it into an error string made a healthy machine read as broken. A machine with no Claude provider is not asked at all. +**Refresh and sign-in are serialized per machine and metered provider +(2026-09-12).** OAuth credentials rotate in a store shared by every CLI process +on that environment. Concurrent `/usage` requests used to be able to run two +refresh probes against the same file, while globally serializing would make an +unreachable machine stall unrelated accounts. `UsageMonitor` therefore has one +gate for each `(machine id, usage provider)` and rechecks the cache after taking +it. A concurrent read serves the last answer when there is one; a first read +waits for the single producer. Different machines and providers proceed +independently. + +When Claude cannot renew an expired login, the phone offers sign-in from both +the Machines tab and the usage dialog. The backend starts the configured +Claude CLI's headless `auth login` on that machine, returns only its Anthropic +authorization URL, and accepts the one code copied back from the browser. The +CLI remains the OAuth client and the only credential writer: the backend keeps +the URL and code only for the live attempt and never sees or persists an access +token or refresh token. An explicit login holds +the same machine/provider gate as usage refresh, is cancelable, expires after +ten minutes, and is killed when the backend stops. + **The five-hour window has no reset time between blocks, and that is not a missing value.** Measured 2026-08-31: the API anchors the window to the block it started in, and when no block is running there is nothing to reset, so @@ -890,7 +922,7 @@ time. ### The file explorer (2026-09-03) **`EXPLORER.md` holds this design.** The one-line version: a machine's -filesystem, seen from the phone through the backend, keyed on the **setup** +filesystem, seen from the phone through the backend, keyed on the **machine** rather than on a session (a session only says where to start), with every operation one fixed shell script run through `Transport` so the local and the ssh case are one implementation. @@ -942,7 +974,7 @@ ssh case are one implementation. "read me an arbitrary file" — the import listing is written that way. The explorer is different because the path is the whole feature, and it grants nothing new: the same token already spawns a bypass-permissions - agent in any directory on any machine a setup names. The import rule + agent in any directory on any configured machine. The import rule stands where it is, because there a path was unnecessary. - **Generation**: 256 bits from the OS CSPRNG, base64url. A machine credential, never typed twice, so at this entropy no stretching is needed. @@ -988,7 +1020,7 @@ ssh case are one implementation. tunnel once DDNS catches up. The symptom is obvious and lossless — the SSE cursor design replays whatever was missed. - Rejected: **Tailscale** and **Headscale**, which add a coordination - service this setup does not need at two or three devices; **forwarding + service this machine does not need at two or three devices; **forwarding the HTTPS port directly**, which puts every internet scanner one pre-auth bug away from RCE on a machine holding SSH keys. - The server refuses to start without TLS, so the token cannot travel @@ -1002,7 +1034,7 @@ ssh case are one implementation. Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). -1. **Session list** — kind icon, title, setup, model, status, last activity. +1. **Session list** — kind icon, title, machine, model, status, last activity. Sessions awaiting an answer sort to the top: the "your turn" inbox. 2. **Import** — Claude Code sessions the machine already has, selected in batches (hold to enter, tap to add), with Delete and Import along the @@ -1013,7 +1045,7 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). not been imported, and tapping it starts a second CLI on the same transcript. That makes rows below slide up under the reader's finger, so a row that has just moved ignores taps for `SETTLE_MS`. -3. **Models** and **Setups** — browsing and downloading GGUFs; adding, +3. **Models** and **Machines** — browsing and downloading GGUFs; adding, renaming, re-probing and removing machines. 4. **Session screen** — the core: - The transcript rendered from the event stream: markdown, inline images, @@ -1174,8 +1206,8 @@ verified by running it, matching dev-updater's posture. - **Remote llama-server** needs its port forwarded (`ssh -L`) and is not built; such a session is refused rather than misdirected. - **Claude sessions over ssh need the remote machine logged in to Claude.** - Usage reporting reads each machine's own credentials, so this is visible - rather than silent. + Usage reporting reads each machine's own credentials, and the Machines tab + can run that machine's CLI login without requiring an interactive SSH shell. ## References diff --git a/SUBAGENTS.md b/SUBAGENTS.md index ad7e78e..1f8c19c 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -9,7 +9,7 @@ card and readable in the same transcript view the session has. Designed **A subagent is a second transcript owned by a session, in the same event model, with no process and no controls.** It is not a session: it cannot be -messaged, stopped or started, and it has no setup, model or usage of its +messaged, stopped or started, and it has no machine, model or usage of its own. Everything it shares with a session -- the transcript file format, the paging routes, the SSE stream, the phone's cache and rendering -- is reused by addressing, not by copying. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 4de1a61..fe9d0ae 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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 = - requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) } +fun fetchMachines(settings: ServerSettings): List = + 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 = - requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) { +fun fetchImportable(settings: ServerSettings, machine: String): List = + 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 = +fun probeMachine(settings: ServerSettings, ssh: SshDetails?): List = 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 = 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, ) @@ -919,8 +996,8 @@ fun fetchUsage(settings: ServerSettings): List = 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) { +fun deleteImportable(settings: ServerSettings, machine: String, sessionIds: List) { 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, 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 = - requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection -> +fun fetchMachineModels(settings: ServerSettings, machineId: String): List = + 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 = requestFromServer( settings, - "/setups/${setupId.urlEncoded()}/providers/${provider.urlEncoded()}/models", + "/machines/${machineId.urlEncoded()}/providers/${provider.urlEncoded()}/models", ) { connection -> JSONArray(connection.inputStream.bufferedReader().readText()).strings() } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index f24223a..81eaaba 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * and the back button the only other way between them. * - * Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of - * them a step down from another -- and what is left here is only what genuinely is a step down: one - * session, spawning one, and settings. + * Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none + * of them a step down from another -- and what is left here is only what genuinely is a step down: + * one session, spawning one, and settings. */ private sealed class Screen { /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index 00687c2..af6e22b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -44,13 +44,13 @@ import kotlinx.coroutines.withContext /** * Which machine's files to show, and where to start. * - * A **setup**, not a session: a filesystem is a property of a machine, and a session only says - * where it was working. That is what makes a second way in -- from the setups tab -- one more + * A **machine**, not a session: a filesystem is a property of a machine, and a session only says + * where it was working. That is what makes a second way in -- from the machines tab -- one more * caller rather than any new code here. */ data class FilesTarget( - val setup: String, - val setupName: String, + val machine: String, + val machineName: String, val start: String, /** A document to open immediately; [start] remains the fallback directory. */ val file: String? = null, @@ -59,8 +59,8 @@ data class FilesTarget( /** The explorer target for this session's machine, optionally opened on [file]. */ fun SessionSummary.filesTarget(file: String? = null) = FilesTarget( - setup = setup, - setupName = setupName, + machine = machine, + machineName = machineName, start = cwd?.takeIf { it.isNotBlank() } ?: "~", file = file, ) @@ -131,7 +131,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un listings[path] = try { withContext(Dispatchers.IO) { - LoadState.Loaded(fetchDir(settings, target.setup, path)) + LoadState.Loaded(fetchDir(settings, target.machine, path)) } } catch (e: ApiException) { LoadState.failed(e) @@ -162,7 +162,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un // A file link can open without visiting the project first, but Back still needs to know where // the project is. Home is likewise resolved by the machine rather than guessed on the phone; // it is what lets every path beneath it be displayed with `~`, including over ssh. - LaunchedEffect(target.setup, target.start) { + LaunchedEffect(target.machine, target.start) { if (target.file != null) load(target.start, again = false) if (target.start != "~") load("~", again = false) } @@ -187,7 +187,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un FilesHeader( title = baseName(shownAt), path = shownAt, - machine = target.setupName, + machine = target.machineName, onBack = { leave(UnsavedDestination.Session) }, ) { GlyphButton( @@ -241,7 +241,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un if (creating && dir != null && listing != null) { CreateDialog( settings = settings, - setup = target.setup, + machine = target.machine, directory = listing.path, onDismiss = { creating = false }, onCreated = { path, isDirectory -> @@ -444,7 +444,7 @@ private fun ColumnScope.DocPane( state = LoadState.Loading state = try { - val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) } + val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) } if (got is FileContent.Text) draft = TextFieldValue(got.content) LoadState.Loaded(got) } catch (e: ApiException) { @@ -467,7 +467,7 @@ private fun ColumnScope.DocPane( try { val written = withContext(Dispatchers.IO) { - writeFile(settings, target.setup, path, draft.text, against) + writeFile(settings, target.machine, path, draft.text, against) } state = LoadState.Loaded( @@ -496,7 +496,7 @@ private fun ColumnScope.DocPane( FilesHeader( title = name, path = tildePath(path, homeDirectory), - machine = target.setupName, + machine = target.machineName, onBack = onBack, ) { if (editing) { @@ -595,7 +595,9 @@ private fun ColumnScope.DocPane( scope.launch { val fresh = try { - withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) } + withContext(Dispatchers.IO) { + fetchFile(settings, target.machine, path) + } } catch (e: ApiException) { saveError = e.message conflict = null @@ -639,7 +641,7 @@ private fun Note(text: String) { @Composable private fun CreateDialog( settings: ServerSettings, - setup: String, + machine: String, directory: String, onDismiss: () -> Unit, onCreated: (String, Boolean) -> Unit, @@ -659,8 +661,8 @@ private fun CreateDialog( scope.launch { try { withContext(Dispatchers.IO) { - if (isDirectory) createDir(settings, setup, path) - else createFile(settings, setup, path) + if (isDirectory) createDir(settings, machine, path) + else createFile(settings, machine, path) } onCreated(path, isDirectory) } catch (e: ApiException) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index 3102053..1642a1f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -82,8 +82,8 @@ private const val SETTLE_MS = 500L @Composable fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) { val scope = rememberCoroutineScope() - var setups by remember { mutableStateOf>>(LoadState.Loading) } - var chosen by remember { mutableStateOf(null) } + var machines by remember { mutableStateOf>>(LoadState.Loading) } + var chosen by remember { mutableStateOf(null) } var sessions by remember { mutableStateOf>>(LoadState.Loading) } // What is happening to each row right now, as the word the row shows. A map keyed by id rather @@ -113,9 +113,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * Taken from the answer rather than kept across the load: the server is what knows what is * running, and this screen may be opening on work another phone started. */ - suspend fun fetchInto(setup: Setup): LoadState> = + suspend fun fetchInto(machine: Machine): LoadState> = try { - val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) } + val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) } running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap() rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap() LoadState.Loaded(rows) @@ -123,10 +123,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio LoadState.Error(err.message ?: "Couldn't list sessions") } - fun loadSessions(setup: Setup) { + fun loadSessions(machine: Machine) { sessions = LoadState.Loading selected = emptySet() - scope.launch { sessions = fetchInto(setup) } + scope.launch { sessions = fetchInto(machine) } } /** Takes a row out of the list, once the machine no longer has it to offer. */ @@ -140,9 +140,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } LaunchedEffect(reloadToken) { - setups = + machines = try { - val found = withContext(Dispatchers.IO) { fetchSetups(settings) } + val found = withContext(Dispatchers.IO) { fetchMachines(settings) } found.firstOrNull()?.let { chosen = it loadSessions(it) @@ -170,7 +170,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio selected = emptySet() running = running + targets.associate { it.id to WAITING } rowErrors = rowErrors - targets.map { it.id }.toSet() - val setup = chosen + val machine = chosen val ids = targets.map { it.id } scope.launch { // One request for the whole batch, not one per row. Sent row by row, a handover was @@ -197,11 +197,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio // The listing is the repair, because it carries the same state the events do. Only when // something still looks outstanding, so the ordinary case does not pay for a second // listing, which is the most expensive call this screen makes. - if (setup != null && targets.any { running.containsKey(it.id) }) { + if (machine != null && targets.any { running.containsKey(it.id) }) { // Quietly: no Loading, because blanking the list to report on rows that are already // saying what is happening to them is the flicker this screen avoids everywhere // else. - sessions = fetchInto(setup) + sessions = fetchInto(machine) } } } @@ -213,12 +213,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio /** Continues [targets] in the background, leaving the screen where it is. */ fun importAll(targets: List) { - val setup = chosen ?: return + val machine = chosen ?: return val useProvider = provider ?: return handOver(targets) { ids -> startImport( settings, - setup = setup.id, + machine = machine.id, sessionIds = ids, provider = useProvider.name, permissionMode = permissionMode, @@ -234,7 +234,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * it, which is the case where waiting is the right thing anyway. */ fun importAndOpen(target: Importable) { - val setup = chosen ?: return + val machine = chosen ?: return val useProvider = provider ?: return running = running + (target.id to IMPORTING) rowErrors = rowErrors - target.id @@ -244,7 +244,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio withContext(Dispatchers.IO) { spawnSession( settings, - setup = setup.id, + machine = machine.id, provider = useProvider.name, // Nothing to say: the server titles it from the session it continues. title = "", @@ -272,10 +272,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio java.util.concurrent.atomic.AtomicReference(null) } LaunchedEffect(chosen?.id) { - val setup = chosen?.id ?: return@LaunchedEffect + val machine = chosen?.id ?: return@LaunchedEffect try { while (true) { - val stream = ImportableStream(settings, setup) + val stream = ImportableStream(settings, machine) liveChanges.set(stream) try { withContext(Dispatchers.IO) { @@ -343,24 +343,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio ) Spacer(Modifier.height(12.dp)) - when (val loaded = setups) { + when (val loaded = machines) { is LoadState.Loading -> CircularProgressIndicator() is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error) is LoadState.Loaded -> { // Only worth choosing when there is a choice. if (loaded.value.size > 1) { Row(Modifier.fillMaxWidth()) { - loaded.value.forEach { setup -> + loaded.value.forEach { machine -> TextButton( onClick = { - chosen = setup - loadSessions(setup) + chosen = machine + loadSessions(machine) } ) { Text( - setup.name, + machine.name, color = - if (setup.id == chosen?.id) + if (machine.id == chosen?.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -441,9 +441,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio confirmButton = { TextButton( onClick = { - val setup = chosen ?: return@TextButton + val machine = chosen ?: return@TextButton confirming = null - handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) } + handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) } } ) { // Coloured by consequence: this takes something away, wherever it appears. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportableStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportableStream.kt index 4009a54..349acd7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportableStream.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportableStream.kt @@ -12,13 +12,13 @@ package com.example.aiapp * the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in * the next listing. */ -class ImportableStream(settings: ServerSettings, private val setup: String) { +class ImportableStream(settings: ServerSettings, private val machine: String) { private val stream = Sse(settings) fun close() = stream.close() fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) { - stream.run("/setups/$setup/importable/events", onOpen) { _, data -> + stream.run("/machines/$machine/importable/events", onOpen) { _, data -> if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MachinesScreen.kt similarity index 82% rename from app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt rename to app/androidApp/src/main/kotlin/com/example/aiapp/MachinesScreen.kt index 5b127cc..2312f8e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MachinesScreen.kt @@ -37,19 +37,20 @@ import kotlinx.coroutines.withContext * which is what keeps the enrolled token from being able to introduce commands. */ @Composable -fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { +fun MachinesScreen(settings: ServerSettings, reloadToken: Int) { val scope = rememberCoroutineScope() - var state by remember { mutableStateOf>>(LoadState.Loading) } + var state by remember { mutableStateOf>>(LoadState.Loading) } var adding by remember { mutableStateOf(false) } - var renaming by remember { mutableStateOf(null) } - var confirmingDelete by remember { mutableStateOf(null) } + var renaming by remember { mutableStateOf(null) } + var confirmingDelete by remember { mutableStateOf(null) } + var signingIn by remember { mutableStateOf?>(null) } var busy by remember { mutableStateOf(null) } var actionError by remember { mutableStateOf(null) } suspend fun reload() { state = try { - withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) } + withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) } } catch (e: ApiException) { LoadState.failed(e) } @@ -82,19 +83,19 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Loaded -> LazyColumn(Modifier.fillMaxSize()) { - uniqueItems(current.value, key = { it.id }) { setup -> - SetupCard( - setup = setup, - onRename = { renaming = setup }, + uniqueItems(current.value, key = { it.id }) { machine -> + MachineCard( + machine = machine, + onRename = { renaming = machine }, onRediscover = { scope.launch { - busy = "Asking ${setup.name} what it has…" + busy = "Asking ${machine.name} what it has…" actionError = runCatching { withContext(Dispatchers.IO) { - updateSetup( + updateMachine( settings, - setup.id, + machine.id, rediscover = true, ) } @@ -105,7 +106,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { reload() } }, - onDelete = { confirmingDelete = setup }, + onDelete = { confirmingDelete = machine }, + onSignIn = { provider -> signingIn = machine to provider }, ) } } @@ -113,7 +115,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { } if (adding) { - AddSetupDialog( + AddMachineDialog( onDismiss = { adding = false }, onAdd = { name, ssh -> adding = false @@ -121,7 +123,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { busy = "Asking $name what it has…" actionError = runCatching { - withContext(Dispatchers.IO) { addSetup(settings, name, ssh) } + withContext(Dispatchers.IO) { addMachine(settings, name, ssh) } } .exceptionOrNull() ?.message @@ -129,13 +131,13 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { reload() } }, - onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } }, + onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } }, ) } - renaming?.let { setup -> + renaming?.let { machine -> RenameDialog( - setup = setup, + machine = machine, onDismiss = { renaming = null }, onRename = { name -> renaming = null @@ -143,7 +145,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { actionError = runCatching { withContext(Dispatchers.IO) { - updateSetup(settings, setup.id, name = name) + updateMachine(settings, machine.id, name = name) } } .exceptionOrNull() @@ -154,10 +156,10 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { ) } - confirmingDelete?.let { setup -> + confirmingDelete?.let { machine -> AlertDialog( onDismissRequest = { confirmingDelete = null }, - title = { Text("Remove \"${setup.name}\"?") }, + title = { Text("Remove \"${machine.name}\"?") }, text = { Text( "The machine is left alone -- this only stops this app offering it. " + @@ -171,7 +173,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { scope.launch { actionError = runCatching { - withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) } + withContext(Dispatchers.IO) { + deleteMachine(settings, machine.id) + } } .exceptionOrNull() ?.message @@ -187,34 +191,60 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { }, ) } + + signingIn?.let { (machine, provider) -> + ProviderLoginDialog( + settings = settings, + machineId = machine.id, + machineName = machine.name, + provider = provider.name, + onDismiss = { signingIn = null }, + onSignedIn = { + signingIn = null + scope.launch { reload() } + }, + ) + } } @Composable -private fun SetupCard( - setup: Setup, +private fun MachineCard( + machine: Machine, onRename: () -> Unit, onRediscover: () -> Unit, onDelete: () -> Unit, + onSignIn: (Provider) -> Unit, ) { Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Column(Modifier.padding(12.dp)) { - Text(setup.name, style = MaterialTheme.typography.titleSmall) + Text(machine.name, style = MaterialTheme.typography.titleSmall) Text( - // Not "this machine": the seeded setup is *called* that, and the card read "this + // Not "this machine": the seeded machine is *called* that, and the card read "this // machine / this machine". - setup.address ?: "runs where the backend does", + machine.address ?: "runs where the backend does", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(4.dp)) - Text( - if (setup.providers.isEmpty()) { - "Nothing found on it. Install something and rediscover." - } else { - setup.providers.joinToString(" · ") { it.name } - }, - style = MaterialTheme.typography.bodySmall, - ) + if (machine.providers.isEmpty()) { + Text( + "Nothing found on it. Install something and rediscover.", + style = MaterialTheme.typography.bodySmall, + ) + } else { + machine.providers.forEach { provider -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(provider.name, style = MaterialTheme.typography.bodySmall) + if (provider.kind == "claude_cli") { + Spacer(Modifier.weight(1f)) + TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") } + } + } + } + } Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = onRename) { Text("Rename") } TextButton(onClick = onRediscover) { Text("Rediscover") } @@ -226,7 +256,7 @@ private fun SetupCard( } @Composable -private fun AddSetupDialog( +private fun AddMachineDialog( onDismiss: () -> Unit, onAdd: (String, SshDetails?) -> Unit, onTest: suspend (SshDetails?) -> List, @@ -349,8 +379,8 @@ private fun AddSetupDialog( } @Composable -private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) { - var name by remember { mutableStateOf(setup.name) } +private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) { + var name by remember { mutableStateOf(machine.name) } AlertDialog( onDismissRequest = onDismiss, title = { Text("Rename") }, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt index 4252ac4..d5e3dd6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -38,7 +38,7 @@ private enum class MainTab(val label: String) { Sessions("Sessions"), Import("Import"), Models("Models"), - Setups("Setups"), + Machines("Machines"), } @Composable @@ -147,7 +147,7 @@ fun MainScreen( MainTab.Import -> ImportScreen(settings = settings, reloadToken = token, onImported = onImported) MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token) - MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token) + MainTab.Machines -> MachinesScreen(settings = settings, reloadToken = token) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderLoginDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderLoginDialog.kt new file mode 100644 index 0000000..189541c --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ProviderLoginDialog.kt @@ -0,0 +1,215 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Relays a provider CLI's headless browser login without ever owning its credentials. + * + * The URL and code live only in this composition. The CLI process on [machineId] remains the one + * OAuth client and the only writer of its credential file. + */ +@Composable +fun ProviderLoginDialog( + settings: ServerSettings, + machineId: String, + machineName: String, + provider: String, + onDismiss: () -> Unit, + onSignedIn: () -> Unit, +) { + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + var login by remember(machineId, provider) { mutableStateOf(null) } + var code by remember(machineId, provider) { mutableStateOf("") } + var error by remember(machineId, provider) { mutableStateOf(null) } + var retry by remember(machineId, provider) { mutableIntStateOf(0) } + + suspend fun follow(initial: ProviderLogin): ProviderLogin { + var current = initial + val wasSubmitting = initial.state == "submitting" + while (current.state == "starting" || current.state == "submitting") { + delay(400) + current = + withContext(Dispatchers.IO) { + fetchProviderLogin( + settings, + machineId, + provider, + current.attempt, + ) + } + login = current + } + if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) { + current = + current.copy( + detail = "That code was not accepted. Copy the complete code and try again." + ) + login = current + } + return current + } + + LaunchedEffect(machineId, provider, retry) { + error = null + code = "" + login = null + try { + val started = + withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) } + login = started + if (follow(started).state == "succeeded") { + onSignedIn() + } + } catch (e: ApiException) { + error = e.message + } + } + + fun dismiss() { + login + ?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") } + ?.let { + scope.launch(Dispatchers.IO) { + runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) } + } + } + onDismiss() + } + + AlertDialog( + onDismissRequest = ::dismiss, + title = { Text("Sign in to Claude") }, + text = { + Column { + Text( + "Claude will sign in on $machineName. Open the authorization page, then " + + "paste the code it gives you here." + ) + Spacer(Modifier.height(12.dp)) + when (val current = login) { + null -> + if (error == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator() + Text("Starting sign-in…") + } + } + else -> + when (current.state) { + "starting", + "submitting" -> + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator() + Text( + if (current.state == "submitting") "Checking code…" + else "Starting sign-in…" + ) + } + "waitingForCode" -> { + TextButton( + onClick = { + runCatching { + current.authorizationUrl?.let(uriHandler::openUri) + } + .onFailure { + error = "Couldn't open the authorization page." + } + }, + enabled = current.authorizationUrl != null, + ) { + Text("Open authorization page") + } + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text("Authorization code") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + current.detail?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + } + "succeeded" -> Text("Signed in on $machineName.") + "cancelled" -> Text("Sign-in was cancelled.") + else -> + Text( + current.detail ?: "Sign-in failed.", + color = MaterialTheme.colorScheme.error, + ) + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + val current = login + when { + current?.state == "waitingForCode" -> + TextButton( + onClick = { + scope.launch { + error = null + try { + val submitted = + withContext(Dispatchers.IO) { + submitProviderLoginCode( + settings, + machineId, + provider, + current.attempt, + code, + ) + } + login = submitted + if (follow(submitted).state == "succeeded") { + onSignedIn() + } + } catch (e: ApiException) { + error = e.message + } + } + }, + enabled = code.isNotBlank(), + ) { + Text("Continue") + } + error != null || current?.state == "failed" || current?.state == "cancelled" -> + TextButton(onClick = { retry++ }) { Text("Try again") } + current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") } + } + }, + dismissButton = { + if (login?.state != "succeeded") { + TextButton(onClick = ::dismiss) { Text("Cancel") } + } + }, + ) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index ce60878..c7f0a7d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -562,7 +562,7 @@ private fun SessionCard( // separator as the session screen's header and the usage dialog, so one // pair of facts is not written three ways. listOfNotNull( - session.setupName, + session.machineName, session.provider, session.model?.let { modelLabel(it) }, ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index fc3e9f1..80fdfd1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1117,11 +1117,11 @@ fun SessionScreen( // Only for the model picker, which a subagent does not have. if (!isSubagent) { - LaunchedEffect(summary.setup, summary.provider) { + LaunchedEffect(summary.machine, summary.provider) { val provider = runCatching { withContext(Dispatchers.IO) { - fetchSetups(settings) - .firstOrNull { it.id == summary.setup } + fetchMachines(settings) + .firstOrNull { it.id == summary.machine } ?.providers ?.firstOrNull { it.name == summary.provider } } @@ -1133,7 +1133,7 @@ fun SessionScreen( ?.let { runCatching { withContext(Dispatchers.IO) { - fetchProviderModels(settings, summary.setup, summary.provider) + fetchProviderModels(settings, summary.machine, summary.provider) } } .getOrDefault(emptyList()) @@ -1412,7 +1412,7 @@ fun SessionScreen( // to, and showing it twice means two things to keep in step -- they // disagreed for a moment on every model change. Text( - "${summary.setupName} · ${summary.provider}", + "${summary.machineName} · ${summary.provider}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -2044,7 +2044,12 @@ fun SessionScreen( fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } if (usageOpen) { usageFeed?.let { - UsageDialog(feed = it, session = summary, onDismiss = { usageOpen = false }) + UsageDialog( + settings = settings, + feed = it, + session = summary, + onDismiss = { usageOpen = false }, + ) } } if (settingsOpen) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index 8622c42..9a0b4f1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -83,7 +83,7 @@ class UsageFeed( return when (val state = snapshots) { is LoadState.Loading -> SessionUsage.Waiting is LoadState.Error -> SessionUsage.Unavailable(state.message) - is LoadState.Loaded -> usageFor(state.value, session.setup, provider, session.model) + is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model) } } } @@ -168,7 +168,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { } // Nothing at all for a session that meters nothing: a row saying "unknown" there would report - // a problem about a setup somebody chose, on every screen, forever. + // a problem about a machine somebody chose, on every screen, forever. // // And nothing while the first fetch is out, which is a different silence. A request in flight // is not a state to report -- and the session that meters nothing is exactly the one this @@ -250,7 +250,7 @@ private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String { } /** - * One meter's snapshot, out of every machine's: [setup]'s row for [provider]. + * One meter's snapshot, out of every machine's: [machine]'s row for [provider]. * * Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's * account and, while a test has one set, an echo session's invented one -- and a snapshot is one @@ -262,19 +262,27 @@ private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String { */ fun usageFor( snapshots: List, - setup: String, + machine: String, provider: String, model: String?, ): SessionUsage { // No snapshot at all means the backend never asked, which it only does where there is nothing // to ask about. That is a different answer from having asked and failed. - val pools = usageSnapshotsFor(snapshots, setup, provider) + val pools = usageSnapshotsFor(snapshots, machine, provider) if (pools.isEmpty()) return SessionUsage.NotMetered val mine = usagePoolFor(pools, model) ?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses") if (mine.state != "ok") { - return SessionUsage.Unavailable(mine.detail ?: mine.state) + val why = + mine.detail + ?: when (mine.state) { + "notLoggedIn" -> "no Claude account is signed in on this machine" + "authenticating" -> "Claude sign-in is in progress" + "loginRequired" -> "Claude sign-in is required" + else -> mine.state + } + return SessionUsage.Unavailable(why) } return SessionUsage.Known(mine.windows) } @@ -282,11 +290,11 @@ fun usageFor( /** Every billing pool reported for one provider on one machine. */ internal fun usageSnapshotsFor( snapshots: List, - setup: String, + machine: String, provider: String?, ): List = if (provider == null) emptyList() - else snapshots.filter { it.setup == setup && it.provider == provider } + else snapshots.filter { it.machine == machine && it.provider == provider } /** The pool an explicit model names, or the provider's generic pool for every other model. */ internal fun usagePoolFor(pools: List, model: String?): UsageSnapshot? { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index d102fbb..adb151b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -48,12 +48,13 @@ fun SpawnScreen( val scope = rememberCoroutineScope() // What the form is made of, and whether we have it yet. A failure here is not the same as a // server with nothing to offer, so it must not reach the pickers as empty lists. - var options by remember { mutableStateOf>>(LoadState.Loading) } + var options by remember { mutableStateOf>>(LoadState.Loading) } - // Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the - // provider is stored by name and resolved against the current setup rather than held as an + // Machine first, then one of its providers. Choosing a machine can invalidate the provider, so + // the + // provider is stored by name and resolved against the current machine rather than held as an // object that could outlive the list it came from. - var setupName by remember { mutableStateOf(null) } + var machineName by remember { mutableStateOf(null) } var providerName by remember { mutableStateOf(null) } var title by remember { mutableStateOf("") } var model by remember { mutableStateOf("") } @@ -80,7 +81,7 @@ fun SpawnScreen( var temperature by remember { mutableStateOf("") } LaunchedEffect(Unit) { - // Separate from the setups fetch below and deliberately not fatal: failing to learn the + // Separate from the machines fetch below and deliberately not fatal: failing to learn the // default must leave a screen you can still spawn from, so the picker stays on "default" // and says so rather than the whole form refusing to draw. runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } } @@ -88,9 +89,9 @@ fun SpawnScreen( defaultsAsked = true options = try { - val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) } + val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) } val first = fetched.firstOrNull() - setupName = first?.name + machineName = first?.name providerName = first?.providers?.firstOrNull()?.name LoadState.Loaded(fetched) } catch (e: ApiException) { @@ -112,7 +113,7 @@ fun SpawnScreen( // Nothing below is fillable until the options are here, and a failure to fetch them leaves // no form worth showing -- so this reports and stops, rather than offering empty pickers // under an error message. - val setups = + val machines = when (val state = options) { is LoadState.Loading -> { CircularProgressIndicator() @@ -124,19 +125,19 @@ fun SpawnScreen( } is LoadState.Loaded -> state.value } - val setup = setups.firstOrNull { it.name == setupName } + val machine = machines.firstOrNull { it.name == machineName } // Whichever machine is chosen now, asked again when that changes. The old machine's list // is dropped first rather than left on screen: a file name from another machine looks // exactly like one from this one. - LaunchedEffect(setup?.id) { + LaunchedEffect(machine?.id) { models = emptyList() modelKey = null - val id = setup?.id ?: return@LaunchedEffect + val id = machine?.id ?: return@LaunchedEffect models = - runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } } + runCatching { withContext(Dispatchers.IO) { fetchMachineModels(settings, id) } } .getOrDefault(emptyList()) } - val current = setup?.providers?.firstOrNull { it.name == providerName } + val current = machine?.providers?.firstOrNull { it.name == providerName } // Coding CLIs take a working directory, model, permission mode and thinking level. Keying // the extra fields on the kind rather than the provider name keeps a second installation // from needing anything here. @@ -145,7 +146,7 @@ fun SpawnScreen( val isCodingCli = isClaude || isCodex val isLlama = current?.kind == "llama_cpp" - LaunchedEffect(setup?.id, current?.name) { + LaunchedEffect(machine?.id, current?.name) { model = "" providerModels = emptyList() providerModelsError = null @@ -155,7 +156,7 @@ fun SpawnScreen( try { providerModels = withContext(Dispatchers.IO) { - fetchProviderModels(settings, setup.id, current.name) + fetchProviderModels(settings, machine.id, current.name) } } catch (e: ApiException) { providerModelsError = e.message @@ -169,41 +170,41 @@ fun SpawnScreen( // The machine first, because it decides what can be run at all. ChipGroup( - label = "Setup", - options = setups.map { it.name }, - selected = setupName, + label = "Machine", + options = machines.map { it.name }, + selected = machineName, onSelect = { name -> - setupName = name + machineName = name // The provider list changes with the machine, so a name carried over from the // previous one would be a selection that isn't in the picker. Take that machine's // first. providerName = - setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name + machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name }, ) - setup?.address?.let { + machine?.address?.let { Text( it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // The address belongs to the setup above it, not to the provider label below; without + // The address belongs to the machine above it, not to the provider label below; without // this they read as one block. Spacer(Modifier.height(8.dp)) } - // Only what this machine actually has. A setup with none says so rather than showing an + // Only what this machine actually has. A machine with none says so rather than showing an // empty row that reads as a failure. - if (setup != null && setup.providers.isEmpty()) { + if (machine != null && machine.providers.isEmpty()) { Text( - "\"${setup.name}\" has no providers configured.", + "\"${machine.name}\" has no providers configured.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { ChipGroup( label = "Provider", - options = setup?.providers?.map { it.name }.orEmpty(), + options = machine?.providers?.map { it.name }.orEmpty(), selected = providerName, onSelect = { providerName = it }, ) @@ -225,7 +226,7 @@ fun SpawnScreen( // disk is a session that cannot start. if (models.isEmpty()) { Text( - "No models on ${setup.name}. The Models screen downloads " + + "No models on ${machine.name}. The Models screen downloads " + "to the backend; another machine needs the file put there itself.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -277,7 +278,7 @@ fun SpawnScreen( ) providerModels.isEmpty() -> Text( - "This setup reported no selectable models.", + "This machine reported no selectable models.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -358,8 +359,8 @@ fun SpawnScreen( settings, // The id, not the label: labels are editable and the server // resolves by id. Non-null here, since `chosen` came from - // `setup`'s own provider list. - setup = setup.id, + // `machine`'s own provider list. + machine = machine.id, provider = chosen.name, title = title.trim(), model = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index dd24aa0..affb69e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -15,6 +15,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -30,7 +34,13 @@ import java.time.OffsetDateTime * own, so the only thing its Back could ever have meant was "put this away". */ @Composable -fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit) { +fun UsageDialog( + settings: ServerSettings, + feed: UsageFeed, + session: SessionSummary, + onDismiss: () -> Unit, +) { + var signingIn by remember { mutableStateOf(false) } // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps // between its title, content and buttons at sizes meant for a sentence of prose and a decision; // this is a dense read-out, and those gaps left a band of empty dialog above Close that was @@ -73,12 +83,12 @@ fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit) LoadState.Loaded( usageSnapshotsFor( snapshots.value, - session.setup, + session.machine, session.usageProvider, ) ) } - UsageBody(state) + UsageBody(state, onSignIn = { signingIn = true }) } TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("Close") @@ -86,11 +96,24 @@ fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit) } } } + if (signingIn) { + ProviderLoginDialog( + settings = settings, + machineId = session.machine, + machineName = session.machineName, + provider = session.provider, + onDismiss = { signingIn = false }, + onSignedIn = { + signingIn = false + feed.refresh() + }, + ) + } } /** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */ @Composable -private fun UsageBody(state: LoadState>) { +private fun UsageBody(state: LoadState>, onSignIn: () -> Unit) { Column { when (val current = state) { is LoadState.Loading -> CircularProgressIndicator() @@ -122,7 +145,7 @@ private fun UsageBody(state: LoadState>) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - SnapshotState(snapshot) + SnapshotState(snapshot, onSignIn) snapshot.windows.forEachIndexed { windowIndex, window -> // Between the bars, not after the last one: a trailing gap here is what // put a band of empty dialog above the Close button. @@ -138,7 +161,7 @@ private fun UsageBody(state: LoadState>) { } private fun usageSectionTitle(snapshot: UsageSnapshot): String { - val machine = snapshot.setupName.ifEmpty { snapshot.setup } + val machine = snapshot.machineName.ifEmpty { snapshot.machine } val provider = snapshot.provider val pool = if (provider == "codex" && snapshot.limitId != "codex") { @@ -156,15 +179,25 @@ private fun usageSectionTitle(snapshot: UsageSnapshot): String { * * The distinction the old single message could not draw. A machine nobody has logged in on is * working exactly as somebody set it up, so it reads as a plain statement -- marking it would be - * the interface nagging about a decision already made. Only the two faults are coloured as faults. + * the interface nagging about a decision already made. It still offers the direct sign-in action; + * unreachable and provider failures are the states coloured as faults. */ @Composable -private fun SnapshotState(snapshot: UsageSnapshot) { +private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) { when (snapshot.state) { "ok" -> {} - "notLoggedIn" -> + "notLoggedIn", + "loginRequired" -> { Text( - "No Claude account on this machine.", + snapshot.detail ?: "No Claude account on this machine.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = onSignIn) { Text("Sign in") } + } + "authenticating" -> + Text( + "Claude sign-in is in progress.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt index 4f1c5e5..efea96a 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt @@ -15,7 +15,7 @@ class SessionUsageTest { listOf(codex, reserve), usageSnapshotsFor( listOf(claude, codex, reserve, elsewhere), - setup = "machine", + machine = "machine", provider = "codex", ), ) @@ -27,7 +27,7 @@ class SessionUsageTest { emptyList(), usageSnapshotsFor( listOf(snapshot("machine", "claude", null)), - setup = "machine", + machine = "machine", provider = null, ), ) @@ -62,15 +62,15 @@ class SessionUsageTest { } private fun snapshot( - setup: String, + machine: String, provider: String, limitId: String?, limitName: String? = null, ) = UsageSnapshot( provider = provider, - setup = setup, - setupName = setup, + machine = machine, + machineName = machine, limitId = limitId, limitName = limitName, state = "ok", diff --git a/app/debug-transcript.sh b/app/debug-transcript.sh index 40f86c9..91a79ed 100755 --- a/app/debug-transcript.sh +++ b/app/debug-transcript.sh @@ -120,10 +120,10 @@ TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; } echo "==> Importing" -SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) +MACHINE=$(api "https://127.0.0.1:$PORT/machines" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) SESSION=$(api -H 'Content-Type: application/json' -X POST \ "https://127.0.0.1:$PORT/sessions" \ - -d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \ + -d "{\"machine\":\"$MACHINE\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \ | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events" diff --git a/app/run-android.sh b/app/run-android.sh index 3b65dae..bca2af1 100755 --- a/app/run-android.sh +++ b/app/run-android.sh @@ -9,7 +9,7 @@ # emulator" is three places for the memory check that was missing from all of # them. # -# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh, +# Environment machine (SDK location, PATH, ...) lives in ./android-env.sh, # which can also be sourced directly for one-off commands. set -eu diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh index 8da69d0..9f88bf4 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -110,7 +110,7 @@ api) # ./ui-sandbox.sh api /path [curl args...] ;; spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id api /sessions -X POST -H 'content-type: application/json' \ - -d "{\"setup\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" | + -d "{\"machine\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' exit 0 ;; @@ -150,7 +150,7 @@ if [ -f "$ROOT/config.ron" ]; then /^tokens: \[/ { in_tokens = 1; next } # The server writes the list back compactly, with the last entry # and the close on one line: " ),],". Reading the close only - # at a line start ran past it into `setups`, and the salvage then + # at a line start ran past it into `machines`, and the salvage then # carried a second copy of that block into the new config. in_tokens && /\],/ { sub(/\],.*/, "") @@ -213,13 +213,24 @@ while [ "$i" -le 8 ]; do i=$((i + 1)) done -# A CLI that does nothing, so importing one of these is free and safe. -# Everything the spawn path cares about is here: it holds the fifo open, -# records a real pid, writes nothing, and dies on a signal. A real +# A CLI that does nothing during a session and offers one deterministic login +# during `auth login`, so both paths are free and safe. Everything the spawn +# path cares about is here: it holds the fifo open, records a real pid, writes +# nothing, and dies on a signal. A real # `claude --resume` against an invented session id would either fail in a # way that tests nothing or start a turn on somebody's account. cat >"$ROOT/fake-claude" <&2 + done + exit 1 +fi # Slow to start, on purpose. An import against this finishes in # milliseconds otherwise, so every state on the way -- the row marked # "importing", the queue behind it, the event that clears them -- is over @@ -346,7 +357,7 @@ tokens: [ sha256: "$hash", ), $salvaged], -setups: [ +machines: [ ( id: "local", name: "sandbox", diff --git a/server/src/config.rs b/server/src/config.rs index 6936858..64885a4 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -28,7 +28,11 @@ pub struct Config { /// credential. A list (of one, today) so per-device tokens with individual /// revocation are a config entry later, not a migration. pub tokens: Vec, - pub setups: Vec, + /// `setups` is the persisted spelling before the machine/provider boundary + /// was named correctly. Read it once so an update does not discard the + /// machines already configured; every subsequent write uses `machines`. + #[serde(alias = "setups")] + pub machines: Vec, pub sessions: Vec, /// What a new session's thinking level is when nothing chose one. /// @@ -52,8 +56,8 @@ pub struct Config { /// host and offered the whole cross-product. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SetupConfig { - /// Stable identifier, minted when the setup is added and never +pub struct MachineConfig { + /// Stable identifier, minted when the machine is added and never /// changed. Sessions reference this rather than the label, so /// renaming a machine on the phone does not orphan its sessions -- /// which is the whole reason the two are separate fields. @@ -62,19 +66,19 @@ pub struct SetupConfig { /// How to reach it, absent for this machine. #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh: Option, - /// What can be spawned here. Names are unique within a setup, and only + /// What can be spawned here. Names are unique within a machine, and only /// within it: two machines may each have a `claude-cli`, which is the point. #[serde(default)] pub providers: Vec, } -impl SetupConfig { +impl MachineConfig { pub fn provider(&self, name: &str) -> Option<&ProviderConfig> { self.providers.iter().find(|provider| provider.name == name) } } -/// One thing a setup can run: which driver, and how to invoke it. +/// One thing a machine can run: which driver, and how to invoke it. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderConfig { @@ -100,7 +104,7 @@ impl ProviderConfig { } } -/// How to reach a setup that isn't this machine, with the system `ssh` client +/// How to reach a machine that isn't this machine, with the system `ssh` client /// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is /// one place to configure connections. A remote session is the identical /// command with `ssh host …` in front, and nothing downstream knows. @@ -302,12 +306,15 @@ pub struct TokenEntry { pub struct SessionConfig { /// Stable identifier; names the session's directory and its routes. pub id: String, - /// Id of the [`SetupConfig`] this session runs on -- the id, not the label, + /// Id of the [`MachineConfig`] this session runs on -- the id, not the label, /// so the machine can be renamed without losing its sessions. - pub setup: String, - /// Name of the provider within that setup. Both stored by name rather than - /// resolved, so an edited setup takes effect on the next relaunch; a session - /// whose setup or provider is gone reports as exited and can still be + /// `setup` is accepted only as the on-disk migration from builds that used + /// that word for a machine. The API and newly written records say `machine`. + #[serde(alias = "setup")] + pub machine: String, + /// Name of the provider within that machine. Both stored by name rather than + /// resolved, so an edited machine takes effect on the next relaunch; a session + /// whose machine or provider is gone reports as exited and can still be /// deleted. pub provider: String, pub title: String, @@ -419,17 +426,17 @@ fn not_set(flag: &bool) -> bool { !*flag } -/// The name of the echo provider, and of the setup this machine gets on first +/// The name of the echo provider, and of the local machine created on first /// run. /// /// Echo is seeded into the config rather than conjured at read time. An /// implicit provider is one a person cannot see in the file or edit from the /// phone; if somebody deletes it, that was a choice. pub const ECHO_PROVIDER: &str = "echo"; -pub const LOCAL_SETUP: &str = "this machine"; -/// The id of the setup a fresh install seeds. Fixed rather than random so a +pub const LOCAL_MACHINE: &str = "this machine"; +/// The id of the machine a fresh install seeds. Fixed rather than random so a /// hand-written config can name it without looking one up. -pub const LOCAL_SETUP_ID: &str = "local"; +pub const LOCAL_MACHINE_ID: &str = "local"; /// Where `ai-server --enroll-link` leaves a token for the running server to /// adopt: beside the config, since it is config in transit. @@ -438,15 +445,15 @@ pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf { } impl Config { - pub fn setup(&self, id: &str) -> Option<&SetupConfig> { - self.setups.iter().find(|setup| setup.id == id) + pub fn machine(&self, id: &str) -> Option<&MachineConfig> { + self.machines.iter().find(|machine| machine.id == id) } - /// A setup by the label a person sees, for messages and for the one place a + /// A machine by the label a person sees, for messages and for the one place a /// name still arrives from outside. Nothing else should look one up this /// way, since labels are editable and ids are not. - pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> { - self.setups.iter().find(|setup| setup.name == name) + pub fn machine_named(&self, name: &str) -> Option<&MachineConfig> { + self.machines.iter().find(|machine| machine.name == name) } /// This machine, offering whatever was found on it. @@ -455,10 +462,10 @@ impl Config { /// be *discovered*: a hardcoded list is a claim about what is installed, and /// this one was wrong -- every fresh install asserted a `claude-cli` /// provider whether or not `claude` existed. - pub fn seed(providers: Vec) -> SetupConfig { - SetupConfig { - id: LOCAL_SETUP_ID.to_string(), - name: LOCAL_SETUP.to_string(), + pub fn seed(providers: Vec) -> MachineConfig { + MachineConfig { + id: LOCAL_MACHINE_ID.to_string(), + name: LOCAL_MACHINE.to_string(), ssh: None, providers, } @@ -533,11 +540,11 @@ mod tests { let path = dir.path().join("config.ron"); // A missing file is the ordinary first-run state, not an error. - // Nothing is conjured to fill it: the seed setup is written by the + // Nothing is conjured to fill it: the seed machine is written by the // manager, so the file always says what there is. let first_run = Config::load(&path).expect("load"); assert!(first_run.tokens.is_empty()); - assert!(first_run.setups.is_empty()); + assert!(first_run.machines.is_empty()); assert!(first_run.sessions.is_empty()); let config = Config { @@ -545,7 +552,7 @@ mod tests { name: "phone".to_string(), sha256: "ab".repeat(32), }], - setups: vec![ + machines: vec![ Config::seed(vec![ Config::echo_provider(), ProviderConfig { @@ -555,7 +562,7 @@ mod tests { models: Vec::new(), }, ]), - SetupConfig { + MachineConfig { id: "vm".to_string(), name: "the vm".to_string(), ssh: Some(SshConfig { @@ -577,7 +584,7 @@ mod tests { default_effort: Some("low".to_string()), sessions: vec![SessionConfig { id: "abc123".to_string(), - setup: "vm".to_string(), + machine: "vm".to_string(), provider: "claude-cli".to_string(), title: "test".to_string(), model: None, @@ -597,14 +604,14 @@ mod tests { let loaded = Config::load(&path).expect("reload"); assert_eq!(loaded.tokens[0].name, "phone"); - assert_eq!(loaded.sessions[0].setup, "vm"); + assert_eq!(loaded.sessions[0].machine, "vm"); // The label and the id are separate, and the session holds the id. - assert_eq!(loaded.setup("vm").expect("setup").name, "the vm"); + assert_eq!(loaded.machine("vm").expect("machine").name, "the vm"); assert_eq!(loaded.sessions[0].provider, "claude-cli"); assert_eq!( loaded - .setup("vm") - .expect("setup") + .machine("vm") + .expect("machine") .ssh .as_ref() .expect("ssh") @@ -612,15 +619,21 @@ mod tests { Some(2222), ); // The same provider name on two machines is the point, not a - // collision: names are unique within a setup and only within one. + // collision: names are unique within a machine and only within one. assert!( loaded - .setup(LOCAL_SETUP_ID) + .machine(LOCAL_MACHINE_ID) .expect("local") .provider("claude-cli") .is_some() ); - assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none()); + assert!( + loaded + .machine(LOCAL_MACHINE_ID) + .expect("local") + .ssh + .is_none() + ); // The house rule both halves of `format` depend on: what is written // is the *body* of the struct, with no outer parentheses and @@ -642,6 +655,33 @@ mod tests { ); } + #[test] + fn reads_setup_spelling_from_existing_configs_but_writes_machine_spelling() { + let old = r#" +setups: [( + id: "vm", + name: "the vm", + providers: [], +)], +sessions: [( + id: "abc123", + setup: "vm", + provider: "echo", + title: "old words", + created: 1234.5, +)], +"#; + let config: Config = format::parse(old).expect("old setup spelling still loads"); + assert_eq!(config.machines[0].id, "vm"); + assert_eq!(config.sessions[0].machine, "vm"); + + let written = format::render(&config).expect("render migrated config"); + assert!(written.contains("machines:"), "{written}"); + assert!(written.contains("machine: \"vm\""), "{written}"); + assert!(!written.contains("setups:"), "{written}"); + assert!(!written.contains("setup: \"vm\""), "{written}"); + } + #[test] /// The seed is this machine and nothing more: a name, no ssh, and /// exactly the providers it was handed. @@ -653,7 +693,7 @@ mod tests { /// this can check is that the seed does not invent anything. fn the_seed_is_this_machine_and_claims_only_what_it_was_given() { let seed = Config::seed(vec![Config::echo_provider()]); - assert_eq!(seed.name, LOCAL_SETUP); + assert_eq!(seed.name, LOCAL_MACHINE); assert!(seed.ssh.is_none()); assert_eq!( seed.provider(ECHO_PROVIDER).expect("echo").kind, diff --git a/server/src/files.rs b/server/src/files.rs index 1affb2b..67283a0 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -1,7 +1,7 @@ -//! Reading and changing files on the machine a setup names. +//! Reading and changing files on a configured machine. //! //! Every operation here is one small POSIX shell script handed to `Transport`, -//! the way `setups::discover` and `import::list` already ask a machine a +//! the way `machines::discover` and `import::list` already ask a machine a //! question. That is what makes the local and the ssh case one implementation: //! a second one written against `std::fs` would be the one that gets tested, //! and the remote half -- the ordering of entries, what a symlink reports, how diff --git a/server/src/setups.rs b/server/src/machines.rs similarity index 97% rename from server/src/setups.rs rename to server/src/machines.rs index e982053..6d213f0 100644 --- a/server/src/setups.rs +++ b/server/src/machines.rs @@ -4,7 +4,7 @@ //! itself which of the known programs it has, and the answer becomes its //! providers. That is a security property, not a convenience: **no route accepts //! a command from the phone.** If it did, the enrolled token could introduce -//! arbitrary programs to run on every machine a setup names. +//! arbitrary programs to run on every machine already configured here. //! //! It is also the better interface: nobody wants to type an absolute path on a //! phone keyboard, and a machine that has moved its binaries answers correctly @@ -28,7 +28,7 @@ const PROBES: &[(&str, &str, DriverKind)] = &[ ("claude-cli", "claude", DriverKind::ClaudeCli), ("codex-cli", "codex", DriverKind::CodexCli), // Named for the program rather than for where it runs: it runs - // wherever the setup is, and "local" was true only while a llama + // wherever the machine is, and "local" was true only while a llama // session could not be spawned on another machine. ("llama-cpp", "llama-server", DriverKind::LlamaCpp), ]; @@ -171,7 +171,7 @@ fn explain(err: anyhow::Error) -> anyhow::Error { } /// A short, stable, filename-safe id derived from a label. Derived once when a -/// setup is added and then fixed, so the label stays editable. Collisions are +/// machine is added and then fixed, so the label stays editable. Collisions are /// resolved by the caller, which is the only place that knows what exists. pub fn id_from(label: &str) -> String { let slug: String = label @@ -233,7 +233,7 @@ pub fn shorten_home(path: &str) -> String { /// /// The common case of [`Transport::capture_with_input`]: nothing on stdin, a /// failure reported as the machine's own words (ssh's "Permission denied" is the -/// useful half of why a setup cannot be reached), and the output read as text +/// useful half of why a machine cannot be reached), and the output read as text /// because every caller here is asking a question whose answer is words. impl Transport { pub async fn capture(&self, launch: &Launch) -> Result { diff --git a/server/src/main.rs b/server/src/main.rs index a7ea498..0943d7d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,12 +14,13 @@ mod auth; mod config; mod files; +mod machines; mod media; mod models; +mod provider_auth; mod resume; mod routes; mod session; -mod setups; mod ssh; mod usage; @@ -143,7 +144,7 @@ async fn main() -> Result<()> { let config_path = args .config .unwrap_or_else(|| config_home("ai-app").join("config.ron")); - // Before the manager exists, on purpose: constructing it and seeding setups + // Before the manager exists, on purpose: constructing it and seeding machines // touches sessions and subprocesses this invocation has no business with // while another instance is serving. Only the hash reaches disk, in the // spool `auth.rs` reads; the link goes to stdout alone. @@ -188,19 +189,19 @@ async fn main() -> Result<()> { // After construction rather than inside it: seeding asks this machine what // it has, and a constructor that quietly runs a subprocess is a surprise to // every caller including the tests. - manager.seed_setup().await?; + manager.seed_machine().await?; tracing::info!("config: {}", config_path.display()); tracing::info!("models: {}", models_dir.display()); - for setup in manager.setups() { - match &setup.ssh { - Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address), - // No parenthetical naming the local machine: the default setup is - // *called* "this machine", and the line read "setup this machine - // (this machine)". - None => tracing::info!(" setup \"{}\" runs here", setup.name), + for machine in manager.machines() { + match &machine.ssh { + Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address), + // No parenthetical naming the local machine: the default machine is + // *called* "this machine", so repeating a local qualifier read + // like a stutter. + None => tracing::info!(" machine \"{}\" runs here", machine.name), } - for provider in &setup.providers { + for provider in &machine.providers { tracing::info!(" provider {} ({:?})", provider.name, provider.kind); } } @@ -263,11 +264,12 @@ async fn main() -> Result<()> { .context("failed to load TLS cert/key")?; // No providers listed here any more: which machines can be asked, and about - // what, comes from the setups at the moment the screen is opened -- so a + // what, comes from the machines at the moment the screen is opened -- so a // machine added from the phone reports its limits without a restart. // The fixture is the manager's, because that is where the `/usage` command // that sets it is typed; the monitor is what serves it. let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture())); + let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor))); // The one thing in here that acts without a request behind it: a session // switched to auto-resume waits out its account's usage limit and picks @@ -279,7 +281,14 @@ async fn main() -> Result<()> { // The bearer-token middleware wraps the entire router -- routes and fallback // alike -- here and only here, so a new route can't forget auth. let app = routes::router(Arc::clone(&manager)) - .merge(routes::usage_router(monitor, Arc::clone(&manager))) + .merge(routes::usage_router( + Arc::clone(&monitor), + Arc::clone(&manager), + )) + .merge(routes::provider_auth_router( + Arc::clone(&provider_logins), + Arc::clone(&manager), + )) .merge(routes::models_router(Arc::clone(&models))) .layer(axum::middleware::from_fn_with_state( Arc::clone(&manager), @@ -322,6 +331,7 @@ async fn main() -> Result<()> { // above: a throwaway session is one nobody meant to keep, and the whole point // is that nothing has to remember to clean it up. manager.stop_throwaway_sessions(); + provider_logins.cancel_all(); manager.detach_all(); Ok(()) diff --git a/server/src/models.rs b/server/src/models.rs index 1b91dcb..fef98f5 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -523,13 +523,13 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec) { } } -/// Where a machine reached over ssh keeps its models, when its setup does +/// Where a machine reached over ssh keeps its models, when its machine does /// not say. /// /// The same place this backend puts its own downloads, written out rather /// than derived: `$XDG_DATA_HOME` here describes *this* machine's /// environment, and the far machine's is the far machine's business. A -/// setup whose models are elsewhere says so (`SshConfig::models_dir`). +/// machine whose models are elsewhere says so (`SshConfig::models_dir`). const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models"; /// Which directory holds the models on the machine `transport` reaches. @@ -550,11 +550,11 @@ pub fn dir_on(transport: &Transport, local: &Path) -> String { } } -/// Every GGUF on the machine a setup names, which is the machine that +/// Every GGUF on a configured machine, which is the machine that /// would have to serve it. /// /// The local half of this is [`ModelStore::list`], reading the same shape -/// off this machine's disk; a caller picks by transport, since a setup +/// off this machine's disk; a caller picks by transport, since a machine /// with no ssh *is* this machine and asking a shell about it would be a /// slower way to the same answer. What must not happen is offering this /// backend's downloads for a session on another machine: the file has to diff --git a/server/src/provider_auth.rs b/server/src/provider_auth.rs new file mode 100644 index 0000000..3ddbe8a --- /dev/null +++ b/server/src/provider_auth.rs @@ -0,0 +1,467 @@ +//! Interactive provider login carried between a CLI on a configured machine +//! and the phone. The CLI remains the only credential writer: this layer keeps +//! its short-lived process and relays only the authorization URL and the code +//! a person copies back from the browser. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::Stdio; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use rand::Rng; +use serde::Serialize; + +use crate::config::{MachineConfig, ProviderConfig}; +use crate::session::transport::Transport; +use crate::usage::UsageMonitor; + +const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const AUTHORIZATION_URL_TIMEOUT: Duration = Duration::from_secs(15); +const OUTPUT_POLL: Duration = Duration::from_millis(50); +const START_WAIT: Duration = Duration::from_secs(16); + +type Key = (String, String); + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum LoginState { + Starting, + WaitingForCode { + #[serde(rename = "authorizationUrl")] + authorization_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, + Submitting, + Succeeded, + Failed { + detail: String, + }, + Cancelled, +} + +impl LoginState { + fn terminal(&self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed { .. } | Self::Cancelled + ) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginInfo { + pub attempt: String, + #[serde(flatten)] + pub state: LoginState, +} + +#[derive(Clone)] +struct Attempt { + id: String, + state: Arc>, + input: mpsc::Sender, +} + +impl Attempt { + fn info(&self) -> LoginInfo { + LoginInfo { + attempt: self.id.clone(), + state: self.state.lock().unwrap().clone(), + } + } +} + +enum Input { + Code(String), + Cancel, +} + +/// The active login per machine and provider. Completed attempts stay until a +/// new one replaces them, so a phone that briefly loses its connection can ask +/// how the operation ended rather than being handed an ambiguous 404. +pub struct LoginManager { + attempts: Mutex>, + usage: Arc, +} + +impl LoginManager { + pub fn new(usage: Arc) -> Self { + Self { + attempts: Mutex::new(HashMap::new()), + usage, + } + } + + pub fn start(&self, machine: MachineConfig, provider: ProviderConfig) -> LoginInfo { + let provider_key = provider + .kind + .usage_provider() + .expect("a login route only accepts a metered provider") + .to_string(); + let key = (machine.id.clone(), provider_key); + let mut attempts = self.attempts.lock().unwrap(); + if let Some(attempt) = attempts.get(&key) + && !attempt.state.lock().unwrap().terminal() + { + return attempt.info(); + } + + let id = attempt_id(); + let state = Arc::new(Mutex::new(LoginState::Starting)); + let (input, commands) = mpsc::channel(); + let attempt = Attempt { + id: id.clone(), + state: Arc::clone(&state), + input, + }; + attempts.insert(key, attempt.clone()); + drop(attempts); + + // Seed the cache before the worker takes the gate. A usage request in + // that small handoff window sees a fresh, truthful state and cannot + // start a second CLI against the same credential. + let gate = self.usage.claude_authentication_started(&machine); + let usage = Arc::clone(&self.usage); + std::thread::spawn(move || { + let _guard = gate.lock().unwrap(); + run_login(&machine, &provider, commands, &state); + usage.claude_authentication_finished(&machine.id); + }); + + attempt.info() + } + + pub fn wait_until_ready(&self, machine: &str, provider: &str, attempt: &str) -> LoginInfo { + let started = Instant::now(); + loop { + let info = self.read(machine, provider, attempt).unwrap_or(LoginInfo { + attempt: attempt.to_string(), + state: LoginState::Failed { + detail: "the sign-in attempt disappeared".to_string(), + }, + }); + if !matches!(info.state, LoginState::Starting) || started.elapsed() >= START_WAIT { + return info; + } + std::thread::sleep(OUTPUT_POLL); + } + } + + pub fn read(&self, machine: &str, provider: &str, attempt: &str) -> Option { + let attempts = self.attempts.lock().unwrap(); + let found = attempts.get(&(machine.to_string(), provider.to_string()))?; + (found.id == attempt).then(|| found.info()) + } + + pub fn submit( + &self, + machine: &str, + provider: &str, + attempt: &str, + code: &str, + ) -> Result { + let code = valid_code(code)?; + let attempts = self.attempts.lock().unwrap(); + let found = attempts + .get(&(machine.to_string(), provider.to_string())) + .filter(|found| found.id == attempt) + .context("no such sign-in attempt")?; + if found.state.lock().unwrap().terminal() { + return Ok(found.info()); + } + *found.state.lock().unwrap() = LoginState::Submitting; + if found.input.send(Input::Code(code.to_string())).is_err() { + *found.state.lock().unwrap() = LoginState::Failed { + detail: "the sign-in process has stopped".to_string(), + }; + anyhow::bail!("the sign-in process has stopped"); + } + Ok(found.info()) + } + + pub fn cancel(&self, machine: &str, provider: &str, attempt: &str) -> Result { + let attempts = self.attempts.lock().unwrap(); + let found = attempts + .get(&(machine.to_string(), provider.to_string())) + .filter(|found| found.id == attempt) + .context("no such sign-in attempt")?; + if !found.state.lock().unwrap().terminal() { + let _ = found.input.send(Input::Cancel); + } + Ok(found.info()) + } + + /// Interactive helpers are unlike sessions: nothing adopts them after a + /// server restart. End every one while the process is still here to reap + /// the child it launched. + pub fn cancel_all(&self) { + let attempts = self.attempts.lock().unwrap(); + for attempt in attempts.values() { + if !attempt.state.lock().unwrap().terminal() { + let _ = attempt.input.send(Input::Cancel); + } + } + let pending: Vec<_> = attempts + .values() + .map(|attempt| Arc::clone(&attempt.state)) + .collect(); + drop(attempts); + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline + && pending + .iter() + .any(|state| !state.lock().unwrap().terminal()) + { + std::thread::sleep(OUTPUT_POLL); + } + } +} + +fn run_login( + machine: &MachineConfig, + provider: &ProviderConfig, + commands: mpsc::Receiver, + state: &Arc>, +) { + if let Err(err) = run_login_inner(machine, provider, commands, state) { + *state.lock().unwrap() = LoginState::Failed { + detail: format!("couldn't sign in to Claude on {}: {err:#}", machine.name), + }; + } +} + +fn run_login_inner( + machine: &MachineConfig, + provider: &ProviderConfig, + commands: mpsc::Receiver, + state: &Arc>, +) -> Result<()> { + let transport = Transport::for_machine(machine); + let args = vec![ + "BROWSER=/bin/false".to_string(), + provider.program().to_string(), + "auth".to_string(), + "login".to_string(), + "--claudeai".to_string(), + ]; + let host = match &transport { + Transport::Here => None, + Transport::Ssh { ssh, .. } => Some(ssh), + }; + let mut command = crate::ssh::command(host, "env", &args, None, None); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command + .spawn() + .with_context(|| format!("couldn't run {} auth login", provider.program()))?; + let mut stdin = child + .stdin + .take() + .context("the login process has no stdin")?; + let stdout = child + .stdout + .take() + .context("the login process has no stdout")?; + let stderr = child + .stderr + .take() + .context("the login process has no stderr")?; + let (output, lines) = mpsc::channel(); + read_lines(stdout, output.clone()); + read_lines(stderr, output); + + let started = Instant::now(); + let mut authorization_url = None; + let mut last_line = None; + loop { + while let Ok(line) = lines.try_recv() { + if let Some(url) = authorization_url_in(&line) { + authorization_url = Some(url.to_string()); + *state.lock().unwrap() = LoginState::WaitingForCode { + authorization_url: url.to_string(), + detail: None, + }; + } else if line.to_ascii_lowercase().contains("invalid code") { + if let Some(url) = &authorization_url { + *state.lock().unwrap() = LoginState::WaitingForCode { + authorization_url: url.clone(), + detail: Some( + "That code was not accepted. Copy the complete code and try again." + .to_string(), + ), + }; + } + } else if !line.trim().is_empty() { + last_line = Some(line.trim().chars().take(500).collect::()); + } + } + + match commands.recv_timeout(OUTPUT_POLL) { + Ok(Input::Code(code)) => { + *state.lock().unwrap() = LoginState::Submitting; + writeln!(stdin, "{code}").context("couldn't send the login code")?; + stdin.flush().context("couldn't send the login code")?; + } + Ok(Input::Cancel) => { + let _ = child.kill(); + let _ = child.wait(); + *state.lock().unwrap() = LoginState::Cancelled; + return Ok(()); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("the phone disconnected from the sign-in attempt"); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + + if let Some(status) = child + .try_wait() + .context("couldn't check the login process")? + { + *state.lock().unwrap() = if status.success() { + LoginState::Succeeded + } else { + LoginState::Failed { + detail: last_line + .unwrap_or_else(|| format!("Claude's login process exited with {status}")), + } + }; + return Ok(()); + } + if authorization_url.is_none() && started.elapsed() >= AUTHORIZATION_URL_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("the Claude CLI did not provide an authorization URL"); + } + if started.elapsed() >= LOGIN_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("the sign-in attempt expired; start it again"); + } + } +} + +fn read_lines(reader: impl Read + Send + 'static, output: mpsc::Sender) { + std::thread::spawn(move || { + for line in BufReader::new(reader).lines().map_while(Result::ok) { + let _ = output.send(line); + } + }); +} + +fn authorization_url_in(line: &str) -> Option<&str> { + let start = line.find("https://")?; + let tail = &line[start..]; + let end = tail + .find(|character: char| character.is_whitespace() || character == '\u{1b}') + .unwrap_or(tail.len()); + let url = &tail[..end]; + (url.starts_with("https://claude.com/") || url.starts_with("https://platform.claude.com/")) + .then_some(url) +} + +fn valid_code(code: &str) -> Result<&str> { + let code = code.trim(); + anyhow::ensure!(!code.is_empty(), "the login code is empty"); + anyhow::ensure!(code.len() <= 4096, "the login code is too long"); + anyhow::ensure!( + !code.chars().any(char::is_control), + "the login code contains a line break or control character" + ); + Ok(code) +} + +fn attempt_id() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::DriverKind; + + #[test] + fn extracts_only_anthropics_https_login_url() { + assert_eq!( + authorization_url_in("visit: https://claude.com/cai/oauth/authorize?state=x"), + Some("https://claude.com/cai/oauth/authorize?state=x") + ); + assert!(authorization_url_in("visit: http://claude.com/nope").is_none()); + assert!(authorization_url_in("visit: https://example.com/nope").is_none()); + } + + #[test] + fn login_code_is_one_bounded_line() { + assert_eq!(valid_code(" abc#state ").unwrap(), "abc#state"); + assert!(valid_code("\n").is_err()); + assert!(valid_code("a\nb").is_err()); + assert!(valid_code(&"x".repeat(4097)).is_err()); + } + + #[cfg(unix)] + #[test] + fn relays_a_headless_cli_login_without_taking_over_its_credentials() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let cli = dir.path().join("fake-claude"); + std::fs::write( + &cli, + "#!/bin/sh\necho 'https://claude.com/cai/oauth/authorize?state=test'\nIFS= read -r code\n[ \"$code\" = 'the-code' ]\n", + ) + .expect("write fake CLI"); + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o700)) + .expect("make fake CLI executable"); + + let monitor = Arc::new(UsageMonitor::new(Default::default())); + let logins = LoginManager::new(monitor); + let machine = MachineConfig { + id: "vm".to_string(), + name: "test vm".to_string(), + ssh: None, + providers: Vec::new(), + }; + let provider = ProviderConfig { + name: "claude-cli".to_string(), + kind: DriverKind::ClaudeCli, + command: Some(cli.display().to_string()), + models: Vec::new(), + }; + + let started = logins.start(machine, provider); + let ready = logins.wait_until_ready("vm", "claude", &started.attempt); + assert!(matches!(ready.state, LoginState::WaitingForCode { .. })); + let wire = serde_json::to_value(&ready).expect("serialize login state"); + assert!(wire.get("authorizationUrl").is_some(), "{wire}"); + assert!(wire.get("authorization_url").is_none(), "{wire}"); + let submitted = logins + .submit("vm", "claude", &started.attempt, "the-code") + .expect("submit code"); + assert!(matches!(submitted.state, LoginState::Submitting)); + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let finished = logins + .read("vm", "claude", &started.attempt) + .expect("attempt remains readable"); + if matches!(finished.state, LoginState::Succeeded) { + break; + } + assert!( + Instant::now() < deadline, + "login did not finish: {finished:?}" + ); + std::thread::sleep(OUTPUT_POLL); + } + } +} diff --git a/server/src/resume.rs b/server/src/resume.rs index 6d3bc23..b92387a 100644 --- a/server/src/resume.rs +++ b/server/src/resume.rs @@ -48,7 +48,7 @@ const AT_LEAST: f64 = 60.0; /// How long after the limit was hit to stop waiting. /// /// Something has to bound it, or a machine that can never be asked -- an -/// unplugged laptop, a setup somebody edited away -- is retried for ever with +/// unplugged laptop, a machine somebody edited away -- is retried for ever with /// nothing on screen saying so. A day is past the longest window Claude /// reports, so reaching this means the wait was never going to end on its own. const GIVE_UP: f64 = 24.0 * 60.0 * 60.0; @@ -123,7 +123,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc) { Step::Send => match manager.resume_now(&owed.session_id) { Ok(message) => tracing::info!( "the limit on {} has lifted; sent \"{message}\" to {}", - owed.setup, + owed.machine, owed.session_id ), Err(err) => { @@ -144,7 +144,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc) { // to read on a phone. let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) { Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(), - _ => format!("{} could not be asked for a day", owed.setup), + _ => format!("{} could not be asked for a day", owed.machine), }; if let Err(err) = manager.abandon_resume(&owed.session_id, &why) { tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id); @@ -164,18 +164,18 @@ async fn snapshot_for( manager: &SessionManager, owed: &OwedResume, ) -> Option { - let setups: Vec<_> = manager - .setups() + let machines: Vec<_> = manager + .machines() .into_iter() - .filter(|setup| setup.id == owed.setup) + .filter(|machine| machine.id == owed.machine) .collect(); - if setups.is_empty() { + if machines.is_empty() { return None; } let provider = owed.provider; tokio::task::spawn_blocking(move || { monitor - .snapshots(&setups) + .snapshots(&machines) .into_iter() .find(|snapshot| snapshot.provider == provider) }) @@ -258,7 +258,7 @@ mod tests { fn owed(since: f64) -> OwedResume { OwedResume { session_id: "s1".to_string(), - setup: "local".to_string(), + machine: "local".to_string(), provider: crate::usage::CLAUDE, scheduled: ScheduledResume { at: since, since }, } @@ -267,8 +267,8 @@ mod tests { fn snapshot(state: UsageState, windows: Vec) -> UsageSnapshot { UsageSnapshot { provider: crate::usage::CLAUDE.to_string(), - setup: "local".to_string(), - setup_name: "this machine".to_string(), + machine: "local".to_string(), + machine_name: "this machine".to_string(), limit_id: None, limit_name: None, state, diff --git a/server/src/routes.rs b/server/src/routes.rs index 89fd7cc..4a3cf66 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -3,27 +3,31 @@ //! wraps the whole router in. //! //! ```text -//! GET /setups machines, each with what it can run -//! POST /setups add {name, ssh?} -- providers are discovered -//! POST /setups/probe dry run {ssh?}: what would be found there -//! GET /setups/{id} one machine, for refetching after a change -//! GET /setups/{id}/models GGUFs on that machine, for a llama session -//! GET /setups/{id}/providers/{provider}/models models a CLI currently offers -//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved -//! GET /setups/{id}/file?path=P content of file P, or why not -//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256 +//! GET /machines machines, each with what it can run +//! POST /machines add {name, ssh?} -- providers are discovered +//! POST /machines/probe dry run {ssh?}: what would be found there +//! GET /machines/{id} one machine, for refetching after a change +//! GET /machines/{id}/models GGUFs on that machine, for a llama session +//! GET /machines/{id}/providers/{provider}/models models a CLI currently offers +//! POST /machines/{id}/providers/{provider}/auth begin provider sign-in +//! GET /machines/{id}/providers/{provider}/auth/{attempt} sign-in state +//! POST /machines/{id}/providers/{provider}/auth/{attempt}/code submit browser code +//! DELETE /machines/{id}/providers/{provider}/auth/{attempt} cancel sign-in +//! GET /machines/{id}/dir?path=P entries of directory P, and P resolved +//! GET /machines/{id}/file?path=P content of file P, or why not +//! PUT /machines/{id}/file {path, content, ifSha256} -> new size/mtime/sha256 //! (409 when the file no longer matches ifSha256) -//! POST /setups/{id}/file {path} create empty; refused if it exists -//! POST /setups/{id}/dir {path} create; refused if it exists -//! GET /setups/{id}/importable Claude Code sessions on it that could be continued -//! POST /setups/{id}/importable/import {sessions} -> 202; runs on the server -//! POST /setups/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts -//! GET /setups/{id}/importable/events SSE: what is in flight against them -//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?} -//! DELETE /setups/{id} remove, refused while sessions use it +//! POST /machines/{id}/file {path} create empty; refused if it exists +//! POST /machines/{id}/dir {path} create; refused if it exists +//! GET /machines/{id}/importable Claude Code sessions on it that could be continued +//! POST /machines/{id}/importable/import {sessions} -> 202; runs on the server +//! POST /machines/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts +//! GET /machines/{id}/importable/events SSE: what is in flight against them +//! PUT /machines/{id} rename {name?} and/or re-probe {rediscover?} +//! DELETE /machines/{id} remove, refused while sessions use it //! GET /sessions list (id, provider, title, model, status, last activity) //! GET /sessions/{id} one session, for refetching after a change -//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?} +//! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?} //! GET /sessions/{id}/events?after=N SSE: backlog after N, then live //! (a backlog past CATCH_UP_LIMIT arrives as a //! `reset` frame plus the newest window) @@ -114,30 +118,30 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; pub fn router(manager: Arc) -> Router { Router::new() - .route("/setups", get(list_setups).post(add_setup)) - .route("/setups/probe", post(probe_setup)) - .route("/setups/{id}/importable", get(list_importable)) + .route("/machines", get(list_machines).post(add_machine)) + .route("/machines/probe", post(probe_machine)) + .route("/machines/{id}/importable", get(list_importable)) // A batch at a time, never a session at a time -- see // [`delete_importable`]. - .route("/setups/{id}/importable/delete", post(delete_importable)) - .route("/setups/{id}/importable/import", post(start_import)) - .route("/setups/{id}/importable/events", get(importable_events)) + .route("/machines/{id}/importable/delete", post(delete_importable)) + .route("/machines/{id}/importable/import", post(start_import)) + .route("/machines/{id}/importable/events", get(importable_events)) .route( - "/setups/{id}", - get(read_setup).put(update_setup).delete(delete_setup), + "/machines/{id}", + get(read_machine).put(update_machine).delete(delete_machine), ) - // The models on the machine a setup names, for a llama session there. - .route("/setups/{id}/models", get(setup_models)) + // The models on a configured machine, for a llama session there. + .route("/machines/{id}/models", get(machine_models)) .route( - "/setups/{id}/providers/{provider}/models", + "/machines/{id}/providers/{provider}/models", get(provider_models), ) - // The filesystem of the machine a setup names. Under the setup + // The filesystem of a configured machine. Under the machine // rather than under a session because a filesystem is a property of // a machine; a session only says where to start looking. - .route("/setups/{id}/dir", get(list_dir).post(create_dir)) + .route("/machines/{id}/dir", get(list_dir).post(create_dir)) .route( - "/setups/{id}/file", + "/machines/{id}/file", get(read_file).put(write_file).post(create_file), ) .route("/sessions", get(list_sessions).post(spawn_session)) @@ -255,7 +259,7 @@ async fn list_sessions(State(manager): State>) -> axum::Json /// opened from a row carries that snapshot with it. Fine for what a row /// *says* and wrong for what a control is *set to*: a switch drawn from a /// stale row shows the position it had when the list was fetched, and the -/// person reading it cannot tell. Same reason `GET /setups/{id}` exists. +/// person reading it cannot tell. Same reason `GET /machines/{id}` exists. async fn read_session( State(manager): State>, UrlPath(id): UrlPath, @@ -269,7 +273,7 @@ async fn read_session( } /// What the spawn screen needs to render itself, so the phone holds no -/// hardcoded list: a setup added to `config.ron` shows up with no app +/// hardcoded list: a machine added to `config.ron` shows up with no app /// rebuild. /// /// One list rather than two, because the halves are not independent. A @@ -278,12 +282,12 @@ async fn read_session( /// the box that hasn't got it". #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] -struct SetupInfo { +struct MachineInfo { /// Stable; what a session stores and what these routes address. id: String, /// The editable label. name: String, - /// Where it runs, for telling two setups apart. Absent for this machine. + /// Where it runs, for telling two machines apart. Absent for this machine. #[serde(skip_serializing_if = "Option::is_none")] address: Option, providers: Vec, @@ -300,16 +304,16 @@ struct ProviderInfo { default_permission_mode: Option<&'static str>, } -async fn list_setups(State(manager): State>) -> axum::Json> { - axum::Json(manager.setups().into_iter().map(info_for).collect()) +async fn list_machines(State(manager): State>) -> axum::Json> { + axum::Json(manager.machines().into_iter().map(info_for).collect()) } -fn info_for(setup: crate::config::SetupConfig) -> SetupInfo { - SetupInfo { - id: setup.id, - name: setup.name, - address: setup.ssh.map(|ssh| ssh.address), - providers: setup +fn info_for(machine: crate::config::MachineConfig) -> MachineInfo { + MachineInfo { + id: machine.id, + name: machine.name, + address: machine.ssh.map(|ssh| ssh.address), + providers: machine .providers .into_iter() .map(|provider| ProviderInfo { @@ -326,7 +330,7 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo { /// How to reach a machine, as the phone describes it. /// /// Note what is absent: nothing here names a program. Providers are found by -/// asking the machine (`crate::setups`), never sent, so the enrolled token +/// asking the machine (`crate::machines`), never sent, so the enrolled token /// cannot introduce something to run. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -353,7 +357,7 @@ impl SshRequest { /// Tidied at the boundary rather than stored as typed -- this came from a /// phone keyboard, so it may have a stray space or a `~`. fn into_config(self) -> Result { - let address = crate::setups::tidy(&self.address) + let address = crate::machines::tidy(&self.address) .ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?; Ok(crate::config::SshConfig { address, @@ -361,12 +365,12 @@ impl SshRequest { identity_file: self .identity_file .as_deref() - .and_then(crate::setups::tidy) + .and_then(crate::machines::tidy) .map(std::path::PathBuf::from), options: self .options .iter() - .filter_map(|o| crate::setups::tidy(o)) + .filter_map(|o| crate::machines::tidy(o)) .collect(), // Not `tidy`: that expands `~` to *this* machine's home, and this // path is on the other one. The remote shell expands it there. @@ -391,7 +395,7 @@ impl SshRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] -struct AddSetupRequest { +struct AddMachineRequest { name: String, /// Absent means this machine. #[serde(default)] @@ -410,11 +414,11 @@ struct ProbeRequest { ssh: Option, } -async fn probe_setup( +async fn probe_machine( axum::Json(body): axum::Json, ) -> Result>, ApiError> { let ssh = body.ssh.map(SshRequest::into_config).transpose()?; - let providers = probe(ssh, "this setup").await?; + let providers = probe(ssh, "this machine").await?; Ok(axum::Json( providers .into_iter() @@ -443,50 +447,50 @@ async fn probe( }, None => crate::session::transport::Transport::Here, }; - crate::setups::discover(&transport) + crate::machines::discover(&transport) .await .map_err(bad_request) } -async fn add_setup( +async fn add_machine( State(manager): State>, - axum::Json(body): axum::Json, -) -> Result, ApiError> { + axum::Json(body): axum::Json, +) -> Result, ApiError> { let ssh = body.ssh.map(SshRequest::into_config).transpose()?; // Ask the machine being added what it has, before writing anything, so a - // bad address fails here rather than leaving a setup that can never + // bad address fails here rather than leaving a machine that can never // spawn. let providers = probe(ssh.clone(), &body.name).await?; - let setup = manager - .add_setup(&body.name, ssh, providers) + let machine = manager + .add_machine(&body.name, ssh, providers) .map_err(bad_request)?; - Ok(axum::Json(info_for(setup))) + Ok(axum::Json(info_for(machine))) } -/// One setup by id, or the 404 that says so. Three handlers ask this same +/// One machine by id, or the 404 that says so. Three handlers ask this same /// question; the answer, and the wording of the refusal, belong in one place. -fn setup_by_id( +fn machine_by_id( manager: &Arc, id: &str, -) -> Result { +) -> Result { manager - .setups() + .machines() .into_iter() - .find(|setup| setup.id == id) - .ok_or_else(|| ApiError::NotFound(format!("no setup {id}"))) + .find(|machine| machine.id == id) + .ok_or_else(|| ApiError::NotFound(format!("no machine {id}"))) } -async fn read_setup( +async fn read_machine( State(manager): State>, UrlPath(id): UrlPath, -) -> Result, ApiError> { - setup_by_id(&manager, &id).map(|setup| axum::Json(info_for(setup))) +) -> Result, ApiError> { + machine_by_id(&manager, &id).map(|machine| axum::Json(info_for(machine))) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] -struct UpdateSetupRequest { +struct UpdateMachineRequest { #[serde(default)] name: Option, /// Ask the machine again what it has -- after installing something @@ -495,37 +499,37 @@ struct UpdateSetupRequest { rediscover: bool, } -async fn update_setup( +async fn update_machine( State(manager): State>, UrlPath(id): UrlPath, - axum::Json(body): axum::Json, -) -> Result, ApiError> { + axum::Json(body): axum::Json, +) -> Result, ApiError> { let providers = if body.rediscover { let existing = manager - .setups() + .machines() .into_iter() - .find(|setup| setup.id == id) - .ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))?; - let transport = crate::session::transport::Transport::for_setup(&existing); + .find(|machine| machine.id == id) + .ok_or_else(|| ApiError::NotFound(format!("no machine {id}")))?; + let transport = crate::session::transport::Transport::for_machine(&existing); Some( - crate::setups::discover(&transport) + crate::machines::discover(&transport) .await .map_err(bad_request)?, ) } else { None }; - let setup = manager - .update_setup(&id, body.name.as_deref(), providers) + let machine = manager + .update_machine(&id, body.name.as_deref(), providers) .map_err(bad_request)?; - Ok(axum::Json(info_for(setup))) + Ok(axum::Json(info_for(machine))) } -async fn delete_setup( +async fn delete_machine( State(manager): State>, UrlPath(id): UrlPath, ) -> Result { - manager.delete_setup(&id).map_err(bad_request)?; + manager.delete_machine(&id).map_err(bad_request)?; Ok(StatusCode::NO_CONTENT) } @@ -538,10 +542,10 @@ fn files_on( id: &str, path: &str, ) -> Result<(crate::session::transport::Transport, String), ApiError> { - let setup = setup_by_id(manager, id)?; + let machine = machine_by_id(manager, id)?; let path = crate::files::check_path(path).map_err(bad_request)?; Ok(( - crate::session::transport::Transport::for_setup(&setup), + crate::session::transport::Transport::for_machine(&machine), path, )) } @@ -566,15 +570,15 @@ struct PathQuery { /// /// Not `GET /models`, which is this backend's own downloads: those are on /// the machine a session runs on only when they are the same machine. A -/// spawn screen offering this backend's list for a remote setup would be +/// spawn screen offering this backend's list for a remote machine would be /// naming files that are not there, and the session would fail at the /// point of loading rather than at the point of choosing. -async fn setup_models( +async fn machine_models( State(manager): State>, UrlPath(id): UrlPath, ) -> Result>, ApiError> { - let setup = setup_by_id(&manager, &id)?; - let transport = crate::session::transport::Transport::for_setup(&setup); + let machine = machine_by_id(&manager, &id)?; + let transport = crate::session::transport::Transport::for_machine(&machine); let dir = crate::models::dir_on(&transport, manager.models_dir()); crate::models::on_machine(&transport, &dir) .await @@ -582,19 +586,19 @@ async fn setup_models( .map_err(from_machine) } -/// The models a CLI provider currently offers on the setup's machine. +/// The models a CLI provider currently offers on its configured machine. /// Codex answers from its live account catalog; providers with a configured /// shortcut list return that list. async fn provider_models( State(manager): State>, UrlPath((id, provider_name)): UrlPath<(String, String)>, ) -> Result>, ApiError> { - let setup = setup_by_id(&manager, &id)?; - let provider = setup.provider(&provider_name).ok_or_else(|| { - ApiError::NotFound(format!("no provider {provider_name} on {}", setup.name)) + let machine = machine_by_id(&manager, &id)?; + let provider = machine.provider(&provider_name).ok_or_else(|| { + ApiError::NotFound(format!("no provider {provider_name} on {}", machine.name)) })?; - let transport = crate::session::transport::Transport::for_setup(&setup); - crate::setups::provider_models(&transport, provider) + let transport = crate::session::transport::Transport::for_machine(&machine); + crate::machines::provider_models(&transport, provider) .await .map(axum::Json) .map_err(from_machine) @@ -704,7 +708,7 @@ async fn create_dir( #[serde(deny_unknown_fields)] struct SpawnRequest { /// Which machine, and which of the things it offers. - setup: String, + machine: String, provider: String, #[serde(default)] title: Option, @@ -721,7 +725,7 @@ struct SpawnRequest { #[serde(default)] params: std::collections::BTreeMap, /// Continue a Claude Code session the machine already has, named by the id - /// `GET /setups/{id}/importable` reported. + /// `GET /machines/{id}/importable` reported. /// /// An id and not a path, deliberately: the server looks the path up again /// among the sessions it enumerated, so an enrolled token cannot turn this @@ -735,8 +739,8 @@ async fn list_importable( State(manager): State>, UrlPath(id): UrlPath, ) -> Result>, ApiError> { - let setup = setup_by_id(&manager, &id)?; - let transport = crate::session::transport::Transport::for_setup(&setup); + let machine = machine_by_id(&manager, &id)?; + let transport = crate::session::transport::Transport::for_machine(&machine); let mut found = crate::session::import::list(&transport) .await .map_err(bad_request)?; @@ -814,8 +818,8 @@ async fn delete_importable( UrlPath(id): UrlPath, axum::Json(body): axum::Json, ) -> Result { - let setup = setup_by_id(&manager, &id)?; - let transport = crate::session::transport::Transport::for_setup(&setup); + let machine = machine_by_id(&manager, &id)?; + let transport = crate::session::transport::Transport::for_machine(&machine); // Registered before anything is spawned, so the 202 is only sent once // every row is already showing "deleting" -- a phone that refetches the // instant it gets the reply cannot catch a row that has not started. @@ -892,10 +896,10 @@ async fn start_import( ) -> Result { // Checked before accepting, so an unknown machine is an error the caller // sees rather than one it has to go and read off a row. - setup_by_id(&manager, &id)?; + machine_by_id(&manager, &id)?; for session in body.sessions { let request = SpawnRequest { - setup: id.clone(), + machine: id.clone(), provider: body.provider.clone(), // Nothing to say: `spawn` titles an import from the session it // continues, and the cwd comes from the same place. @@ -946,22 +950,25 @@ struct ImportRequest { /// is the pending registry. fn in_background( manager: &Arc, - setup: String, + machine: String, session: String, operation: Operation, work: F, ) where F: std::future::Future> + Send + 'static, { - let running = manager.pending().begin(&setup, &session, operation); + let running = manager.pending().begin(&machine, &session, operation); tokio::spawn(async move { match work.await { Ok(()) => { - tracing::info!("{} {session} on {setup}: done", operation.label()); + tracing::info!("{} {session} on {machine}: done", operation.label()); running.succeeded(); } Err(err) => { - tracing::warn!("{} {session} on {setup} failed: {err:#}", operation.label()); + tracing::warn!( + "{} {session} on {machine} failed: {err:#}", + operation.label() + ); // The server's own words, the way every other failure in this // app reaches a person. running.failed(format!("{err:#}")); @@ -970,7 +977,7 @@ fn in_background( }); } -/// Every change to what is in flight against one machine. Scoped to the setup +/// Every change to what is in flight against one machine. Scoped to the machine /// the screen is showing, the same way a session's events are scoped to that /// session. async fn importable_events( @@ -983,7 +990,7 @@ async fn importable_events( // that is what the listing is for: the screen refetches on arrival and // carries the truth whatever this stream missed. let change = item.ok()?; - if change.setup() != id { + if change.machine() != id { return None; } Some(Ok(SseEvent::default().json_data(&change).ok()?)) @@ -1010,15 +1017,15 @@ async fn spawn(manager: &Arc, body: SpawnRequest) -> Result { - let setup = setup_by_id(manager, &body.setup)?; - let transport = crate::session::transport::Transport::for_setup(&setup); + let machine = machine_by_id(manager, &body.machine)?; + let transport = crate::session::transport::Transport::for_machine(&machine); let chosen = crate::session::import::find(&transport, want) .await .map_err(bad_request)? .ok_or_else(|| { ApiError::NotFound(format!( - "setup \"{}\" has no Claude Code session {want} to import", - body.setup + "machine \"{}\" has no Claude Code session {want} to import", + body.machine )) })?; if let Some(existing) = manager.session_driving(want) { @@ -1065,7 +1072,7 @@ async fn spawn(manager: &Arc, body: SpawnRequest) -> Result, + manager: Arc, +} + +/// Separate state from the session routes because login attempts are +/// short-lived provider processes, not conversations to persist or adopt. +pub fn provider_auth_router( + logins: Arc, + manager: Arc, +) -> Router { + Router::new() + .route( + "/machines/{id}/providers/{provider}/auth", + post(start_provider_auth), + ) + .route( + "/machines/{id}/providers/{provider}/auth/{attempt}", + get(read_provider_auth).delete(cancel_provider_auth), + ) + .route( + "/machines/{id}/providers/{provider}/auth/{attempt}/code", + post(submit_provider_auth_code), + ) + .with_state(ProviderAuthState { logins, manager }) +} + +fn provider_auth_target( + manager: &Arc, + machine_id: &str, + provider_name: &str, +) -> Result< + ( + crate::config::MachineConfig, + crate::config::ProviderConfig, + &'static str, + ), + ApiError, +> { + let machine = machine_by_id(manager, machine_id)?; + let provider = machine.provider(provider_name).cloned().ok_or_else(|| { + ApiError::NotFound(format!("no provider {provider_name} on {}", machine.name)) + })?; + let key = provider.kind.usage_provider().ok_or_else(|| { + ApiError::BadRequest(format!( + "{} does not have an account to sign in to", + provider.name + )) + })?; + if key != crate::usage::CLAUDE { + return Err(ApiError::BadRequest(format!( + "{} does not support sign-in through the app", + provider.name + ))); + } + Ok((machine, provider, key)) +} + +async fn start_provider_auth( + State(state): State, + UrlPath((machine_id, provider_name)): UrlPath<(String, String)>, +) -> Result, ApiError> { + let (machine, provider, key) = + provider_auth_target(&state.manager, &machine_id, &provider_name)?; + let info = state.logins.start(machine, provider); + let logins = Arc::clone(&state.logins); + let info = tokio::task::spawn_blocking(move || { + logins.wait_until_ready(&machine_id, key, &info.attempt) + }) + .await + .context("provider sign-in worker panicked")?; + Ok(axum::Json(info)) +} + +async fn read_provider_auth( + State(state): State, + UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>, +) -> Result, ApiError> { + let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?; + state + .logins + .read(&machine_id, key, &attempt) + .map(axum::Json) + .ok_or_else(|| ApiError::NotFound("no such sign-in attempt".to_string())) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ProviderAuthCode { + code: String, +} + +async fn submit_provider_auth_code( + State(state): State, + UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?; + state + .logins + .submit(&machine_id, key, &attempt, &body.code) + .map(axum::Json) + .map_err(bad_request) +} + +async fn cancel_provider_auth( + State(state): State, + UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>, +) -> Result, ApiError> { + let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?; + state + .logins + .cancel(&machine_id, key, &attempt) + .map(axum::Json) + .map_err(bad_request) +} + async fn usage( State(state): State, ) -> Result>, ApiError> { // Read here rather than inside the fetch, so the list of machines is the // one that existed when the request arrived and cannot change under a // fetch that takes an ssh round trip per machine. - let setups = state.manager.setups(); + let machines = state.manager.machines(); // The fetch is blocking by design (see `usage`); off the workers. - let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&setups)) + let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&machines)) .await .context("usage fetch panicked")?; Ok(axum::Json(snapshots)) @@ -1348,7 +1473,7 @@ struct CwdRequest { /// Moves a session to a different working directory. /// /// The directory is checked here rather than in the manager because checking -/// it is an ssh round trip on a remote setup, and the manager is not async. +/// it is an ssh round trip on a remote machine, and the manager is not async. /// /// Checked rather than trusted, and refused rather than corrected: a mistyped /// path that was accepted would leave a session recorded somewhere its process @@ -1372,20 +1497,20 @@ async fn set_cwd( // launched from, which is not something the person typing it can see. The // same question the explorer asks of every path, asked in one place. let cwd = crate::files::check_path(&body.cwd.to_string_lossy()).map_err(bad_request)?; - let setup = setup_by_id(&manager, &session.setup)?; - let transport = crate::session::transport::Transport::for_setup(&setup); + let machine = machine_by_id(&manager, &session.machine)?; + let transport = crate::session::transport::Transport::for_machine(&machine); if !crate::session::import::directory_exists(&transport, &cwd).await { return Err(ApiError::BadRequest(format!( "{} has no directory {cwd}", - setup.name + machine.name ))); } // Stored in the short form, so the one path kept is the one the phone will // draw -- rather than storing `/home/bob/…` and abbreviating it again at // each place it is shown, which is two representations of one directory. - // Only where the setup runs here; see `setups::shorten_home`. - let stored = if setup.ssh.is_none() { - crate::setups::shorten_home(&cwd) + // Only where the machine runs here; see `machines::shorten_home`. + let stored = if machine.ssh.is_none() { + crate::machines::shorten_home(&cwd) } else { cwd.clone() }; diff --git a/server/src/session/import.rs b/server/src/session/import.rs index b9bfc74..bfd8048 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -13,7 +13,7 @@ //! //! **The phone never names a file.** It picks an id out of what this //! module enumerated, and the path is looked up again on the server -- the -//! same rule the setups model follows for providers, and for the same +//! same rule the machines model follows for providers, and for the same //! reason: an enrolled token must not be able to turn into "read me this //! arbitrary path". diff --git a/server/src/session/llama.rs b/server/src/session/llama.rs index 8f7f297..6f93fbc 100644 --- a/server/src/session/llama.rs +++ b/server/src/session/llama.rs @@ -12,7 +12,7 @@ //! command carries the tunnel between them. The far `llama-server` binds //! loopback only, so a model is never served to that machine's network. //! -//! **The model file is the far machine's, not this one's.** A remote setup +//! **The model file is the far machine's, not this one's.** A remote machine //! names its own models directory (`SshConfig::models_dir`, defaulting to where //! this backend keeps its downloads), and the file is looked for *there* -- so //! a session naming a model that machine does not have says so, instead of diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 91af508..2d8b69e 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -30,8 +30,8 @@ use serde::Serialize; use tokio::sync::{broadcast, mpsc}; use crate::config::{ - Config, DEFAULT_RESUME_MESSAGE, DriverKind, ProviderConfig, ScheduledResume, SessionConfig, - SetupConfig, SshConfig, TokenEntry, + Config, DEFAULT_RESUME_MESSAGE, DriverKind, MachineConfig, ProviderConfig, ScheduledResume, + SessionConfig, SshConfig, TokenEntry, }; use claude::ClaudeDriver; use codex::CodexDriver; @@ -100,7 +100,7 @@ pub fn now() -> f64 { } pub struct SpawnSpec { - pub setup: String, + pub machine: String, pub provider: String, pub title: Option, pub model: Option, @@ -172,7 +172,7 @@ impl AutoResumeView { pub struct OwedResume { pub session_id: String, /// The machine whose account ran out, which is the one to ask. - pub setup: String, + pub machine: String, /// Which meter reports on it -- a `crate::usage::UsageProvider::name`, the /// same pairing `SessionInfo::usage_provider` uses. pub provider: &'static str, @@ -193,11 +193,11 @@ fn resume_message(meta: &SessionConfig) -> String { pub struct SessionInfo { pub id: String, pub provider: String, - pub setup: String, + pub machine: String, /// That machine's current label, resolved when this row is built, so - /// renaming a setup renames it everywhere rather than leaving old + /// renaming a machine renames it everywhere rather than leaving old /// sessions showing the old name. - pub setup_name: String, + pub machine_name: String, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -562,7 +562,7 @@ impl LiveSession { Ok((name, path)) } - /// `setup_name` and `cwd` are passed in rather than read from the + /// `machine_name` and `cwd` are passed in rather than read from the /// snapshot this session launched with: only the manager holds the /// config, and both can change under a running session. Passed rather /// than mirrored into `Shared`, so there is one answer, read where the @@ -574,7 +574,7 @@ impl LiveSession { /// cautious one. fn info( &self, - setup_name: &str, + machine_name: &str, cwd: Option<&Path>, effort: Option<&str>, imported: bool, @@ -584,8 +584,8 @@ impl LiveSession { SessionInfo { id: self.meta.id.clone(), provider: self.meta.provider.clone(), - setup: self.meta.setup.clone(), - setup_name: setup_name.to_string(), + machine: self.meta.machine.clone(), + machine_name: machine_name.to_string(), title: self.shared.title.lock().unwrap().clone(), model: self.shared.model.lock().unwrap().clone(), permission_mode: self.shared.permission_mode.lock().unwrap().clone(), @@ -646,7 +646,7 @@ pub struct SessionManager { /// The CLI-owned copy optionally removed with an ai-app session. #[derive(Debug, PartialEq, Eq)] pub struct ForeignTranscript { - pub setup: String, + pub machine: String, pub id: String, kind: DriverKind, } @@ -702,10 +702,10 @@ impl SessionManager { // One unlaunchable session -- a corrupt transcript, an // unreachable host, a provider edited away -- shows as exited // rather than taking the server down, and can still be deleted. - match resolve(&config, meta).and_then(|(setup, provider)| { + match resolve(&config, meta).and_then(|(machine, provider)| { launch( meta.clone(), - &setup, + &machine, &provider, Env { data_dir: &data_dir, @@ -777,7 +777,7 @@ impl SessionManager { self } - /// Writes this machine into a config that has no setups, with the + /// Writes this machine into a config that has no machines, with the /// providers actually found on it. /// /// Discovered rather than assumed. This used to write a `claude-cli` @@ -789,16 +789,16 @@ impl SessionManager { /// this server runs, and says so in the log. Seeding the hardcoded list /// would be the original bug with an extra step, and seeding nothing /// leaves a fresh install with nothing to prove the pipe with. - pub async fn seed_setup(&self) -> Result<()> { - if !self.inner.read().unwrap().config.setups.is_empty() { + pub async fn seed_machine(&self) -> Result<()> { + if !self.inner.read().unwrap().config.machines.is_empty() { return Ok(()); } - let providers = match crate::setups::discover(&transport::Transport::Here).await { + let providers = match crate::machines::discover(&transport::Transport::Here).await { Ok(found) => found, Err(err) => { tracing::warn!( "couldn't ask this machine what it has ({err}); seeding {} only -- \ - re-probe the setup from the app once that is fixed", + re-probe the machine from the app once that is fixed", crate::config::ECHO_PROVIDER ); vec![Config::echo_provider()] @@ -807,16 +807,16 @@ impl SessionManager { let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect(); let mut inner = self.inner.write().unwrap(); - if !inner.config.setups.is_empty() { + if !inner.config.machines.is_empty() { return Ok(()); } let mut candidate = inner.config.clone(); - candidate.setups.push(Config::seed(providers.clone())); + candidate.machines.push(Config::seed(providers.clone())); candidate.save(&self.config_path)?; inner.config = candidate; tracing::info!( - "no setups configured -- added \"{}\" with: {}", - crate::config::LOCAL_SETUP, + "no machines configured -- added \"{}\" with: {}", + crate::config::LOCAL_MACHINE, names.join(", ") ); Ok(()) @@ -839,80 +839,80 @@ impl SessionManager { /// /// `providers` comes from probing rather than from the caller: the /// probe is async and this is not, so the route asks and this writes. - pub fn add_setup( + pub fn add_machine( &self, name: &str, ssh: Option, providers: Vec, - ) -> Result { + ) -> Result { let name = name.trim().to_string(); if name.is_empty() { - bail!("a setup needs a name"); + bail!("a machine needs a name"); } self.update(|config| { - if config.setup_named(&name).is_some() { - bail!("there is already a setup called \"{name}\""); + if config.machine_named(&name).is_some() { + bail!("there is already a machine called \"{name}\""); } // Ids are derived once and then fixed, so a label can be // edited later without orphaning the sessions that named it. - let mut id = crate::setups::id_from(&name); - while config.setup(&id).is_some() { + let mut id = crate::machines::id_from(&name); + while config.machine(&id).is_some() { id = format!("{id}-{}", &random_hex()[..4]); } - let setup = SetupConfig { + let machine = MachineConfig { id, name: name.clone(), ssh, providers, }; - config.setups.push(setup.clone()); - Ok(setup) + config.machines.push(machine.clone()); + Ok(machine) }) } - pub fn update_setup( + pub fn update_machine( &self, id: &str, name: Option<&str>, providers: Option>, - ) -> Result { + ) -> Result { self.update(|config| { if let Some(name) = name { let name = name.trim(); if name.is_empty() { - bail!("a setup needs a name"); + bail!("a machine needs a name"); } - if config.setups.iter().any(|s| s.name == name && s.id != id) { - bail!("there is already a setup called \"{name}\""); + if config.machines.iter().any(|s| s.name == name && s.id != id) { + bail!("there is already a machine called \"{name}\""); } } - let setup = config - .setups + let machine = config + .machines .iter_mut() - .find(|setup| setup.id == id) - .with_context(|| format!("no setup with id \"{id}\""))?; + .find(|machine| machine.id == id) + .with_context(|| format!("no machine with id \"{id}\""))?; if let Some(name) = name { - setup.name = name.trim().to_string(); + machine.name = name.trim().to_string(); } if let Some(providers) = providers { - setup.providers = providers; + machine.providers = providers; } - Ok(setup.clone()) + Ok(machine.clone()) }) } /// Removes a machine, provided nothing is still running on it. /// Refused rather than cascaded: the person asking is better placed to /// decide which of those sessions they still want. - pub fn delete_setup(&self, id: &str) -> Result<()> { + pub fn delete_machine(&self, id: &str) -> Result<()> { self.update(|config| { - if config.setup(id).is_none() { - bail!("no setup with id \"{id}\""); + if config.machine(id).is_none() { + bail!("no machine with id \"{id}\""); } let using: Vec<&str> = config .sessions .iter() - .filter(|session| session.setup == id) + .filter(|session| session.machine == id) .map(|session| session.title.as_str()) .collect(); if !using.is_empty() { @@ -922,7 +922,7 @@ impl SessionManager { using.join(", "), ); } - config.setups.retain(|setup| setup.id != id); + config.machines.retain(|machine| machine.id != id); Ok(()) }) } @@ -1061,18 +1061,18 @@ impl SessionManager { pub fn remote_of(&self, id: &str) -> Option<(crate::config::SshConfig, Option)> { let inner = self.inner.read().unwrap(); let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?; - let setup = inner + let machine = inner .config - .setups + .machines .iter() - .find(|setup| setup.id == meta.setup)?; - Some((setup.ssh.clone()?, meta.cwd.clone())) + .find(|machine| machine.id == meta.machine)?; + Some((machine.ssh.clone()?, meta.cwd.clone())) } pub fn foreign_transcript(&self, id: &str) -> Option { let inner = self.inner.read().unwrap(); let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?; - let kind = kind_of(&inner.config, &meta.setup, &meta.provider)?; + let kind = kind_of(&inner.config, &meta.machine, &meta.provider)?; let session_dir = self.data_dir.join(&meta.id); let foreign = match kind { DriverKind::ClaudeCli => { @@ -1085,7 +1085,7 @@ impl SessionManager { DriverKind::Echo | DriverKind::LlamaCpp => None, }?; Some(ForeignTranscript { - setup: meta.setup.clone(), + machine: meta.machine.clone(), id: foreign, kind, }) @@ -1101,28 +1101,28 @@ impl SessionManager { .iter() .map(|meta| match inner.live.get(&meta.id) { Some(session) => session.info( - label_of(&inner.config, &meta.setup), + label_of(&inner.config, &meta.machine), meta.cwd.as_deref(), meta.effort.as_deref(), import::read_cursor(&self.data_dir.join(&meta.id)).is_some(), - kind_of(&inner.config, &meta.setup, &meta.provider), + kind_of(&inner.config, &meta.machine, &meta.provider), AutoResumeView::of(meta), ), None => SessionInfo { id: meta.id.clone(), - setup: meta.setup.clone(), - setup_name: label_of(&inner.config, &meta.setup).to_string(), + machine: meta.machine.clone(), + machine_name: label_of(&inner.config, &meta.machine).to_string(), provider: meta.provider.clone(), title: meta.title.clone(), model: meta.model.clone(), permission_mode: meta.permission_mode.clone(), effort: meta.effort.clone(), - takes_effort: kind_of(&inner.config, &meta.setup, &meta.provider) + takes_effort: kind_of(&inner.config, &meta.machine, &meta.provider) .is_some_and(DriverKind::takes_effort), context_tokens: None, - max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider) + max_image_edge: kind_of(&inner.config, &meta.machine, &meta.provider) .and_then(DriverKind::max_image_edge), - usage_provider: kind_of(&inner.config, &meta.setup, &meta.provider) + usage_provider: kind_of(&inner.config, &meta.machine, &meta.provider) .and_then(DriverKind::usage_provider), notify: meta.notify, auto_resume: meta.auto_resume, @@ -1131,10 +1131,10 @@ impl SessionManager { imported: import::read_cursor(&self.data_dir.join(&meta.id)).is_some(), keeps_own_transcript: keeps_own_transcript( &inner.config, - &meta.setup, + &meta.machine, &meta.provider, ), - own_transcript_name: kind_of(&inner.config, &meta.setup, &meta.provider) + own_transcript_name: kind_of(&inner.config, &meta.machine, &meta.provider) .and_then(DriverKind::own_transcript_name), cwd: meta.cwd.clone(), status: status_of_unlaunched(&self.data_dir.join(&meta.id)), @@ -1172,8 +1172,8 @@ impl SessionManager { /// Every machine this server can run something on, each with what it /// can run. One list rather than two, because the pair is the choice. - pub fn setups(&self) -> Vec { - self.inner.read().unwrap().config.setups.clone() + pub fn machines(&self) -> Vec { + self.inner.read().unwrap().config.machines.clone() } pub fn spawn_session(&self, spec: SpawnSpec) -> Result { @@ -1191,28 +1191,28 @@ impl SessionManager { fn spawn_seeded(&self, spec: SpawnSpec, seed: Option) -> Result { let mut inner = self.inner.write().unwrap(); - let setup = inner + let machine = inner .config - .setup(&spec.setup) + .machine(&spec.machine) .with_context(|| { format!( - "no setup with id \"{}\" -- configured: {}", - spec.setup, + "no machine with id \"{}\" -- configured: {}", + spec.machine, // Ids, since that is what was looked up. Labels made - // the failure read as a contradiction: "no setup named + // the failure read as a contradiction: "no machine named // X -- configured: X". - names(inner.config.setups.iter().map(|s| s.id.as_str())), + names(inner.config.machines.iter().map(|s| s.id.as_str())), ) })? .clone(); - let provider = setup + let provider = machine .provider(&spec.provider) .with_context(|| { format!( - "setup \"{}\" has no provider named \"{}\" -- it offers: {}", - spec.setup, + "machine \"{}\" has no provider named \"{}\" -- it offers: {}", + spec.machine, spec.provider, - names(setup.providers.iter().map(|p| p.name.as_str())), + names(machine.providers.iter().map(|p| p.name.as_str())), ) })? .clone(); @@ -1223,7 +1223,7 @@ impl SessionManager { .unwrap_or_else(|| format!("{} session", provider.name)); let meta = SessionConfig { id: id.clone(), - setup: setup.id.clone(), + machine: machine.id.clone(), provider: provider.name.clone(), title, // No model unless one was chosen. This used to fall back to the @@ -1267,7 +1267,7 @@ impl SessionManager { let session = launch( meta.clone(), - &setup, + &machine, &provider, self.env(), self.announce.clone(), @@ -1286,7 +1286,7 @@ impl SessionManager { // Whether this one was seeded, the same question the listing asks // of the directory a moment later. let info = session.info( - &setup.name, + &machine.name, session.meta.cwd.as_deref(), session.meta.effort.as_deref(), import::read_cursor(&self.data_dir.join(&id)).is_some(), @@ -1442,8 +1442,8 @@ impl SessionManager { let scheduled = meta.resume?; Some(OwedResume { session_id: meta.id.clone(), - setup: meta.setup.clone(), - provider: kind_of(&inner.config, &meta.setup, &meta.provider)? + machine: meta.machine.clone(), + provider: kind_of(&inner.config, &meta.machine, &meta.provider)? .usage_provider()?, scheduled, }) @@ -1628,7 +1628,7 @@ impl SessionManager { /// it entirely. /// /// Whether the directory exists is the caller's question, because - /// asking it is an ssh round trip on a remote setup; see the route. + /// asking it is an ssh round trip on a remote machine; see the route. pub fn set_session_cwd(&self, id: &str, cwd: PathBuf) -> Result<()> { { let mut inner = self.inner.write().unwrap(); @@ -1889,7 +1889,7 @@ impl SessionManager { // Fresh from the config, like every other launch: a model or a // permission mode changed while the session was stopped is what it // starts with. - let (setup, provider) = resolve(&inner.config, &meta)?; + let (machine, provider) = resolve(&inner.config, &meta)?; match existing { Some(session) => { // Replacing the value a driver lives in does not end the @@ -1901,7 +1901,7 @@ impl SessionManager { } *session.driver.lock().unwrap() = Some(make_driver( &meta, - &setup, + &machine, &provider, self.env(), session.dir(), @@ -1915,7 +1915,7 @@ impl SessionManager { None => { let session = launch( meta, - &setup, + &machine, &provider, self.env(), self.announce.clone(), @@ -2050,24 +2050,26 @@ fn adoptable(session_dir: &Path) -> bool { /// The provider and host a session's config names, or a message saying /// which one is missing. Both are looked up fresh at every launch, so /// editing either takes effect on the next respawn. -fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, ProviderConfig)> { - let setup = config - .setup(&meta.setup) - .with_context(|| format!("no setup named \"{}\"", meta.setup))?; - let provider = setup.provider(&meta.provider).with_context(|| { +fn resolve(config: &Config, meta: &SessionConfig) -> Result<(MachineConfig, ProviderConfig)> { + let machine = config + .machine(&meta.machine) + .with_context(|| format!("no machine named \"{}\"", meta.machine))?; + let provider = machine.provider(&meta.provider).with_context(|| { format!( - "setup \"{}\" has no provider named \"{}\"", - meta.setup, meta.provider + "machine \"{}\" has no provider named \"{}\"", + meta.machine, meta.provider ) })?; - Ok((setup.clone(), provider.clone())) + Ok((machine.clone(), provider.clone())) } -/// A setup's current label, or its id when the setup has been deleted -- +/// A machine's current label, or its id when the machine has been deleted -- /// which is what a session left behind by a removed machine shows, and is /// better than an empty column or a guess at what it used to be called. fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str { - config.setup(id).map_or(id, |setup| setup.name.as_str()) + config + .machine(id) + .map_or(id, |machine| machine.name.as_str()) } /// The two ways a session directory can name a Claude Code conversation: @@ -2094,21 +2096,21 @@ fn foreign_ids(dir: &Path) -> (Option, Option) { /// app's delete cannot reach. /// /// False when the provider can't be found, which is the safe way round: a -/// setup or provider removed from the config leaves sessions naming one +/// machine or provider removed from the config leaves sessions naming one /// that is gone, and the warning that then shows is the strong one. Saying /// "this can be brought back" on no evidence is the answer that loses /// somebody's conversation. -fn keeps_own_transcript(config: &Config, setup: &str, provider: &str) -> bool { - kind_of(config, setup, provider).is_some_and(DriverKind::keeps_own_transcript) +fn keeps_own_transcript(config: &Config, machine: &str, provider: &str) -> bool { + kind_of(config, machine, provider).is_some_and(DriverKind::keeps_own_transcript) } /// What a session's provider is, for the questions answered by its *kind* /// rather than by its name. `None` for a provider that has been edited away, /// which is a session that cannot run at all. -fn kind_of(config: &Config, setup: &str, provider: &str) -> Option { +fn kind_of(config: &Config, machine: &str, provider: &str) -> Option { config - .setup(setup) - .and_then(|setup| setup.providers.iter().find(|it| it.name == provider)) + .machine(machine) + .and_then(|machine| machine.providers.iter().find(|it| it.name == provider)) .map(|provider| provider.kind) } @@ -2310,7 +2312,7 @@ struct Env<'a> { /// process for it to speak to. See [`Launching`] for when that is. fn launch( meta: SessionConfig, - setup: &SetupConfig, + machine: &MachineConfig, provider: &ProviderConfig, env: Env<'_>, announce: Announcements, @@ -2405,7 +2407,7 @@ fn launch( && shared.context_tokens.lock().unwrap().is_none() && let Some(session_id) = claude::read_resume_token(&dir) { - let transport = Transport::for_setup(setup); + let transport = Transport::for_machine(machine); let shared = Arc::clone(&shared); tokio::spawn(async move { if let Some(context) = import::context_of(&transport, &session_id).await { @@ -2423,7 +2425,7 @@ fn launch( && shared.context_tokens.lock().unwrap().is_none() && let Some(thread_id) = codex::read_thread(&dir) { - let transport = Transport::for_setup(setup); + let transport = Transport::for_machine(machine); let shared = Arc::clone(&shared); tokio::spawn(async move { if let Some(context) = codex::context_of(&transport, &thread_id).await { @@ -2441,7 +2443,7 @@ fn launch( // anybody pressing anything. if let Some(cursor) = import::read_cursor(&dir) { spawn_import_sync( - Transport::for_setup(setup), + Transport::for_machine(machine), dir.clone(), cursor, sink.clone(), @@ -2454,7 +2456,7 @@ fn launch( .then(|| { make_driver( &meta, - setup, + machine, provider, env, &dir, @@ -2505,7 +2507,7 @@ fn launch( #[allow(clippy::too_many_arguments)] fn make_driver( meta: &SessionConfig, - setup: &SetupConfig, + machine: &MachineConfig, provider: &ProviderConfig, env: Env<'_>, dir: &Path, @@ -2525,7 +2527,7 @@ fn make_driver( DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch( meta, provider, - &Transport::for_setup(setup), + &Transport::for_machine(machine), env.models_dir, transcript_path, dir, @@ -2535,7 +2537,7 @@ fn make_driver( DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch( meta, provider, - &Transport::for_setup(setup), + &Transport::for_machine(machine), dir, sink.clone(), Arc::clone(subagents), @@ -2543,7 +2545,7 @@ fn make_driver( DriverKind::CodexCli => Arc::new(CodexDriver::launch( meta, provider, - Transport::for_setup(setup), + Transport::for_machine(machine), dir, sink.clone(), )?), @@ -2799,7 +2801,7 @@ mod tests { fn echo_spec() -> SpawnSpec { SpawnSpec { params: Default::default(), - setup: crate::config::LOCAL_SETUP_ID.to_string(), + machine: crate::config::LOCAL_MACHINE_ID.to_string(), provider: crate::config::ECHO_PROVIDER.to_string(), title: None, model: None, @@ -2857,7 +2859,7 @@ mod tests { /// depending on whether `claude` happens to be installed. fn seed_echo_only(config_path: &std::path::Path) { Config { - setups: vec![Config::seed(vec![Config::echo_provider()])], + machines: vec![Config::seed(vec![Config::echo_provider()])], ..Config::default() } .save(config_path) @@ -3356,7 +3358,7 @@ mod tests { assert_eq!( manager.foreign_transcript(&info.id), Some(ForeignTranscript { - setup: info.setup.clone(), + machine: info.machine.clone(), id: "5ecf21da-d53f".to_string(), kind: DriverKind::ClaudeCli, }) @@ -3979,7 +3981,7 @@ mod tests { std::fs::write(&command, "#!/bin/sh\ncat > /dev/null\n").expect("write stand-in"); std::fs::set_permissions(&command, std::fs::Permissions::from_mode(0o755)).expect("chmod"); Config { - setups: vec![Config::seed(vec![ + machines: vec![Config::seed(vec![ Config::echo_provider(), ProviderConfig { name: "stand-in".to_string(), diff --git a/server/src/session/pending.rs b/server/src/session/pending.rs index 6ed2915..23e24e6 100644 --- a/server/src/session/pending.rs +++ b/server/src/session/pending.rs @@ -48,16 +48,16 @@ impl Operation { #[serde(rename_all = "camelCase", tag = "state")] pub enum Change { Started { - setup: String, + machine: String, session: String, operation: Operation, }, Finished { - setup: String, + machine: String, session: String, }, Failed { - setup: String, + machine: String, session: String, message: String, }, @@ -66,11 +66,11 @@ pub enum Change { impl Change { /// Which machine this is about, so a stream scoped to one can drop the rest. /// Every variant carries it; matching here keeps that fact in one place. - pub fn setup(&self) -> &str { + pub fn machine(&self) -> &str { match self { - Self::Started { setup, .. } - | Self::Finished { setup, .. } - | Self::Failed { setup, .. } => setup, + Self::Started { machine, .. } + | Self::Finished { machine, .. } + | Self::Failed { machine, .. } => machine, } } } @@ -106,12 +106,12 @@ impl Registry { /// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports /// a failure. Dropping without settling means the task was cancelled or /// panicked, and a row stuck on "importing" for ever is a worse answer. - pub fn begin(self: &Arc, setup: &str, session: &str, operation: Operation) -> InFlight { - let key = (setup.to_string(), session.to_string()); + pub fn begin(self: &Arc, machine: &str, session: &str, operation: Operation) -> InFlight { + let key = (machine.to_string(), session.to_string()); self.running.lock().unwrap().insert(key.clone(), operation); self.failures.lock().unwrap().remove(&key); let _ = self.changes.send(Change::Started { - setup: key.0.clone(), + machine: key.0.clone(), session: key.1.clone(), operation, }); @@ -123,25 +123,25 @@ impl Registry { } /// What is happening to this session, if anything is. - pub fn running(&self, setup: &str, session: &str) -> Option { - let key = (setup.to_string(), session.to_string()); + pub fn running(&self, machine: &str, session: &str) -> Option { + let key = (machine.to_string(), session.to_string()); self.running.lock().unwrap().get(&key).copied() } /// How the last operation on this session failed, if it did. - pub fn failure(&self, setup: &str, session: &str) -> Option { - let key = (setup.to_string(), session.to_string()); + pub fn failure(&self, machine: &str, session: &str) -> Option { + let key = (machine.to_string(), session.to_string()); self.failures.lock().unwrap().get(&key).cloned() } /// Forgets failures against sessions the machine no longer has. Called from /// the listing, which is the only place that knows what is still there. - pub fn prune(&self, setup: &str, present: &[String]) { + pub fn prune(&self, machine: &str, present: &[String]) { self.failures .lock() .unwrap() - .retain(|(kept_setup, session), _| { - kept_setup != setup || present.iter().any(|id| id == session) + .retain(|(kept_machine, session), _| { + kept_machine != machine || present.iter().any(|id| id == session) }); } @@ -174,7 +174,7 @@ impl InFlight { } self.settled = true; self.registry.running.lock().unwrap().remove(&self.key); - let (setup, session) = (self.key.0.clone(), self.key.1.clone()); + let (machine, session) = (self.key.0.clone(), self.key.1.clone()); let change = match failure { Some(message) => { self.registry @@ -183,12 +183,12 @@ impl InFlight { .unwrap() .insert(self.key.clone(), message.clone()); Change::Failed { - setup, + machine, session, message, } } - None => Change::Finished { setup, session }, + None => Change::Finished { machine, session }, }; let _ = self.registry.changes.send(change); } diff --git a/server/src/session/transport.rs b/server/src/session/transport.rs index 88ef777..5694cc8 100644 --- a/server/src/session/transport.rs +++ b/server/src/session/transport.rs @@ -97,7 +97,7 @@ pub enum Transport { Here, /// Reached with the system `ssh` client. Owns its entry rather than /// borrowing it, so a session keeps working against the config it was - /// spawned with even if the setup is edited afterwards. + /// spawned with even if the machine is edited afterwards. Ssh { name: String, ssh: SshConfig }, } @@ -179,11 +179,11 @@ impl Transport { } } - /// The transport a setup describes; a setup with no `ssh` is here. - pub fn for_setup(setup: &crate::config::SetupConfig) -> Self { - match &setup.ssh { + /// The transport a machine describes; a machine with no `ssh` is here. + pub fn for_machine(machine: &crate::config::MachineConfig) -> Self { + match &machine.ssh { Some(ssh) => Self::Ssh { - name: setup.name.clone(), + name: machine.name.clone(), ssh: ssh.clone(), }, None => Self::Here, diff --git a/server/src/usage.rs b/server/src/usage.rs index de36dec..5d1efdc 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -16,11 +16,11 @@ //! behind the same snapshot shape. //! //! **Asked of the machine that spends the tokens, not of this one.** A session -//! runs wherever its setup says, so the account being billed is that machine's. +//! runs wherever its machine says, so the account being billed is that machine's. //! In the layout this project aims at, `ai-server` is on the host, the host has //! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers //! the screen could show would be an account with no sessions. Credentials are -//! read through the session `Transport`, one snapshot per setup that offers +//! read through the session `Transport`, one snapshot per machine that offers //! that provider. //! //! The token is read *to* the backend and the HTTP call is made from here, so @@ -36,7 +36,7 @@ use std::time::{Duration, Instant}; use serde::Serialize; use serde_json::Value; -use crate::config::SetupConfig; +use crate::config::MachineConfig; use crate::session::transport::{Launch, Transport}; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; @@ -72,12 +72,12 @@ pub struct UsageWindow { /// What came back when a machine was asked about its limits. /// -/// Four answers rather than a flag and a message, because the screen has to +/// Named answers rather than a flag and a message, because the screen has to /// treat them differently. "Nobody is logged in here" is a machine working /// exactly as configured, while "I could not reach it" is a fault worth /// chasing, and "the endpoint refused me" says nothing about the machine at /// all. Collapsing them into one `error` string made the first look like the -/// last, so a perfectly healthy setup read as broken. +/// last, so a perfectly healthy machine read as broken. #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(tag = "state", rename_all = "camelCase")] pub enum UsageState { @@ -88,6 +88,13 @@ pub enum UsageState { NotLoggedIn, /// The machine could not be asked at all. Unreachable { detail: String }, + /// An explicit Claude login is waiting for its browser code. Kept apart + /// from a failure because nothing is broken while somebody is completing + /// the operation on the phone. + Authenticating, + /// Credentials exist, but the CLI could not renew them. Unlike a generic + /// provider failure, this has a direct action the phone can offer. + LoginRequired { detail: String }, /// The machine is logged in, but the usage endpoint did not answer. Failed { detail: String }, } @@ -98,10 +105,10 @@ pub struct UsageSnapshot { pub provider: String, /// Which machine these are the numbers for. The point of the whole module: /// they belong to an account on a particular box. - pub setup: String, + pub machine: String, /// That machine's current label, resolved when the snapshot is built, so - /// renaming a setup renames it here too. - pub setup_name: String, + /// renaming a machine renames it here too. + pub machine_name: String, /// The provider's billing pool, when it exposes more than one. Codex uses /// this to keep regular usage separate from its Luna reserve allowance. #[serde(skip_serializing_if = "Option::is_none")] @@ -149,8 +156,8 @@ pub trait UsageProvider: Send + Sync { /// credentials that machine stores -- nothing to configure, and it reports on /// exactly the account whose CLI runs the sessions there. pub struct ClaudeUsage { - pub setup: String, - pub setup_name: String, + pub machine: String, + pub machine_name: String, /// How to reach that machine. `Here` for the backend's own. pub transport: Transport, /// The CLI to run there, for the one thing this asks of it: refreshing its @@ -168,8 +175,8 @@ impl ClaudeUsage { fn snapshot(&self, state: UsageState, windows: Vec) -> UsageSnapshot { UsageSnapshot { provider: self.name().to_string(), - setup: self.setup.clone(), - setup_name: self.setup_name.clone(), + machine: self.machine.clone(), + machine_name: self.machine_name.clone(), limit_id: None, limit_name: None, state, @@ -234,8 +241,8 @@ impl UsageProvider for ClaudeUsage { /// app-server protocol. The CLI owns authentication and token refresh; this /// process never opens or copies its credentials. pub struct CodexUsage { - pub setup: String, - pub setup_name: String, + pub machine: String, + pub machine_name: String, pub transport: Transport, pub program: String, } @@ -244,8 +251,8 @@ impl CodexUsage { fn snapshot(&self, state: UsageState, windows: Vec) -> UsageSnapshot { UsageSnapshot { provider: CODEX.to_string(), - setup: self.setup.clone(), - setup_name: self.setup_name.clone(), + machine: self.machine.clone(), + machine_name: self.machine_name.clone(), limit_id: None, limit_name: None, state, @@ -283,7 +290,7 @@ impl UsageProvider for CodexUsage { Err(err) => { return vec![self.snapshot( UsageState::Unreachable { - detail: format!("couldn't ask Codex on {}: {err:#}", self.setup_name), + detail: format!("couldn't ask Codex on {}: {err:#}", self.machine_name), }, Vec::new(), )]; @@ -446,7 +453,7 @@ impl ClaudeUsage { return Err(UsageState::Failed { detail: format!( "the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}", - self.setup_name, self.program + self.machine_name, self.program ), }); } @@ -463,10 +470,10 @@ impl ClaudeUsage { /// A login the CLI could not renew: the one state here somebody has to act /// on, so it says where and what to run. fn still_expired(&self) -> UsageState { - UsageState::Failed { + UsageState::LoginRequired { detail: format!( - "the Claude login on {} has expired and could not be refreshed; run `{} /login` there", - self.setup_name, self.program + "The Claude login on {} has expired and could not be refreshed.", + self.machine_name ), } } @@ -727,6 +734,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String { }, UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(), UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"), + UsageState::Authenticating => "sign-in is in progress".to_string(), + UsageState::LoginRequired { detail } => format!("sign-in required ({detail})"), UsageState::Failed { detail } => format!("the meter failed ({detail})"), } } @@ -734,8 +743,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String { /// The fixture, as a provider, so it travels the same route and the same /// cache as a real meter rather than being spliced in at the screen. struct EchoUsage { - setup: String, - setup_name: String, + machine: String, + machine_name: String, fixture: Fixture, } @@ -759,8 +768,8 @@ impl UsageProvider for EchoUsage { )); vec![UsageSnapshot { provider: self.name().to_string(), - setup: self.setup.clone(), - setup_name: self.setup_name.clone(), + machine: self.machine.clone(), + machine_name: self.machine_name.clone(), limit_id: None, limit_name: None, state, @@ -778,7 +787,7 @@ impl UsageProvider for EchoUsage { /// Which paid services a machine can be asked about. /// -/// Derived from what the setup says it can run, so a machine with no Claude +/// Derived from what the machine says it can run, so a machine with no Claude /// provider is not asked about Claude limits -- it has none, and a row saying /// so would be a fact about nothing. /// @@ -787,9 +796,9 @@ impl UsageProvider for EchoUsage { /// one of these rows by that same name: two lists that disagreed would leave a /// session looking for a snapshot nothing produces. A second service later is a /// name there and an impl beside [`ClaudeUsage`], not a screen. -fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec> { +fn providers_for(machine: &MachineConfig, fixture: &Fixture) -> Vec> { let mut found: Vec> = Vec::new(); - for provider in &setup.providers { + for provider in &machine.providers { let Some(name) = provider.kind.usage_provider() else { continue; }; @@ -801,23 +810,23 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec found.push(Box::new(ClaudeUsage { - setup: setup.id.clone(), - setup_name: setup.name.clone(), - transport: Transport::for_setup(setup), + machine: machine.id.clone(), + machine_name: machine.name.clone(), + transport: Transport::for_machine(machine), program: provider.program().to_string(), })), CODEX => found.push(Box::new(CodexUsage { - setup: setup.id.clone(), - setup_name: setup.name.clone(), - transport: Transport::for_setup(setup), + machine: machine.id.clone(), + machine_name: machine.name.clone(), + transport: Transport::for_machine(machine), program: provider.program().to_string(), })), // Nothing at all until a test has asked for something: an // echo session costs nothing, so the honest answer is no row // rather than a row saying zero. ECHO if fixture.is_set() => found.push(Box::new(EchoUsage { - setup: setup.id.clone(), - setup_name: setup.name.clone(), + machine: machine.id.clone(), + machine_name: machine.name.clone(), fixture: fixture.clone(), })), _ => {} @@ -829,16 +838,22 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec)>; +type ProviderGate = Arc>; #[derive(Default)] /// The cache in front of whatever machines exist: at most one real fetch per /// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks. pub struct UsageMonitor { cache: Mutex, + /// One gate per machine and metered provider. The cache lock cannot cover + /// a network call without making an unreachable machine stall every other + /// one, but releasing it used to let concurrent `/usage` requests launch + /// two token refreshers against the same rotating credential. + gates: Mutex>, /// The invented meter an echo session can put up; empty unless one /// has. Shared with the session layer, which is where the command /// that sets it is typed -- see [`Fixture`]. @@ -849,37 +864,116 @@ impl UsageMonitor { pub fn new(fixture: Fixture) -> Self { Self { cache: Mutex::new(Cached::new()), + gates: Mutex::new(HashMap::new()), fixture, } } + fn gate_for(&self, key: &(String, &'static str)) -> ProviderGate { + self.gates + .lock() + .unwrap() + .entry(key.clone()) + .or_default() + .clone() + } + + fn cached( + &self, + key: &(String, &'static str), + machine_name: &str, + max_age: Option, + ) -> Option> { + let cache = self.cache.lock().unwrap(); + let (fetched, snapshots) = cache.get(key)?; + if max_age.is_some_and(|age| fetched.elapsed() >= age) { + return None; + } + let mut snapshots = snapshots.clone(); + for snapshot in &mut snapshots { + snapshot.machine_name = machine_name.to_string(); + } + Some(snapshots) + } + + /// Prevents automatic refresh from overlapping an explicit login. The + /// fresh cache entry closes the small gap before the login worker acquires + /// the same gate, while an attempt lasting beyond the normal cache window + /// is protected by the worker holding it. + pub(crate) fn claude_authentication_started(&self, machine: &MachineConfig) -> ProviderGate { + let key = (machine.id.clone(), CLAUDE); + let gate = self.gate_for(&key); + let guard = gate.lock().unwrap(); + self.cache.lock().unwrap().insert( + key, + ( + Instant::now(), + vec![UsageSnapshot { + provider: CLAUDE.to_string(), + machine: machine.id.clone(), + machine_name: machine.name.clone(), + limit_id: None, + limit_name: None, + state: UsageState::Authenticating, + windows: Vec::new(), + fetched_at: crate::session::now(), + }], + ), + ); + drop(guard); + gate + } + + /// Makes the first read after a login ask the provider instead of keeping + /// the pre-login state for the ordinary cache interval. + pub(crate) fn claude_authentication_finished(&self, machine: &str) { + self.cache + .lock() + .unwrap() + .remove(&(machine.to_string(), CLAUDE)); + } + /// One or more snapshots per machine that offers a paid service, in the /// order the machines are configured and the provider reports them. /// - /// Blocking -- call via `spawn_blocking`. Takes the setups rather than + /// Blocking -- call via `spawn_blocking`. Takes the machines rather than /// holding the manager, so this module stays below the session layer rather /// than reaching up into it. - pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec { + pub fn snapshots(&self, machines: &[MachineConfig]) -> Vec { let mut fresh = Vec::new(); - for setup in setups { - for provider in providers_for(setup, &self.fixture) { - let key = (setup.id.clone(), provider.name()); - if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key) - && fetched.elapsed() < provider.poll_interval() + for machine in machines { + for provider in providers_for(machine, &self.fixture) { + let key = (machine.id.clone(), provider.name()); + if let Some(snapshots) = + self.cached(&key, &machine.name, Some(provider.poll_interval())) + { + fresh.extend(snapshots); + continue; + } + + let gate = self.gate_for(&key); + let _guard = match gate.try_lock() { + Ok(guard) => guard, + Err(std::sync::TryLockError::WouldBlock) => { + // A refresh already in flight can keep serving the last + // measured answer. An explicit login seeds its own + // authenticating answer before taking the gate. + if let Some(snapshots) = self.cached(&key, &machine.name, None) { + fresh.extend(snapshots); + continue; + } + // The first-ever fetch has no honest stale answer. Wait + // for its one producer, then recheck below. + gate.lock().unwrap() + } + Err(std::sync::TryLockError::Poisoned(err)) => err.into_inner(), + }; + if let Some(snapshots) = + self.cached(&key, &machine.name, Some(provider.poll_interval())) { - // Cached numbers, but the machine's *name* is read fresh: a - // rename should show immediately rather than waiting out a - // poll interval it has nothing to do with. - let mut snapshots = snapshot.clone(); - for snapshot in &mut snapshots { - snapshot.setup_name = setup.name.clone(); - } fresh.extend(snapshots); continue; } - // Fetched without the lock held: this makes a network call per - // machine, and holding the cache across them would serialise - // every phone asking for the screen behind the slowest ssh. let snapshots = provider.fetch(); self.cache .lock() @@ -890,11 +984,14 @@ impl UsageMonitor { } // Machines that have gone away should not keep their numbers alive. let live: std::collections::HashSet<&str> = - setups.iter().map(|setup| setup.id.as_str()).collect(); + machines.iter().map(|machine| machine.id.as_str()).collect(); self.cache .lock() .unwrap() - .retain(|(setup, _), _| live.contains(setup.as_str())); + .retain(|(machine, _), _| live.contains(machine.as_str())); + self.gates.lock().unwrap().retain(|(machine, _), gate| { + live.contains(machine.as_str()) || Arc::strong_count(gate) > 1 + }); fresh } } @@ -904,6 +1001,31 @@ mod tests { use super::*; use crate::config::DriverKind; + #[test] + fn refresh_gates_are_shared_per_machine_and_provider_only() { + let monitor = UsageMonitor::new(Fixture::default()); + let claude_here = monitor.gate_for(&("here".to_string(), CLAUDE)); + let same = monitor.gate_for(&("here".to_string(), CLAUDE)); + let claude_there = monitor.gate_for(&("there".to_string(), CLAUDE)); + let codex_here = monitor.gate_for(&("here".to_string(), CODEX)); + + let held = claude_here.lock().unwrap(); + assert!( + matches!(same.try_lock(), Err(std::sync::TryLockError::WouldBlock)), + "the same machine/provider pair must have one credential writer" + ); + assert!( + claude_there.try_lock().is_ok(), + "another machine must not wait" + ); + assert!( + codex_here.try_lock().is_ok(), + "another provider must not wait" + ); + drop(held); + assert!(same.try_lock().is_ok()); + } + #[test] fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() { assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500")); @@ -913,9 +1035,9 @@ mod tests { #[test] fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() { let provider = ClaudeUsage { - setup: "far".to_string(), - setup_name: "somewhere else".to_string(), - transport: Transport::for_setup(&unreachable_setup()), + machine: "far".to_string(), + machine_name: "somewhere else".to_string(), + transport: Transport::for_machine(&unreachable_machine()), program: "/opt/claude".to_string(), }; // The machine cannot be reached, so the refresh attempt fails there @@ -934,10 +1056,10 @@ mod tests { "the token must never be quoted back" ); - let UsageState::Failed { detail } = provider.still_expired() else { - panic!("still expired is a fault"); + let UsageState::LoginRequired { detail } = provider.still_expired() else { + panic!("still expired must offer a new login"); }; - assert!(detail.contains("/opt/claude /login"), "{detail}"); + assert!(detail.contains("somewhere else"), "{detail}"); assert!(!detail.contains("unreachable"), "{detail}"); } @@ -967,10 +1089,10 @@ mod tests { assert_eq!(windows[3].resets_at, None); } - /// A setup naming a machine that cannot be dialled, so nothing here touches + /// A machine naming a machine that cannot be dialled, so nothing here touches /// the network beyond ssh failing to resolve it. - fn unreachable_setup() -> SetupConfig { - SetupConfig { + fn unreachable_machine() -> MachineConfig { + MachineConfig { id: "far".to_string(), name: "somewhere else".to_string(), ssh: Some(crate::config::SshConfig { @@ -993,9 +1115,9 @@ mod tests { #[test] fn a_machine_that_cannot_be_asked_says_so_rather_than_looking_logged_out() { let provider = ClaudeUsage { - setup: "far".to_string(), - setup_name: "somewhere else".to_string(), - transport: Transport::for_setup(&unreachable_setup()), + machine: "far".to_string(), + machine_name: "somewhere else".to_string(), + transport: Transport::for_machine(&unreachable_machine()), program: "claude".to_string(), }; let snapshot = provider.fetch().remove(0); @@ -1007,8 +1129,8 @@ mod tests { "{:?}", snapshot.state ); - assert_eq!(snapshot.setup, "far"); - assert_eq!(snapshot.setup_name, "somewhere else"); + assert_eq!(snapshot.machine, "far"); + assert_eq!(snapshot.machine_name, "somewhere else"); assert!(snapshot.windows.is_empty()); } @@ -1043,7 +1165,7 @@ mod tests { #[test] fn only_machines_that_can_run_claude_are_asked_about_it() { - let mut echo_only = unreachable_setup(); + let mut echo_only = unreachable_machine(); echo_only.providers = vec![crate::config::ProviderConfig { name: "echo".to_string(), kind: DriverKind::Echo, @@ -1056,7 +1178,7 @@ mod tests { // otherwise there is no meter to report. let unset = Fixture::new(); assert!(providers_for(&echo_only, &unset).is_empty()); - assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1); + assert_eq!(providers_for(&unreachable_machine(), &unset).len(), 1); // And with one set, that machine has exactly the invented meter // -- under the name the session's `usageProvider` will name. @@ -1142,8 +1264,8 @@ mod tests { #[test] fn keeps_codex_reserve_as_a_named_pool() { let provider = CodexUsage { - setup: "local".to_string(), - setup_name: "this machine".to_string(), + machine: "local".to_string(), + machine_name: "this machine".to_string(), transport: Transport::Here, program: "codex".to_string(), };