Skip to content

feat(auth): headless login URL + import-codex + TUI#92

Merged
AprilNEA merged 4 commits into
masterfrom
feat/headless-login-and-codex-import
May 14, 2026
Merged

feat(auth): headless login URL + import-codex + TUI#92
AprilNEA merged 4 commits into
masterfrom
feat/headless-login-and-codex-import

Conversation

@AprilNEA
Copy link
Copy Markdown
Owner

@AprilNEA AprilNEA commented May 8, 2026

Summary

  • Headless-friendly login — auth-code flow (claude / codex / gemini / antigravity / iflow / amp) now prints the full authorization URL on stderr in CLI mode, so users on machines without a usable browser (no DISPLAY, xdg-open failing) can copy-paste it. Device-code flow already did this; this brings parity.
  • byokey import-codex — new subcommand mirroring import-claude-code. Reads ~/.codex/auth.json, decodes the access_token JWT's exp claim, and stores the token under account codex-cli. Supports both ChatGPT-mode OAuth and raw-OPENAI_API_KEY modes. Codex CLI uses the same path on macOS (no Keychain), so a single non-Keychain code path suffices.
  • byokey tui — new byokey-tui crate (ratatui + crossterm) wired up as a top-level subcommand.
  • install.sh + README — adds the install-script section to README that points at the existing install.sh.

Test plan

  • cargo build --release --bin byokey clean
  • cargo test -p byokey-auth codex_cli — 3 new unit tests pass (chatgpt-mode JSON parse, api_key-mode parse, JWT decode bails on non-JWT)
  • On a headless Linux box: byokey login codex --account dryrun prints the full https://auth.openai.com/oauth/authorize?... URL to stderr before blocking on the callback listener
  • On a box with Codex CLI logged in: byokey import-codex followed by byokey status reports codex: authenticated; account list shows codex-cli [Codex CLI] (active)
  • Manual smoke of byokey tui in an interactive terminal

- auth-code flow now prints the full authorization URL to stderr on
  the CLI path so users on headless machines (no DISPLAY, xdg-open
  unavailable) can copy-paste it when open::that fails. Mirrors the
  device-code flow's existing UX.
- New `byokey import-codex` subcommand mirroring import-claude-code:
  reads ~/.codex/auth.json on every platform (Codex CLI doesn't use
  Keychain), decodes the access_token JWT for `exp`, saves under
  account `codex-cli`. Supports both chatgpt-mode OAuth tokens and
  api_key-mode raw keys.
- New byokey-tui crate (ratatui + crossterm) wired in as `byokey tui`.
- README install-script section + install.sh entry point.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9bb27fcf6b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/actions/auth.rs
.to_string();
let label = label.unwrap_or_else(|| "Codex CLI".to_string());
self.auth
.save_token_for(&provider, &account_id, Some(label.as_str()), token)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the imported account when refreshing

When the default codex-cli import is the first Codex account, it becomes the active account, but expired active tokens are refreshed through AuthManager::refresh_token, which persists the refreshed token with store.save(provider, ...) (the default account) rather than this codex-cli account. After the first expiry, one request may get a fresh token in memory, but the active imported account remains expired, causing subsequent requests to hit the refresh cooldown/retry refresh forever instead of using the refreshed credentials.

Useful? React with 👍 / 👎.

Comment thread crates/auth/src/provider/codex_cli.rs Outdated
Comment on lines +72 to +76
return Ok(Some(OAuthToken {
access_token: key,
refresh_token: None,
expires_at: None,
token_type: Some("api-key".to_string()),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not import Codex API keys as OAuth tokens

For Codex CLI API-key mode, this stores the raw OPENAI_API_KEY in the token store, but CodexExecutor::token() treats every AuthManager token as OAuth (is_oauth = true) and sends it to the Codex Responses endpoint instead of the OpenAI Chat Completions API-key path. Users whose ~/.codex/auth.json contains only OPENAI_API_KEY will see import-codex succeed but proxied Codex requests will be authenticated/routed incorrectly; either reject this mode or teach the executor to honor token_type == "api-key".

Useful? React with 👍 / 👎.

- TokenStore::save now resolves the active account_id (from the DB
  for SqliteTokenStore, the in-memory map for InMemoryTokenStore)
  instead of hardcoding "default". The old behavior wrote refreshed
  tokens to a non-active "default" row that load() never read,
  stranding any provider whose active account had a different name
  (e.g. `codex-cli` from `import-codex`, or `claude-code` from
  `import-claude-code`) in a perpetual refresh loop. Adds regression
  tests in both stores.
- import-codex now rejects API-key-mode auth.json with a clear
  error pointing the user at `byokey add-api-key codex <sk-...>`.
  byokey's Codex executor targets the ChatGPT-mode Responses
  endpoint and would misroute a raw OpenAI API key.
- Bump rustls-webpki 0.103.12 → 0.103.13 (RUSTSEC-2026-0104,
  reachable panic in CRL parsing).
- Apply cargo fmt to codex_cli.rs and import_codex.
@pullfrog
Copy link
Copy Markdown

pullfrog Bot commented May 8, 2026

TL;DR — Brings OAuth login parity to headless CLI environments, ships a byokey import-codex subcommand that adopts a logged-in OpenAI Codex CLI's credentials, and adds a byokey tui interactive terminal UI built on a new shared ManagementClient. A latent regression in token-store save() is fixed along the way so that refreshes correctly target the active non-default account (e.g. codex-cli).

Key changes

  • Print the auth URL on stderr during CLI login — auth-code flow now echoes the full https://.../authorize?... URL before opening the browser, so users without a usable browser can copy-paste.
  • New byokey import-codex subcommand — reads ~/.codex/auth.json, decodes the JWT exp for refresh scheduling, and stores the token under account codex-cli. API-key-mode auth.json is rejected with a clear error pointing users at byokey add-api-key codex.
  • New byokey-tui crate and byokey tui commandratatui + crossterm UI with Status/Accounts/Usage tabs, 5s auto-refresh tick, and a new --url flag pointing at the ConnectRPC management API.
  • New client feature on byokey-proto — exposes a ManagementClient wrapper around the generated StatusService / AccountsService / AmpService clients; consumed by the TUI but reusable for other management API clients.
  • Token store save() now targets the active account — both InMemoryTokenStore and SqliteTokenStore previously hardcoded account_id = "default", which orphaned refreshes for any non-default active account.
  • install.sh + README install-script section — POSIX sh installer that fetches the latest release tarball into ~/.byokey/bin/, with BYOKEY_VERSION / BYOKEY_INSTALL_DIR overrides.

Summary | 23 files | 4 commits | base: masterfeat/headless-login-and-codex-import


Headless-friendly OAuth login

Before: auth-code flow only said "opening browser..." and silently relied on xdg-open / equivalent — on a headless box with no DISPLAY, the user saw nothing actionable.
After: stderr prints the full authorization URL and the localhost callback port before invoking the browser, matching the device-code flow's behavior.

The change is scoped to CLI mode; programmatic callers using the SDK form continue to receive the URL via the existing emit channel without extra stderr noise.

crates/auth/src/flow/auth_code.rs


byokey import-codex — adopt the local Codex CLI session

Before: users with a working Codex CLI login had to redo OAuth through byokey login codex.
After: byokey import-codex lifts the existing token straight out of ~/.codex/auth.json into the byokey store as account codex-cli (override with --account / --label).

The new byokey_auth::provider::codex_cli module parses the Codex CLI auth file, extracts the OAuth tokens block, and decodes the access_token JWT's exp claim so AuthManager schedules refresh correctly.

Why is API-key mode rejected outright?

An earlier revision accepted both ChatGPT-mode OAuth and raw OPENAI_API_KEY from auth.json (mapped to token_type = "api-key"). Review feedback flagged that byokey's Codex executor targets the ChatGPT-mode Codex Responses endpoint, where a raw sk-... cannot authenticate — silently importing it would produce a token that fails on first use. The current code returns a descriptive error directing users to byokey add-api-key codex <key> against the standard OpenAI API instead.

crates/auth/src/provider/codex_cli.rs · src/actions/auth.rs · src/main.rs · crates/types/src/traits.rs


Interactive terminal UI as a management API client

Before: inspecting state required chaining byokey status, byokey accounts <provider>, and SQL queries against the usage table.
After: byokey tui opens a three-tab UI (Status, Accounts, Usage) with Tab/arrow navigation, r to refresh, q/Esc/Ctrl-C to quit, and a 5-second auto-refresh tick. The CLI accepts --url <URL> to override the default http://127.0.0.1:8018 endpoint.

The crate sits outside the layered DAG and intentionally cannot reach into byokey-store, byokey-auth, or byokey-daemon. Its only BYOKEY dep is byokey-proto with the new client feature; all state — server liveness, accounts, usage — is fetched over ConnectRPC against the proxy's management surface. A new crates/tui/AGENTS.md and an updated root AGENTS.md codify this boundary so future changes don't reintroduce direct store access.

Tab ManagementClient call
Status get_status → server host:port + per-provider AuthStatus
Accounts list_accounts → per-provider AccountDetail with TokenState
Usage get_usage → totals plus per-model rows

crates/tui/src/lib.rs · crates/tui/src/app.rs · crates/tui/src/ui.rs · crates/tui/AGENTS.md


Reusable ManagementClient on byokey-proto

Before: byokey-proto only emitted generated server stubs; any client of the management API had to wire up connectrpc::client machinery itself.
After: an opt-in client Cargo feature exports a ManagementClient that bundles StatusService, AccountsService, and AmpService clients behind one constructor, plus convenience methods that unwrap to owned response types.

local_http builds a plaintext client at a given http::Uri with a 5s default timeout; with_config / with_transport are escape hatches for callers that need TLS, custom headers, or shared transports. The TUI is the first consumer; the same wrapper is available to any future client (e.g. a desktop app or scripting tool) without re-implementing transport setup.

crates/proto/src/client.rs · crates/proto/src/lib.rs · crates/proto/Cargo.toml


TokenStore::save() now overwrites the active account

Before: both InMemoryTokenStore::save and SqliteTokenStore::save hardcoded account_id = "default", so when the active account was named otherwise (e.g. codex-cli from import-codex), a refresh wrote a second non-active row that load() never returned.
After: save() resolves the currently-active account first and writes through to that row, falling back to "default" only when no active account exists yet.

Regression tests save_updates_non_default_active_account were added to both stores to lock in the behavior. This was surfaced by review feedback against the import-codex flow, where the bug would have caused refreshes to silently strand the imported token.

crates/store/src/memory.rs · crates/store/src/persistent/token.rs


install.sh and README install section

Before: binary install was Homebrew-only or cargo install byokey.
After: a POSIX-sh installer at the repo root fetches the latest release archive for the current os/arch, drops byokey into $HOME/.byokey/bin (or BYOKEY_INSTALL_DIR), and prints a PATH hint when the install dir is not already on PATH. README and docs/README_CN.md document the one-liner alongside the new byokey tui entry.

The script detects platform via uname, supports BYOKEY_VERSION for pinning, and err-exits with an actionable message if the resolved release lacks a build for the detected target.

install.sh · README.md · docs/README_CN.md

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

The new commit 20eccdb is labeled as a clippy/doc-comment fix but actually lands an incomplete crates/tui/src/lib.rs refactor that breaks the build. cargo check -p byokey-tui fails with three errors: byokey_proto and http are unresolved (neither is in crates/tui/Cargo.toml), and App::new is invoked with one argument while crates/tui/src/app.rs:75 still expects (Arc<SqliteTokenStore>, Arc<AuthManager>). src/main.rs:254 is also still calling byokey_tui::run(store.db).await against the old Option<PathBuf> signature.

Reviewed changes:

  • Replaced direct SqliteTokenStore + AuthManager wiring in crates/tui/src/lib.rs::run with a byokey_proto::client::ManagementClient (incomplete — App, the Cargo manifest, and the CLI caller were not updated).
  • Backticked OpenAI in the codex_cli.rs module doc comment.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/tui/src/lib.rs
};

use app::App;
use byokey_proto::client::ManagementClient;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import doesn't resolve. byokey-tui's Cargo.toml lists only byokey-types, byokey-store, byokey-auth, byokey-daemon, anyhow, rquest, ratatui, crosstermbyokey-proto isn't a dependency. Even with it added, byokey_proto has no client module: crates/proto/src/lib.rs only include!s the ConnectRPC-generated code, and grepping the workspace finds no ManagementClient type anywhere except this line. cargo check -p byokey-tui fails with E0433: failed to resolve: use of unresolved module or unlinked crate \byokey_proto`. The http::Urion line 31 has the same problem —http` isn't a dependency either.

Comment thread crates/tui/src/lib.rs
/// Returns an error if the terminal cannot be initialized or if the underlying
/// management API URL is invalid.
pub async fn run(endpoint: Option<String>) -> Result<()> {
let mut app = App::new(management_client(endpoint)?);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arity mismatch: App::new in crates/tui/src/app.rs:75 is still pub fn new(store: Arc<SqliteTokenStore>, auth: Arc<AuthManager>) -> Self and uses both fields in refresh(). cargo check reports E0061: this function takes 2 arguments but 1 argument was supplied. Likewise src/main.rs:254 still calls byokey_tui::run(store.db).await against the old Option<PathBuf> parameter, so even after fixing App the binary won't link. Either land the rest of the refactor (new App impl over ManagementClient, updated Cargo.toml, updated CLI caller) in this commit or revert the lib.rs changes back to the SQLite-direct version that was already shipped in 9bb27fc.

Copy link
Copy Markdown

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The TUI ConnectRPC refactor flagged in the prior review is now wired end-to-end (deps, App constructor, CLI caller all updated) and the build errors are gone. One small mismatch in crates/tui/src/lib.rs: the bail! message advertises "local http:// URLs only" but the validation only checks the scheme — --url http://anywhere.example.com:8018 is accepted and account labels/IDs and token state are sent there in plaintext.

Reviewed changes:

  • Refactored crates/tui to fetch state through byokey_proto::client::ManagementClient instead of touching SqliteTokenStore/AuthManager/byokey-daemon directly.
  • Added a new client cargo feature on byokey-proto exposing ManagementClient over the generated StatusServiceClient / AccountsServiceClient / AmpServiceClient.
  • Replaced the old byokey tui --db flag with --url <URL> (defaults to http://127.0.0.1:8018) in src/main.rs and byokey_tui::run.
  • Documented the new TUI boundary and subcommand in AGENTS.md, crates/tui/AGENTS.md, README.md, and docs/README_CN.md.

Prior review feedback:

  • byokey_proto::client::ManagementClient import unresolvable — addressed via new client feature on byokey-proto plus byokey-proto/http deps in crates/tui/Cargo.toml
  • App::new arity mismatch and src/main.rs calling old Option<PathBuf> signature — addressed (App::new(ManagementClient), byokey_tui::run(url))

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/tui/src/lib.rs
Comment on lines +34 to +39
if uri.scheme_str() != Some("http") {
bail!("TUI management client currently supports local http:// URLs only");
}
if uri.authority().is_none() {
bail!("management API URL must include host and port");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message claims "local http:// URLs only" but only the scheme is checked — --url http://anywhere.example.com:8018 passes validation and the TUI then sends GetStatus / ListAccounts / GetUsage to that host in plaintext, leaking account IDs, labels, token state and usage counters. Either drop "local" from the message or enforce a loopback authority (127.0.0.1 / ::1 / localhost) here.

@AprilNEA AprilNEA merged commit 1bbb69f into master May 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant