Files
galaxy/specs/APP-3801/TECH.md
T

30 KiB

TECH.md — Remote Server Authentication (APP-3801)

Linear: APP-3801

Behavior is specified in specs/APP-3801/PRODUCT.md. This document plans the implementation and documents the alternatives that were rejected because of how APP-4068 reshapes the server topology.

1. Context

Terminology note

The originating Linear ticket is titled "remote server initialization with authentication runtime flags", which reads like CLI flags on oz remote-server*. The design does not introduce such flags. Once APP-4068's daemon topology is in scope, every startup-time credential transport (argv, env, fd inheritance, file handoff) fails for the same set of reasons — §4 walks through each. The "runtime" in this design means runtime protocol fields — an auth_token carried on Initialize for the initial credential, and a dedicated Authenticate message for mid-session rotation — exchanged over the already-encrypted client↔server byte stream, not CLI args parsed at process start. Reviewers who come in expecting --auth-token should start at §4.

Remote server today

  • crates/remote_server/proto/remote_server.protoClientMessage/ServerMessage envelopes, Initialize/InitializeResponse, shared ErrorResponse with ErrorCode { UNSPECIFIED, INVALID_REQUEST, INTERNAL }. No auth fields anywhere.
  • crates/remote_server/src/client.rs (117-199)RemoteServerClient::initialize(), per-request correlation via pending_requests, fire-and-forget helpers (send_notification, notify_session_bootstrapped).
  • crates/remote_server/src/manager.rs (168-283)RemoteServerManager::connect_session drives Setup → Launch → Initialize handshake → Connected.
  • app/src/remote_server/mod.rs:21run() configures stderr-only logging and boots the headless warpui app; reads no CLI args or env.
  • app/src/remote_server/server_model.rs (118-480)ServerModel state, handle_message dispatch, handle_initialize returns server_version + host_id.
  • crates/warp_cli/src/lib.rs:430-433WorkerCommand::RemoteServer is a unit variant; app/src/lib.rs:548-551 dispatches to remote_server::run() with no args.

Credential sources on the client

  • app/src/auth/credentials.rsCredentials::{Firebase, ApiKey, SessionCookie, Test}, AuthToken::{Firebase(String), ApiKey(String), NoAuth}, AuthToken::bearer_token().
  • app/src/server/server_api/auth.rs (243-280)ServerApi::get_or_refresh_access_token() returns a fresh AuthToken, transparently refreshing Firebase tokens 5 minutes before expiry. Emits ServerApiEvent::NeedsReauth on refresh failure.

APP-4068 daemon topology (the operative constraint)

APP-4068 splits the binary into remote-server-proxy (byte-bridging SSH stdio to a Unix socket via std::io::copy) and remote-server-daemon (long-lived, spawned by the first proxy via setsid with null stdio, serves multiple concurrent proxy connections, 10-minute grace after last disconnect). ServerModel already tracks connections in HashMap<ConnectionId, Sender<ServerMessage>> and exposes register_connection(id, sender) / deregister_connection(id). Routing of responses is per-ConnectionId — there is no broadcast path except explicit fan-out over the map.

The critical invariants this imposes on auth design:

  • Which tab spawned the daemon is an accident of timing — any credential baked into the daemon at spawn belongs statically to whichever proxy won the flock race, with no path to update it later.
  • The proxy is deliberately protocol-agnostic; anything the proxy has to understand about auth is new coupling.
  • The daemon outlives any single SSH session, so credential rotation has to work at any point during the daemon's life.

Identity key and socket-path partitioning (APP-4068 foundational assumption)

APP-4068 partitions daemon sockets by user identity at the path level: ~/.warp[-channel]/remote-server/{identity_key}/server.sock. The {identity_key} is:

  • Logged-in users: the Warp canonical user UUID.
  • Anonymous users: a per-install persistent UUID loaded from user preferences under key "ExperimentId", generated and saved if absent. This ensures all tabs for the same anonymous install share a single daemon and socket, matching the logged-in behavior.

APP-3801 treats this partitioning as a given. Because the socket is identity-scoped, all connections to a given daemon belong to the same user by construction, and the daemon can store a single credential for its whole lifetime rather than one per connection. APP-3801 does not add any cross-user authentication logic. For anonymous users with no bearer token, Initialize carries an empty auth_token (no credential provided); handler behavior in the unauthenticated case is out of scope and ships with the first handler that actually needs an upstream credential (§7).

2. Proposed changes

2.1 Protocol (crates/remote_server/proto/remote_server.proto)

message ClientMessage {
  string request_id = 1;
  oneof message {
    Initialize initialize = 2;
    // ... existing variants ...
    Authenticate authenticate = 11;  // new, rotation only
  }
}

// Initialize gains an optional auth_token field that carries the daemon's
// initial credential as part of the handshake. Empty string means "no
// credential provided" (anonymous users); the daemon leaves its existing
// auth_token unchanged in that case rather than clearing it.
message Initialize {
  // ... existing fields ...
  string auth_token = N;  // new; empty = no credential provided
}

// Client → server: refresh the daemon's credential mid-session.
// Fire-and-forget. Sending replaces the previously stored credential
// (last-writer-wins; the daemon stores one token for its whole lifetime, §4.7).
message Authenticate {
  string auth_token = 1;
}

No other protocol additions. ErrorCode stays at the existing { UNSPECIFIED, INVALID_REQUEST, INTERNAL }; ErrorResponse is unchanged. Auth-specific error codes (MISSING_CREDENTIAL, CREDENTIAL_REJECTED, PERMISSION_DENIED, and any sub-reason enum) are deferred to the follow-up PR that introduces the first handler actually needing them (§7) — defining them here with no caller would ship dead protocol surface.

No ClearCredentials message either. Mid-life clearing is not part of the protocol; the daemon holds the credential until process exit (§6).

Two paths exist for writing the daemon's singleton: Initialize.auth_token carries it during handshake (covering every new connection in a single round-trip), and Authenticate updates it mid-session (covering rotation). The split saves one fire-and-forget message per new connection over the SSH-bridged topology; see §4.5 for why the original unified Authenticate-only design was rejected in favor of this bundling.

2.2 Client (crates/remote_server/src/client.rs)

impl RemoteServerClient {
    /// Perform the Initialize handshake, optionally carrying the daemon's credential.
    /// If `auth_token` is `Some`, the server stores it as the daemon-wide singleton
    /// as part of handshake processing. If `None`, no credential is set or cleared.
    pub async fn initialize(
        &self,
        auth_token: Option<&str>,
    ) -> Result<InitializeResponse, ClientError> {
        // ... existing initialize logic, with auth_token wired into the Initialize message ...
    }

    /// Refresh the daemon's credential mid-session. Fire-and-forget.
    /// Used on token rotation only; initial auth rides on `initialize`.
    pub fn authenticate(&self, auth_token: &str) {
        let msg = ClientMessage {
            request_id: String::new(),
            message: Some(client_message::Message::Authenticate(
                Authenticate { auth_token: auth_token.to_owned() },
            )),
        };
        self.send_notification(msg);
    }
}

No ClientError additions in this PR. Auth-specific error-code handling lands with the first handler that produces those codes (§7).

2.3 Manager (crates/remote_server/src/manager.rs)

Pre-initialize auth step:

Phase 4 of connect_session fetches the credential before initialize and passes it through as part of the handshake, rather than as a follow-up fire-and-forget message.

  1. Before calling client.initialize(...), hop to the main thread and obtain a fresh AuthToken via ServerApi::get_or_refresh_access_token() (reached through a new AuthProvider handle passed into connect_session, kept abstract so remote_server stays independent of app/src/server).
  2. Call client.initialize(auth_token.bearer_token()). If the Option is Some, the daemon stores the token as part of handshake processing. If None (anonymous user, or an AuthToken::NoAuth), the daemon leaves its singleton untouched — in the anonymous case there is nothing to set; in the pathological case of a logged-in user arriving on an already-populated daemon, the existing credential is still valid.
  3. Transition to Connected.

No separate follow-up authenticate call on first connection. See §3's mermaid for the single-round-trip flow and §4.5 for why the original split-message design was rejected.

Token rotation (pick-one, no fan-out):

Subscribe RemoteServerManager to the client-side auth state. On token rotation (the existing Firebase refresh path in ServerApi), pick one arbitrary Connected session and call client.authenticate(new_token) on it. The daemon stores a single credential shared across all its connections, so one send is sufficient to update every handler's view of the current token. This pattern matches how the manager already sends other server-bound notifications. If no sessions are Connected at the moment of rotation, the rotation is a no-op; the next new session's initialize carries the current token.

Logout path:

On logout, the client tears down its remote connections. No explicit server-side clear is sent — the daemon retains the stale token in memory until its grace period expires and the process exits. See §6 for the trade accepted.

No change to start_remote_server or SSH args. No credential enters argv or env.

2.4 Server (app/src/remote_server/server_model.rs)

pub struct ServerModel {
    // ... existing fields ...
    /// Daemon-wide credential, populated by handle_initialize (when the
    /// Initialize message carries a non-empty auth_token) or by
    /// handle_authenticate (mid-session rotation). Stored as a plain String;
    /// the server forwards tokens opaquely without categorising or validating
    /// them locally. Never cleared except by daemon process exit — see §4.7
    /// for why per-connection state was rejected given APP-4068's identity-
    /// scoped daemon.
    auth_token: Option<String>,
}

fn handle_initialize(&mut self, msg: Initialize) -> InitializeResponse {
    if !msg.auth_token.is_empty() {
        log::info!("Initialize carries credential (token=<redacted>)");
        self.auth_token = Some(msg.auth_token);
    }
    // If auth_token is empty: do nothing. Never clear an existing singleton
    // from Initialize — a reconnect with no token must not invalidate the
    // daemon's current credential.
    //
    // ... existing response construction (server_version, host_id, ...) ...
}

fn handle_authenticate(&mut self, msg: Authenticate) {
    log::info!("Handling Authenticate (token=<redacted>)");
    self.auth_token = Some(msg.auth_token);
}

pub fn auth_token(&self) -> Option<&str> {
    self.auth_token.as_deref()
}

// `deregister_connection` (added by APP-4068) is unchanged by this PR.
// `auth_token` is deliberately retained across connection teardown;
// the only cleanup event is daemon process exit (grace-period expiry,
// SIGTERM, panic). See §6.

Why daemon-wide rather than per-connection? APP-4068 already partitions daemon socket paths by user identity (§1), so every connection on a given daemon belongs to the same Warp user by construction and will present the same bearer token. A HashMap<ConnectionId, String> would hold N copies of the same string with no behavioral difference. §4.7 walks through the per-connection alternative in full; the short version is that Option<String> is simpler, smaller, and avoids cleanup machinery that has no work to do.

3. End-to-end flow

Overview

Three flows — fresh connection, proactive refresh, and server-side teardown — route through two protocol touchpoints: the initial credential rides on the existing Initialize handshake (as a new auth_token field), and mid-session refreshes use the new fire-and-forget Authenticate message. The server is a passive recipient in both cases: it writes into its singleton auth_token slot when either carries a value, and does nothing else auth-related. The client-side RemoteServerManager owns every decision about when to send. Handler behavior when a credential is missing, rejected, or insufficient is out of scope here (§7).

User identity scoping

User identity is established before any APP-3801 message flows. The client selects the daemon socket path by identity key — the Warp canonical user UUID for logged-in users, or a per-install persistent UUID for anonymous users (see §1 "Identity key and socket-path partitioning"). Because each daemon is bound to exactly one socket path, every connection it accepts already belongs to a single user by construction, and the daemon stores a single credential for all of them. APP-3801 carries no additional user-identifier field on the wire; the bearer token carried on Initialize (or Authenticate at refresh time) is the only user-identifying information the daemon sees.

How identity flows in practice. Using abc123 as a placeholder for the user's Warp UUID:

  1. Client computes the path. Warp (running on the local machine) already has the user's identity in memory. It builds the socket path: ~/.warp/remote-server/abc123/server.sock. For anonymous users, abc123 is replaced with the per-install ExperimentId UUID loaded from preferences.
  2. Client launches the proxy with that path. When the user opens a remote session, the client invokes something like oz remote-server-proxy --socket-path ~/.warp/remote-server/abc123/server.sock on the remote host over SSH. The proxy receives the path as an opaque argv string — it does not parse, validate, or interpret the UUID segment; it just knows where the socket lives.
  3. Proxy finds or spawns the daemon. The proxy attempts to connect() at the given path. If a daemon is already listening, it joins. If not, it spawns one (via setsid) whose first action is to bind() the socket at that path. APP-4068 owns this whole dance; APP-3801 rides on top of it.
  4. Daemon has no UUID awareness. The daemon never sees a UUID in its own code. It does not parse an identifier out of any message, does not validate users, does not know what abc123 means. It listens on the socket it was told to listen on and accepts whatever connections arrive there.

Why this scopes identity without a protocol field:

  • A second tab from the same Warp user → same UUID → same path → same daemon (joins it).
  • A tab from a different Warp user → different UUID → different path → different daemon (separate process).
  • A process running as a different OS user on the remote host → cannot reach the socket at all, because the parent directory abc123/ is mode 0700 owned by the one OS user. The kernel's permission check enforces it.

The daemon never needs to trust a user identifier on the wire because the identifier isn't on the wire. It is in the file-system path, and the OS's permission check is what turns "a process requested connection to this path" into "that process is running as the owning user."

sequenceDiagram
    participant API as ServerApi (client)
    participant Mgr as RemoteServerManager
    participant C1 as Client (tab 1)
    participant C2 as Client (tab 2)
    participant Server as ServerModel (daemon)

    Note over Mgr,Server: 1. Initial authentication (first connection, single round-trip)
    Mgr->>API: get_or_refresh_access_token()
    API-->>Mgr: AuthToken
    Mgr->>C1: initialize(Some(token))
    C1->>Server: ClientMessage(Initialize{auth_token})
    Note right of Server: handle_initialize sets auth_token = Some(token)
    Server-->>C1: ServerMessage(InitializeResponse)

    Note over Mgr,Server: 2. Additional connection on same daemon (same single RTT)
    Mgr->>C2: initialize(Some(token))
    C2->>Server: ClientMessage(Initialize{auth_token})
    Note right of Server: auth_token overwritten with same value
    Server-->>C2: ServerMessage(InitializeResponse)

    Note over API,Server: 3. Proactive refresh (~5 min before expiry, pick-one)
    API-->>Mgr: token-rotated event
    Mgr->>C1: authenticate(new_token)
    C1->>Server: ClientMessage(Authenticate)
    Note right of Server: auth_token = Some(new_token)
    Note over C2: no message sent — singleton already updated

    Note over Server: 4. Server-side teardown (SSH drop / crash / logout)
    C1--xServer: connection closes
    Note right of Server: deregister_connection runs; auth_token retained
    C2--xServer: connection closes
    Note right of Server: grace timer starts; auth_token still retained
    Note right of Server: grace expiry → process exit → auth_token gone

Fresh connection

  1. Client calls manager.connect_session(session_id, socket_path).
  2. Manager runs Setup / Launch / creates RemoteServerClient.
  3. Manager calls get_or_refresh_access_token(), then client.initialize(auth_token.bearer_token()). If bearer_token() is Some, the initial credential rides on the Initialize message; if NoAuth, None is passed and the daemon leaves its singleton untouched.
  4. Server: handle_initialize runs the existing handshake logic and, if the carried auth_token is non-empty, sets auth_token = Some(token). Responds with InitializeResponse. If this is not the first connection on this daemon, the value overwrites the existing singleton with the same token (same user, same ServerApi).
  5. Session transitions to Connected.

Token rotation

  1. Client-side ServerApi refreshes its Firebase ID token (existing auto-refresh).
  2. RemoteServerManager observes the rotation event and picks one arbitrary Connected session, calling client.authenticate(new_token) on it. This pattern matches how the manager already handles other server-bound notifications.
  3. Server: handle_authenticate replaces the singleton.
  4. In-flight requests using the previous token continue — warp-server accepts a refresh overlap window of both old and new tokens.
  5. If no sessions are Connected at the moment of rotation, the rotation is a no-op; the next new session's initialize carries the current token.

SSH drop / daemon grace-period expiry

  1. Proxy sees SSH EOF → exits → daemon's accept loop observes connection close → deregister_connection(connection_id).
  2. auth_token is retained. There is no per-connection auth state to clean up.
  3. Daemon continues serving other connections (if any) with the same singleton credential.
  4. When the last connection leaves, APP-4068's grace timer starts. The daemon still holds auth_token during the grace window.
  5. On grace-period expiry (or SIGTERM, panic) the daemon process exits. All in-memory state, including auth_token, dies with the process.

4. Alternatives considered

Each was considered and rejected. Each failure mode is stated with respect to APP-4068's daemon topology.

4.1 CLI flag on the server binary (--auth-token)

Shape: make WorkerCommand::RemoteServer a struct variant carrying --auth-token; the client includes the token in the SSH launch command.

Why rejected:

  • Leaks via ps. On any shared-user remote host, argv is world-readable. Disqualifying on security grounds before even considering the daemon.
  • No refresh channel; stale at spawn. Firebase ID tokens expire in ~1 hour. A startup-only credential cannot be updated once baked into argv: even if a later tab on the same daemon has a fresher token, there is no protocol path for it to land on the already-spawned daemon. The runtime Authenticate message this spec introduces is the only way to ship a rotation-capable credential.

4.2 Environment variable (WARP_REMOTE_AUTH_TOKEN)

Why rejected:

  • Readable via /proc/$pid/environ on shared hosts.
  • SSH strips env by default; SendEnv/AcceptEnv cooperation not guaranteed on arbitrary remote hosts.
  • Same "no refresh channel, stale at spawn" problem as §4.1.

4.3 File-based handoff

Why rejected:

  • Brief disk artifact; a crash between write and unlink leaves a credential on disk.
  • Ambiguous ownership in daemon mode (multiple tabs drop distinct files; daemon must pick one).
  • One-shot; doesn't model rotation.

4.4 File-descriptor handoff (--auth-fd 3)

Why rejected:

  • No fd inheritance path to the daemon. APP-4068 spawns the daemon via Command::pre_exec(setsid) with null stdio; inherited fds are dropped.
  • Even if the proxy held such an fd, the daemon-spawn step drops it.
  • One-shot; doesn't model rotation.

4.5 Separate Authenticate for initial auth (in addition to refresh)

Shape: Initialize carries no credential. The client sends a fire-and-forget Authenticate message immediately after Initialize completes, and again on any later rotation. Both initial and refresh paths use the same message.

This is how the spec was structured before the bundling pivot — a unified "one message for setting credentials" surface.

Why rejected:

  • Extra message per new connection. Every new connection on a daemon would send Initialize + one follow-up Authenticate, rather than a single Initialize carrying the token. On the SSH-bridged topology (Warp → SSH → remote proxy → Unix socket → daemon), each message adds bytes on the wire and a processing round on both ends. Authenticate is fire-and-forget so it does not block an RTT, but it adds one message per new connection for no semantic gain — the token was available to the client before Initialize was sent.
  • Transient unauthenticated state. With split messages, the session briefly exists in an "Initialize complete, Authenticate in-flight" window. No one observes this state today (no handlers use auth_token yet, §7), but the moment the first such handler lands, either the client or the handler has to reason about what happens if a request arrives during that window. Bundling collapses this: once InitializeResponse is received, the daemon's credential is already set.
  • No unified-surface benefit. The ostensible win of "one path to set the credential" is surface-level; Authenticate stays in place for refresh regardless, so both paths exist either way. The question is whether the split buys anything, and it doesn't.

4.6 Handshake-time upstream validation

Why rejected:

  • Doubles handshake latency.
  • Adds a failure mode orthogonal to the credential itself (transient network issue on the remote host blocks authentication even when the token is fine).
  • Store-and-forward produces the same end-to-end behavior via the reactive-refresh path at lower cost.

4.7 Per-connection credential map

Shape: ServerModel holds auth_tokens: HashMap<ConnectionId, String> instead of auth_token: Option<String>. Each Authenticate is keyed by the connection that sent it; each deregister_connection clears that connection's entry; each handler reads the entry for the connection its request arrived on. A dedicated ClearCredentials message pairs with the map to give the client a proactive clear path on logout.

This is the closest alternative to the chosen design — it was the initial shape of APP-3801 before the design collapsed to a singleton.

Why rejected:

  • Storage doesn't reflect the data model. APP-4068 partitions daemon socket paths by user identity (§1), so every connection on a given daemon belongs to the same Warp user and presents the same bearer token. A per-connection map holds N copies of the same string with no behavioral difference; the storage structure implies a flexibility the system does not provide.
  • Fan-out with no purpose. Token rotation in the per-connection model requires the manager to iterate every Connected session and call authenticate on each — N messages to write the same value N times. The singleton collapses this to one authenticate on any arbitrary connection, matching the pick-one pattern already used for other manager-to-server notifications.
  • Cleanup machinery with nothing to clean. Per-connection storage motivates a ClearCredentials message, a handle_clear_credentials branch, and an explicit auth_tokens.remove inside deregister_connection — each guarding narrow logout or teardown windows. With a singleton, none of these are needed: the daemon process itself is the trust boundary (Unix socket, user-partitioned path, OS-level file permissions owned by APP-4068), and the credential dies with the process on grace-period expiry. The per-connection design pays a fixed protocol-surface cost to close a window that, at realistic durations, is already bounded by the grace period the singleton accepts (§6).

What the singleton gives up: foreclosing per-connection policy if it is ever needed (different bearer tokens per tab, for example). No such use case exists today or is on the roadmap; adding per-connection state back later is a straight refactor if the need arises.

5. Testing and validation

  • Daemon-wide authentication: unit tests on ServerModel using in-memory transports. Send Initialize with a non-empty auth_token; assert auth_token() returns the value. Send a second Authenticate (rotation); assert last-writer-wins. Send Initialize with an empty auth_token on a daemon whose singleton is already populated; assert the existing value is preserved (empty on Initialize never clears). Register a second connection and send on either one; assert the singleton reflects the last write regardless of source connection.
  • Lifecycle: initialize with a token, deregister; assert auth_token() is still Some after deregister_connection (deliberate retention — no per-connection cleanup). Drop the ServerModel; assert a freshly-constructed model has no auth_token. Assert no file artifacts in ~/.warp* contain the token at any point during the above.
  • Client and manager: RemoteServerClient unit tests for initialize(Some(token)) producing an Initialize message with auth_token set, and initialize(None) producing one with the field empty. Unit test for authenticate(token) producing the expected Authenticate ClientMessage. RemoteServerManager integration test: new-session flow fetches the token via ServerApi before calling initialize and passes it through. Rotation test: rotation event sends authenticate to exactly one Connected session (pick-one, no fan-out) and none of the Disconnected ones.
  • Security: CI grep check that no {:?}/Debug formatting is applied to ClientMessage/Initialize/Authenticate inside server-side log sites. Unit test of describe_client_message on an Initialize input with a non-empty auth_token and on an Authenticate input asserts the token field is replaced with <redacted>. Audit test that start_remote_server argv and env contain no credential-shaped strings.

6. Risks and mitigations

  • Token-in-log regression. A future contributor adds log::info!("{:?}", msg) in the server path. Mitigation: CI grep check on {:?}-formatting of ClientMessage/Authenticate inside app/src/remote_server/ and crates/remote_server/, plus a redaction helper; all existing sites converted in this PR.
  • Credential persists during daemon grace period (accepted). After the last connection disconnects, APP-4068's daemon keeps running for up to 10 minutes before exiting. During that window the daemon process still holds auth_token in memory with no active consumer. Mitigation: the daemon's Unix socket is owned by APP-4068 with user-scoped file-system permissions — the same security boundary that protects live credentials on disk. Reaching the socket requires OS-level access as the owning user, at which point far more sensitive material is already accessible. Treating the grace-period window as a real exposure would require either a second timer (artificial cleanup) or teardown-on-last-disconnect (collapsing the daemon model). Neither is justified by the actual threat.
  • Stale token after logout. If the user logs out while one or more remote sessions are still alive, the daemon holds the (now-invalidated) token until its process exits at grace-period expiry. Mitigation: the client tears down the remote connections on logout, which starts the grace timer; warp-server treats the logged-out token as revoked on any upstream call, so the window is a latency issue, not an authorization-bypass issue.
  • Credential type heterogeneity. AuthToken can be Firebase(String), ApiKey(String), or NoAuth. The server stores the opaque bearer string. NoAuth users skip authenticate and leave auth_token unset; handler behavior for that state ships with the first handler that needs upstream auth (§7). No server-side changes are needed when a new credential variant is added as long as it produces a bearer string.

7. Follow-ups

  • First handler that actually calls upstream. This spec establishes only the plumbing (ServerModel::auth_token, handle_initialize's auth-setting branch, handle_authenticate, RemoteServerClient::initialize(auth_token), RemoteServerClient::authenticate, manager-side pre-initialize credential fetch and rotation wiring). No handler in this PR calls auth_token(). The follow-up PR introduces the first such handler and is where the MISSING_CREDENTIAL, CREDENTIAL_REJECTED, PERMISSION_DENIED, and any ErrorSubReason additions land — defined together with their first caller rather than as dead protocol surface now.
  • Client-side automatic reconnect. If APP-4068's reconnect follow-up lands, the new session's post-initialize auth step re-authenticates naturally — no changes needed here.
  • Daemon-scoped handlers without a ConnectionId context. If Warp ever needs a handler that runs outside any connection (periodic background sync, cross-tab broadcast), the singleton auth_token is directly usable. The current design already assumes every upstream call reads from daemon state rather than per-connection state.