Agent sign-in
Agent-Up signs agents in with a ChatGPT, Cursor, or Claude subscription, never an API key. This page is how the Server and the clients handle those transports without special-casing any one agent.
Transports
The Server declares which shape an agent CLI implements and reports it on the sign-in challenge as
transport. Clients branch on that and never on which agent is signing in.
| Transport | What happens | Carried back | Works when the browser is elsewhere |
|---|---|---|---|
poll | The CLI prints a deep link and polls the provider itself. | nothing | yes |
code | The user carries a value: either a user code typed into the provider page, or an authorization code pasted back out of it. | a code, sometimes | yes |
redirect | The provider redirects to a loopback address the CLI is listening on. | the redirect URL | only with an in-app WebView |
Defaults come from that agent module's Login spec when present. First-party Codex, Cursor, and Claude module ids keep a CLI fallback when that spec is absent. Listing stays by module id; there is no public AgentKind on either side of the wire - the client's own type is AgentId, a module id. Those defaults live in AgentLoginFlowProvider, matching the login command AgentLoginCommandProvider runs:
- Codex —
codex login --device-auth, socodewith a user code. - Cursor —
agent loginon the ACP binary, sopoll. - Claude —
claude setup-token, socodewith a pasted code.
Agents:{agent}:LoginTransport overrides this when a deployment points a module at a different CLI or
a different login subcommand. Agents:{agent}:LoginChallengeTimeoutSeconds and
Agents:{agent}:LoginCompletionTimeoutSeconds bound each phase, and
Agents:{agent}:LoginEnvironment passes anything else a particular CLI build needs.
Why the Server does not read the flow out of the CLI's output
It used to. That could not work for two of the three shapes:
claude setup-tokenwrites its prompt with no trailing newline and then blocks on stdin, so a line-oriented read never returns and the link printed above it is never surfaced.AgentLoginOutputReaderreads characters and flushes whatever is pending once the CLI goes quiet, which is what makes an unterminated prompt observable.- There was no way to answer that prompt at all. The Server now keeps stdin open and
POST api/workspaces/{id}/agent/login/codedelivers a code to a CLI that is waiting for one.
Output is still parsed for the link, the user code, and the expiry, but the shape is declared rather than guessed, so a code-shaped token in a progress message is never mistaken for a code the user is meant to type.
The redirect transport and the callback relay
The agent CLI binds its callback to loopback on the Server host. When the browser is on that same host the redirect reaches the CLI directly and nothing else is needed. When the browser is on a phone or another machine, the redirect goes nowhere unless something carries it back.
POST api/workspaces/{id}/agent/login/callback accepts an intercepted redirect and
AgentLoginCallbackRelay replays it against the CLI's listener. The URL comes from a client, so it
is a request-forgery vector and is treated as one: it is accepted only when it matches the redirect
URI the CLI itself advertised — host, port, and path — and only when that address is loopback.
Clients
AgentUp.AgentAuth (@agent-up/agent-auth) holds the client half. resolveAction turns a
challenge into the single thing to do; runAction does it through a platform port. The state
machine has no React or React Native imports, so it is tested under plain Node.
Only one platform can complete a redirect sign-in from elsewhere: an in-app WebView, which sees
every navigation its own content makes. Linking.openURL hands the URL to Safari or Chrome and
nothing comes back, and a browser cannot read a cross-origin popup's location. The port declares
canInterceptRedirect and the web adapter says false rather than hanging a sign-in it cannot
finish.
A client must not open a sign-in link the user did not ask it to open. Choosing a method reads the
session after POST .../authenticate until the challenge carries a sign-in URL, or the Server
leaves authenticating. A single GET raced slower login CLIs — loopback redirect binds a listener
before it prints the link — and that empty snapshot could overwrite a later event-stream frame that
already had the URL. Waiting only on the stream left the Open button unrendered when that stream
lagged.
Testing
AgentUp.TestAgents publishes real CLIs implementing each shape, with a real OAuth identity
provider behind them, so the whole path is exercised without signing in to a real vendor.
AgentUp.Mobile.E2E drives them through the real mobile client on an iOS simulator, an Android
emulator, and the installable web build. Device-code scenarios wait until the challenge carries
the user code, not only the sign-in URL: the CLI prints the URL first, and treating that as ready
is how the installable-web suite approved a challenge with no code. The Detox Jest envelope must
outlive the pasted-code wait budget (challenge, in-app browser return, code submit, ready); a
smaller envelope kills the test with Exceeded timeout instead of naming the wait. The method
button wait must outlive schedule rendering the session: Detox synchronization is off, so the
picker tap returns before the client shows the sign-in methods. A free-port check that
binds and closes leaves TIME_WAIT; the identity provider then dies with address already in use,
so the harness probes by connecting and retries a bind failure on the next port. See the
Testing section of AGENTS.md.
The disposable native harness enables cleartext transport because its simulator and emulator must
reach Server and identity-provider processes on ephemeral CI-host ports. That exception is applied
by the harness config plugin only; it must not be copied into the production Mobile application.
The plugin also writes a network-security-config that permits 10.0.2.2 and loopback, because a
generated config that forbids cleartext wins over usesCleartextTraffic on API 28+. Android Detox
reverses the Server and identity-provider ports onto emulator loopback and points the client at
http://localhost. QEMU's 10.0.2.2 NAT ConnectException'd on hosted emulators, and cleartext to
127.0.0.1 is forbidden because Android ignores IP literals in domain tags. Either failure leaves
the picker prompt with no agent buttons.
Native Detox launches the harness through agent-up-chat://connect instead of typing into the
connect form. The method-button tap uses the same 60s visibility budget as the picker and Open:
iOS can still be laying out after the Server already reports that sign-in is required. Android
Fabric's replaceText does not update React state, so Connect was a no-op and the suite timed
out on a picker the chat never mounted. The installable-web suite still fills the form. When a
native pasted-code flow returns from the system browser, Detox delivers that connect URL again
so an iOS process reclaimed in the background remounts the same workspace. After launch the
native suite waits for the picker, the connect form, or the picker
prompt as a condition: a 500ms peek at the form skipped Connect when the deep link missed, and
then waited a minute for a picker the chat never mounted. A blank frame stays in that wait
until the deadline. Relaunching on the first empty snapshot interrupted a deep link that had
already connected, and Detox then waited on the connect form while the event stream kept the
app busy. A launcher Application Not Responding
dialog is force-stopped without sending Home, because Home relaunches that same dialog over the
app.
Each end-to-end stack waits for its Server and identity-provider processes to close before removing
its temporary data directory. In particular, the Server can still flush its Chromium profile after
receiving SIGTERM; deleting the directory before process close races that writer and makes web
cleanup fail with ENOTEMPTY after an otherwise successful scenario.
Keeping the suites quick
Compiling the client is nearly all of what those jobs cost, and most pushes do not change the
client: they change the Server, the test agents, or the suites. So the compiled app is cached
under a key covering every tracked file that ends up bundled into it - the client, the shared
sign-in module, the design system, the audit package - and a push that touches none of them
reuses it and skips the prebuild, the pods, and the build outright. mobile-app-key.sh computes
that key from git's own blob hashes.
If you add a source tree that gets bundled into the app, add it there too. A key that does not cover something the app contains will serve a stale app, and the suite will test the wrong build without saying so.
The pods and their object files are cached separately, under the dependency set alone, so a change to the client relinks rather than recompiling every dependency. Both are saved immediately after the build rather than at the end of the job, because a run whose tests fail would otherwise throw the build away - which is exactly when the next push is about to need it.
Android installs the emulator package and the API 35 Google APIs system image with retries before
android-emulator-runner asks for them. Google's zips are occasionally not archives, and that
action treats a single failed download as a failed job. The NDK step already retries the same
class of truncated download.
Before Detox starts, the workflow also configures the emulator to stay awake while powered and
disables its screen timeout. Merely finding a focused window at boot is insufficient because the
screen can otherwise lock while the harness starts its Server and test-agent processes, leaving
Espresso with a visible application root that no longer has window focus. An Application Not
Responding dialog or the Google first-run wizard also holds focus; the wake script skips that
wizard and waits until a normal window is focused before the suite starts. When the focused ANR
is the launcher, it does not send Home: that key relaunches the same dialog over the app.