refactor(coder-lite): move the login flow inside the TUI

0c7b78300e5c · AtlantisPleb · · parent d9b5cc5c997c

refactor(coder-lite): move the login flow inside the TUI

The GitHub device-authorization flow now starts from inside the
alternate screen. The TUI opens first; if no valid token is found, it
shows 'Press Enter to log in with GitHub.' as a notice. Pressing Enter
inside the TUI runs do_login, shows progress and errors in the transcript,
and creates the Session once the token is validated. A stored token is
also validated before a Session is created.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/coder-lite/src/interactive.rs

Diff

1 file changed, +89 -75

crates/coder-lite/src/interactive.rs modified +89 -75

@@ -60,8 +60,6 @@ pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::

60 60
        return Ok(());
61 61
    }
62 62
63
    ensure_authenticated().await?;
64
65 63
    let lane = Lane::from_str(&options.lane_name);
66 64
    let (tx, rx) = mpsc::channel::<Control>();
67 65
    let mut ui = CoderUi::new();

@@ -90,27 +88,39 @@ pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::

90 88
    };
91 89
    ui.agents = agents.clone();
92 90
93
    let session = Arc::new(Mutex::new(Session::open(
94
        lane.clone(),
95
        &options.lane_name,
96
        options.reasoning.clone(),
97
        agents,
98
        tx.clone(),
99
    )));
91
    let mut session: Option<Arc<Mutex<Session>>> = None;
100 92
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
101 93
102
    // What the session is, said once, from what it actually holds. The model
103
    // is not named here: no model has answered yet, and naming the lane's
104
    // preferred id as though it had is exactly the class of claim this UI must
105
    // not make.
106
    ui.entries.push(Entry::new(
107
        Role::Notice,
108
        format!(
109
            "{} · {} · {acp_line} · /help",
110
            lane.label(),
111
            crate::runtime::api_base()
112
        ),
113
    ));
94
    if let Some(token) = crate::runtime::user_token() {
95
        let endpoint = openagents_cli::auth::resolve_endpoint(None, None)?;
96
        if validate_token(&endpoint.origin, &Secret::new(token)).await.is_ok() {
97
            session = Some(Arc::new(Mutex::new(Session::open(
98
                lane.clone(),
99
                &options.lane_name,
100
                options.reasoning.clone(),
101
                agents.clone(),
102
                tx.clone(),
103
            ))));
104
            ui.entries.push(Entry::new(
105
                Role::Notice,
106
                format!(
107
                    "{} · {} · {acp_line} · /help",
108
                    lane.label(),
109
                    crate::runtime::api_base()
110
                ),
111
            ));
112
        } else {
113
            ui.entries.push(Entry::new(
114
                Role::Notice,
115
                "Stored token did not authenticate. Press Enter to log in with GitHub.",
116
            ));
117
        }
118
    } else {
119
        ui.entries.push(Entry::new(
120
            Role::Notice,
121
            "Press Enter to log in with GitHub.",
122
        ));
123
    }
114 124
115 125
    enable_raw_mode()?;
116 126
    let mut stdout = stdout();

@@ -169,8 +179,46 @@ pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::

169 179
            ComposerAction::Submit(text) => {
170 180
                history.record(&text);
171 181
                history.stop_walking();
172
                if !text.trim().is_empty() {
173
                    submit(&mut ui, text, &session, &tx, &cwd);
182
                if let Some(session) = &session {
183
                    if !text.trim().is_empty() {
184
                        submit(&mut ui, text, session, &tx, &cwd);
185
                    }
186
                } else if text.trim().is_empty() {
187
                    ui.entries.push(Entry::new(
188
                        Role::Notice,
189
                        "Opening GitHub login in your browser...",
190
                    ));
191
                    match do_login().await {
192
                        Ok(message) => {
193
                            ui.entries.push(Entry::new(Role::Notice, message));
194
                            session = Some(Arc::new(Mutex::new(Session::open(
195
                                lane.clone(),
196
                                &options.lane_name,
197
                                options.reasoning.clone(),
198
                                agents.clone(),
199
                                tx.clone(),
200
                            ))));
201
                            ui.entries.push(Entry::new(
202
                                Role::Notice,
203
                                format!(
204
                                    "{} · {} · {acp_line} · /help",
205
                                    lane.label(),
206
                                    crate::runtime::api_base()
207
                                ),
208
                            ));
209
                        }
210
                        Err(error) => {
211
                            ui.entries.push(Entry::new(
212
                                Role::Notice,
213
                                format!("Login failed: {error}"),
214
                            ));
215
                        }
216
                    }
217
                } else {
218
                    ui.entries.push(Entry::new(
219
                        Role::Notice,
220
                        "Press Enter to log in with GitHub.",
221
                    ));
174 222
                }
175 223
            }
176 224
            ComposerAction::Redraw => history.stop_walking(),

@@ -192,60 +240,23 @@ pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::

192 240
    // `Drop` backstop can only spawn an ending this process may exit before
193 241
    // polling. It ends by reporting what the session did, so leaving is not
194 242
    // recorded as a cancellation and the thread can be resumed later.
195
    match tokio::time::timeout(REVOCATION_GRACE, async {
196
        session.lock().await.finish().await
197
    })
198
    .await
199
    {
200
        Ok(Ok(Some(line))) => println!("{line}"),
201
        Ok(Ok(None)) => {}
202
        Ok(Err(error)) => eprintln!("coder-lite: the thread was not ended: {error}"),
203
        Err(_) => eprintln!(
204
            "coder-lite: the session was still working after {}s, so its thread was left to \
205
             the best-effort ending.",
206
            REVOCATION_GRACE.as_secs()
207
        ),
208
    }
209
    Ok(())
210
}
211
212
/// If no valid credential is in the environment or store, start a GitHub
213
/// device-authorization flow and store the resulting token for the runtime to
214
/// spend. The TUI is not yet on the alternate screen, so this prints normally.
215
async fn ensure_authenticated() -> Result<(), Box<dyn std::error::Error>> {
216
    let endpoint = openagents_cli::auth::resolve_endpoint(None, None)?;
217
    let store = CredentialStore::for_origin(&endpoint.origin);
218
219
    if let Ok(key) = std::env::var("OPENAGENTS_API_KEY") {
220
        let token = Secret::new(key);
221
        if validate_token(&endpoint.origin, &token).await.is_ok() {
222
            return Ok(());
223
        }
224
        eprintln!("OPENAGENTS_API_KEY did not authenticate; it will be ignored.");
225
        // SAFETY: this process owns the environment; the TUI has not started.
226
        unsafe { std::env::remove_var("OPENAGENTS_API_KEY") };
227
    }
228
229
    if let Some(held) = store.find_token()? {
230
        if validate_token(&endpoint.origin, &held.token).await.is_ok() {
231
            return Ok(());
232
        }
233
        eprintln!("Stored token did not authenticate; logging in again.");
234
        store.remove()?;
235
    }
236
237
    println!("Press Enter to log in with GitHub.");
238
    let mut line = String::new();
239
    let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
240
    tokio::io::AsyncBufReadExt::read_line(&mut stdin, &mut line).await?;
241
242
    match do_login().await {
243
        Ok(message) => {
244
            println!("{message}");
245
            Ok(())
243
    if let Some(session) = &session {
244
        match tokio::time::timeout(REVOCATION_GRACE, async {
245
            session.lock().await.finish().await
246
        })
247
        .await
248
        {
249
            Ok(Ok(Some(line))) => println!("{line}"),
250
            Ok(Ok(None)) => {}
251
            Ok(Err(error)) => eprintln!("coder-lite: the thread was not ended: {error}"),
252
            Err(_) => eprintln!(
253
                "coder-lite: the session was still working after {}s, so its thread was left \
254
                 to the best-effort ending.",
255
                REVOCATION_GRACE.as_secs()
256
            ),
246 257
        }
247
        Err(error) => Err(error),
248 258
    }
259
    Ok(())
249 260
}
250 261
251 262
/// Check that a token is accepted by the deployment without calling GitHub.

@@ -274,6 +285,9 @@ async fn validate_token(origin: &str, token: &Secret) -> Result<(), Box<dyn std:

274 285
/// open the approval URL in the browser, poll for the token, store it, and
275 286
/// verify it against the model catalog. The token is also placed in
276 287
/// `OPENAGENTS_API_KEY` so the runtime spends it without a second store lookup.
288
///
289
/// This is called from inside the TUI, so it must not print to stdout; the
290
/// caller is responsible for showing any message in the transcript.
277 291
pub async fn do_login() -> Result<String, Box<dyn std::error::Error>> {
278 292
    let endpoint = openagents_cli::auth::resolve_endpoint(None, None)?;
279 293
    let client = DeviceClient::new(&endpoint.origin);

This page updates live while a promote is in flight · changelog