Skip to main content

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.

TransportWhat happensCarried backWorks when the browser is elsewhere
pollThe CLI prints a deep link and polls the provider itself.nothingyes
codeThe user carries a value: either a user code typed into the provider page, or an authorization code pasted back out of it.a code, sometimesyes
redirectThe provider redirects to a loopback address the CLI is listening on.the redirect URLonly 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, so code with a user code.
  • Cursor — agent login on the ACP binary, so poll.
  • Claude — claude setup-token, so code with 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-token writes 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. AgentLoginOutputReader reads 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/code delivers 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.