Authentication
How the Rust
induscodeagent resolves a provider key — environment variables for the quick path, and theindus signin/indus signoutcommands for stored credentials (masked api-key entry or paste-based browser sign-in / OAuth). The command and the 14-provider directory live ininduscode::launch::credentials; the registry plus the three browser flows ininduscode::launch::oauth; the disk vault (DiskAuthVault) writesauth.jsonunder~/.indusagi/agent/. Secrets are wrapped in a redactingSecretStringso a token never reaches a log line.
Table of Contents
- Overview
- The fastest path: environment variables
- indus signin
- indus signin --list
- indus signout
- auth status, login, logout
- Provider directory
- Browser sign-in (OAuth / PKCE)
- Stored accounts and the disk vault
- Where credentials live
- Secret redaction
- Typed faults
- Console overlays
- Programmatic surface
- Notes
Overview
A run needs exactly one thing from this subsystem: a usable api-key string for the provider that backs the model you are running. There are three ways to supply it, in increasing order of persistence:
- Environment variable — export
ANTHROPIC_API_KEY(or the provider's own key) and you are done; nothing is stored on disk. indus signinwith an api key — paste a key once (echo muted on a TTY); it is validated and written to the vault under a named account.indus signinwith browser sign-in (OAuth) — for the providers that support it, complete a paste-based PKCE authorization-code flow (or, for GitHub Copilot, a device grant) and store the resulting tokens.
The crate ships two cargo [[bin]] targets — indusr (primary) and indusagir
— that share one async fn main() -> ExitCode in src/bin/main.rs. The
user-facing command name (the brand resolve_brand reports, from BIN_NAMES)
is indus (primary) or indusagi, matched against the basename of argv[0];
anything else (including the raw indusr target name) falls back to
BIN_NAMES[0] = indus. This page writes commands as indus …; the help banner,
the examples below, and the api-key tip all print indus. The signin /
signout verbs are no-session commands the CLI owns: route short-circuits
them in main.rs (via is_credential_verb) before the
boot handoff, because boot would otherwise fall them
through to the interactive REPL. The command itself,
run_credential_command(argv, &dyn AuthVault, &dyn CredentialIo, &dyn OAuthSeam) -> CredentialResult, returns handled: false only when the leading token is not
a credential verb, so a normal prompt falls straight through.
/// The outcome of `run_credential_command` (`credentials.rs`).
pub struct CredentialResult {
pub handled: bool,
pub verb: Option<CredentialVerb>,
pub provider: Option<String>,
pub fault: Option<CredentialFault>,
}
See Launch for the dispatch order and the flag reader. For the Python and TypeScript editions of the same flow, see python-cli auth and the TS CLI.
The fastest path: environment variables
Every provider in the directory has a conventional environment variable. Set it
and the provider is authenticated for the process — no signin required:
export ANTHROPIC_API_KEY="sk-ant-..."
indus -p "explain this repo"
The api-key signin flow consults the env var first through env_api_key: if
your key is already exported it offers to store that value rather than asking you
to re-type it.
/// Read a provider api key already exported in the environment
/// (the env-first shortcut, `credentials.rs`).
fn env_api_key(entry: &ProviderEntry) -> Option<String> {
std::env::var(entry.env_key)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
| Provider | id |
Environment variable |
|---|---|---|
| Anthropic (Claude) | anthropic |
ANTHROPIC_API_KEY |
| OpenAI | openai |
OPENAI_API_KEY |
| Google Gemini | google |
GEMINI_API_KEY |
| xAI (Grok) | xai |
XAI_API_KEY |
| Groq | groq |
GROQ_API_KEY |
| Cerebras | cerebras |
CEREBRAS_API_KEY |
| Mistral | mistral |
MISTRAL_API_KEY |
| OpenRouter | openrouter |
OPENROUTER_API_KEY |
| MiniMax | minimax |
MINIMAX_API_KEY |
| Kimi (Moonshot) | kimi |
MOONSHOT_API_KEY |
| Sarvam | sarvam |
SARVAM_API_KEY |
| Krutrim | krutrim |
KRUTRIM_API_KEY |
| NVIDIA | nvidia |
NVIDIA_API_KEY |
| Z.ai | zai |
ZAI_API_KEY |
indus signin
signin resolves a provider, then either runs a browser sign-in or stores an api
key. Naming a provider is optional — omit it and the command prints the merged
sign-in directory and asks you to pick by number.
indus signin # interactive: pick from the menu
indus signin anthropic # provider as a bare positional
indus signin --provider openai --method api-key
indus signin --provider anthropic --account work # name the stored account
indus login anthropic # `login` is an alias for `signin`
as_verb accepts login / logout as natural-language aliases of
signin / signout. The verb tail is parsed by read_verb_flags:
| Flag | Effect |
|---|---|
--provider <id> |
Provider to sign in to (also accepted as a bare positional) |
--account <name> |
Account label to store under (default: default) |
--method oauth / --oauth |
Force browser sign-in |
--method api-key / --api-key / --apikey |
Force api-key entry |
--list / --ls |
List stored accounts read-only (see below) |
When a provider supports browser sign-in and you do not request a method, the
browser path is preferred (do_signin: use_oauth = method == Some(Oauth) || (method.is_none() && target.oauth_capable)). An explicit --method always wins.
Default-to-api-key (the CLI default): the shipped OAuth client ids are
unconfigured sentinels out of the box, so a bare indus signin <provider> would
otherwise land on an inert browser path. The CLI's default_signin_to_api_key
appends --method api-key to a signin argv only when the user has not already
named a method (detected by argv_picks_method, which looks for --method /
--oauth / --api-key / --apikey), so the api-key prompt is the easy path;
--oauth / --method oauth are preserved untouched.
The first account stored for a provider becomes its default. Validation runs before any write:
const MIN_KEY_LENGTH: usize = 20;
const MAX_ACCOUNT_LENGTH: usize = 50;
// placeholder markers rejected as not-a-real-key:
const PLACEHOLDER_MARKERS: &[&str] =
&["your-api-key", "your_api_key", "xxxx", "placeholder", "changeme", "<"];
pub fn validate_api_key(key: &str) -> Option<CredentialFault>;
pub fn validate_account_name(name: &str) -> Option<CredentialFault>;
A key must be non-empty, at least 20 characters, and free of placeholder markers;
an account name must be non-empty, at most 50 characters, and match
^[A-Za-z0-9_-]+$. Storing under a name that already exists is a NameCollision
fault — pick a different --account or signout first.
indus signin --list
--list (alias --ls) is a read-only switch on signin that short-circuits the
verb: do_list_accounts walks the merged sign-in directory (de-duplicating
providers that appear in both halves), queries the vault for each provider, and
prints one line per stored account — tagging the provider default and whether the
record is a [browser] (OAuth) or [api key] record (an unrecognised record
reads [unknown]). It writes nothing.
indus signin --list
Saved accounts:
Anthropic (Claude) (anthropic):
- default (default) [browser]
OpenAI (openai):
- work [api key]
When no provider holds a saved account it prints a single (none) line. --list
is honoured on signin only; it is meaningless for signout.
indus signout
signout (alias logout) removes stored credentials. Name an --account to
remove one, or omit it to remove every account for the provider:
indus signout --provider anthropic --account work # remove one account
indus signout --provider anthropic # remove all anthropic credentials
indus logout openai # `logout` is an alias
Removing a provider/account that holds nothing is a NotFound fault. When the
removed account was the provider default and survivors remain, the first survivor
is promoted to default (DiskAuthVault::remove); when the last account is
removed, the provider key is dropped from the file entirely.
auth status, login, logout
The CLI also owns an auth subcommand, intercepted in route before the global
--help / --version short-circuit so auth --help reaches auth-specific help.
classify_auth_verb dispatches the sub-verb:
| Sub-verb | Route | What runs |
|---|---|---|
auth status [provider] |
AgentStatus |
The agent's own vault reader (run_agent_status) |
auth logout [...] |
AgentLogout |
The agent's signout over auth.json (run_agent_logout) |
auth login / refresh / help / unknown |
Framework |
The framework run_auth_command (indusagi::shell_app::auth_cli) |
indus auth status # show which providers are signed in
indus auth status anthropic # filter to one provider
indus auth login anthropic # framework PKCE browser flow (where configured)
indus auth logout # agent multi-account logout
auth status exists because of a real reconciliation gap: the framework
auth status reader understands only the top-level single-record
{ provider: StoredCredential } shape and folds the agent's nested multi-account
vault to empty — so after indus signin anthropic it would report nothing. The
agent-owned reader (DiskAuthVault::status_entries) reads BOTH shapes and reports
every stored sign-in, api-key and browser alike:
pub struct AuthStatusEntry {
pub provider: String,
pub account: String,
pub kind: AuthKind, // ApiKey | Oauth
pub is_default: bool,
pub refreshable: bool, // true only for an OAuth record carrying a refresh token
pub freshness: String, // "no expiry recorded" | "expired" | "valid for ~N min"
}
OAuth freshness follows the framework wording: no expiry recorded when no
deadline is stored, expired once the deadline passes, else valid for ~N min
(rounded to the nearest minute, +30_000 ms before the /60_000 divide). auth login <provider> prints a one-line tip pointing at the working api-key path
(Tip: to log in with an API key instead, run: indus signin <provider>) before
handing off, because a default build's OAuth client ids are unconfigured
sentinels.
Provider directory
PROVIDER_DIRECTORY is the 14-provider api-key directory — every provider the
sign-in surface can store a key for. Each ProviderEntry carries the facts the
prompts print and validate against:
pub struct ProviderEntry {
pub id: &'static str, // stable provider id, e.g. "anthropic"
pub label: &'static str, // human-facing menu label
pub env_key: &'static str, // conventional api-key env var
pub docs_url: &'static str, // page where a user obtains a key
}
pub const PROVIDER_DIRECTORY: &[ProviderEntry] = &[ /* 14 entries */ ];
pub fn find_provider(id: &str) -> Option<&'static ProviderEntry>; // case-insensitive
find_provider is the case-insensitive lookup by id. The api-key directory and
the OAuth registry are merged by list_login_providers into the menu signin
shows, browser-sign-in providers first; a provider that appears in both halves is
listed twice so the user can pick either path.
Browser sign-in (OAuth / PKCE)
The OAuth registry plus the three real browser flows live in
induscode::launch::oauth (behind the oauth cargo feature). The Rust framework
ships only the OAuth transport primitives
(indusagi::llmgateway::{create_pkce_pair, build_auth_url, exchange_code, refresh_token}) — not the high-level provider registry — so the agent owns:
- the [
OAuthProvider] trait (id/name/login/refresh_token/get_api_key) and the storedOAuthCredentialsshape; - the process-global
OAuthProviderRegistry(aOnceLock-backedRwLock<IndexMap>) andregister_built_in_oauth_providers, an idempotent explicit boot-time prime (Rust has no import-time side effects); - the three flows — Anthropic (
anthropic), OpenAI Codex (openai-codex), and GitHub Copilot (github-copilot).
The paste-based authorization-code flow
There is no local callback HTTP server anywhere in this build. The shared
paste_code_login choreography is:
- resolve the real client id from the per-provider env var, or refuse
(
resolve_client_id); - mint a PKCE pair (
create_pkce_pair(64),S256) and a fresh CSRFstate; - build the authorization URL (
build_auth_url) and open it (open_login_url, which accepts onlyhttp/httpsschemes); - read the pasted authorization code — a bare code, a
code#statefragment, or the whole redirect URL — and extract thecodequery param (extract_code); - exchange the code for tokens (
exchange_code) and persist them through the vault.
pub fn extract_code(pasted: &str) -> String; // bare code | code#state | full redirect URL
pub async fn start_oauth_login(
provider_id: &str,
callbacks: &dyn OAuthLoginCallbacks,
vault: &dyn OAuthVaultSink,
account: &str,
) -> Result<OAuthLoginResult, OAuthError>;
start_oauth_login looks the provider up, applies the sentinel gate up front so a
doomed consent flow never opens, runs provider.login, and stores under account
(the first account stored becomes default).
The client-id sentinel gate
No real client ids ship in source. Each of the three providers has a parity-locked
env var and <UNREGISTERED-…> sentinel; the gate reads the env var live (so a
deployer who exports it before /login is honoured) and refuses an inert flow
naming the exact variable:
| Provider | Env var | Sentinel |
|---|---|---|
anthropic |
INDUSAGI_ANTHROPIC_OAUTH_CLIENT_ID |
<UNREGISTERED-ANTHROPIC-OAUTH-APP> |
openai-codex |
INDUSAGI_OPENAI_CODEX_CLIENT_ID |
<UNREGISTERED-OPENAI-CODEX-APP> |
github-copilot |
INDUSAGI_GITHUB_COPILOT_CLIENT_ID |
<UNREGISTERED-COPILOT-APP> |
pub fn oauth_client_id_env_var(provider_id: &str) -> Option<&'static str>;
pub fn has_registered_oauth_client_id(provider_id: &str) -> bool;
A provider with no known wiring is treated as registered (only the three sentinel-shipped providers are gated).
GitHub Copilot device flow (RFC 8628)
GitHub Copilot uses the OAuth device-authorization grant instead of a redirect:
the user enters a code at a verification URL while the driver polls the token
endpoint. The poll FSM is pure and unit-tested, modelled on the framework
connectors-saas ConnectAction rule table:
pub enum PollStatus { Authorized, Pending, SlowDown, Denied, Expired }
pub fn classify_poll(error: Option<&str>) -> PollStatus;
pub enum PollDecision {
Done,
Poll { wait_ms: u64, slowed: bool },
Expired,
Failed { reason: String },
}
pub fn plan_poll(status: PollStatus, base_interval_ms: u64) -> PollDecision;
slow_down bumps the interval by 5s per RFC 8628 §3.5; unknown error codes are
treated as a terminal failure. The live device-code exchange runs at the
interactive console seam, not in a headless context.
The browser launcher
open_login_url refuses any scheme that is not http/https (a security gate)
and then tries an ordered list of platform launch strategies — open on macOS,
cmd /c start on Windows, xdg-open / x-www-browser / google-chrome /
chromium on Linux — spawning and detaching the first that succeeds.
Stored accounts and the disk vault
The vault surface the command depends on is the AuthVault trait
(object-safe + async, so both the CLI and the console overlays hold a
&dyn AuthVault):
#[async_trait]
pub trait AuthVault: Send + Sync {
async fn list_accounts(&self, provider: &str) -> Vec<String>;
async fn default_account(&self, provider: &str) -> Option<String>;
async fn put_api_key(&self, provider: &str, account: &str,
api_key: &SecretString, make_default: bool) -> Result<(), VaultError>;
async fn put_oauth(&self, provider: &str, account: &str,
credentials: &OAuthCredentials, make_default: bool) -> Result<(), VaultError>;
async fn auth_kind(&self, provider: &str, account: &str) -> Option<AuthKind>;
async fn read_usable_key(&self, provider: &str, account: &str) -> Option<SecretString>;
async fn remove(&self, provider: &str, account: Option<&str>) -> bool;
}
The concrete implementation is DiskAuthVault::new(path), keyed
provider → account → record. Each on-disk AuthRecord is a discriminated enum
serialized with a "kind" tag:
#[serde(tag = "kind", rename_all = "camelCase")]
enum AuthRecord {
ApiKey { key: SecretString, #[serde(rename = "isDefault")] is_default: bool },
Oauth { #[serde(flatten)] credentials: OAuthCredentials,
#[serde(rename = "isDefault")] is_default: bool },
}
The OAuth record's wire fields are flattened OAuthCredentials
(access / refresh / expires / issuedAt / provider, all camelCase). The
file is IndexMap-ordered so the serialized bytes stay stable, written as pretty
JSON with a trailing newline and 0o600 permissions on Unix. The vault reads the
whole file each call (it is tiny and operations are interactive) and is robust to
three input shapes:
- the agent's nested
{ provider: { account: record } }shape; - the legacy pre-discriminant
{ apiKey, isDefault }record (nokind), coerced bynormalise_record; - a framework-written single-record
{ provider: StoredCredential }file (accessToken/refreshToken/expiresAt/updatedAt), upgraded to onedefaultOAuth account per provider (upgrade_framework_record).
A missing or malformed file reads as empty rather than wedging the store.
read_usable_key returns an api key verbatim; for an OAuth record the bare disk
vault returns the stored access token, while the console wiring supplies a
refreshing variant that exchanges an expired token via the registry.
Where credentials live
The agent nests its state one level deeper than the framework Locator: the
profile root is <home>/.indusagi/agent (the framework's own root is
<home>/.indusagi), and the credential store is auth.json under it. The path is
resolved by induscode::core::workspace into Workspace::auth_path:
| Variable | Role |
|---|---|
INDUSAGI_CODING_AGENT_DIR |
Agent profile-directory override (highest precedence) |
| (default) | ~/.indusagi/agent |
// brand.rs — the single source of identity truth
profile_dir_name: ".indusagi"
state_dir_name: "agent"
env_prefix: "INDUSAGI"
env_profile_dir: "INDUSAGI_CODING_AGENT_DIR"
env_debug: "INDUSAGI_DEBUG"
// auth_path = <profile_root>/auth.json
So a default install stores credentials at ~/.indusagi/agent/auth.json. See
Settings and the boot
pipeline for directory materialisation and the legacy-oauth-file migration.
Secret redaction
Every bearer secret (api key, access/refresh token) is wrapped in SecretString,
whose Debug and Display redact the value so no secret can leak into a tracing
span, a {:?} log line, or a panic message. The cleartext is reachable only via
SecretString::expose:
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct SecretString(String);
impl SecretString {
pub fn new(value: impl Into<String>) -> Self;
pub fn expose(&self) -> &str; // the only path that yields the real bytes
pub fn is_empty(&self) -> bool;
}
// {:?} → SecretString("<redacted>") | {} → <redacted>
OAuthCredentials likewise has a custom Debug that prints <redacted> for its
access/refresh fields. On a TTY the api-key prompt mutes echo via crossterm raw
mode (read_secret_raw); a piped stdin falls through to a plain line read so the
flow stays scriptable.
Typed faults
Every failure is a typed CredentialFault, never a string sentinel or a bare
process::exit. format_credential_fault(&fault) renders the single human-facing
message (label, message, and an optional hint on a second indented line) the caller
prints before exiting non-zero. The seven kinds:
pub enum CredentialFaultKind {
UnknownProvider, InvalidKey, InvalidAccount,
NameCollision, NotFound, Vault, Aborted,
}
| Kind | label() |
Meaning |
|---|---|---|
UnknownProvider |
Unknown provider | A named provider is not in the directory / registry |
InvalidKey |
Invalid api key | The api key is empty, too short, or a placeholder |
InvalidAccount |
Invalid account name | The account name is empty, too long, or malformed |
NameCollision |
Account already exists | An account by that name already exists for the provider |
NotFound |
Nothing to remove | signout found nothing to remove |
Vault |
Credential store error | The credential store (or browser sign-in) failed |
Aborted |
Cancelled | The interactive provider pick was cancelled |
A vault I/O failure is a separate VaultError(String) that the command folds into
a Vault fault (to_vault_fault). The command never panics for an expected
failure — it surfaces as result.fault, which run_agent_credential maps to
ExitCode::FAILURE after printing the message to stderr.
Console overlays
Inside the interactive ratatui console, three leaf modules under
console/overlays/ route credential management onto the framework's LoginDialog
and OAuthDialog widgets (see Dialogs):
| Module | Modal | Role |
|---|---|---|
signin.rs |
ModalKind::SignIn |
The merged-directory picker (LoginMode::Login); resolve_direct_route skips the picker for /login <provider>; routes each entry by AuthKind to the entry overlay |
signout.rs |
ModalKind::SignOut |
The saved-account picker (LoginMode::Logout); accounts are pre-fetched before the push (a synchronous handle_key cannot await the vault) |
oauth.rs |
ModalKind::Oauth |
The in-flight browser/api-key entry overlay; seeds OAuthOverlayState, then drives the live flow over a channel |
Because the framework Dialog::handle_key is synchronous, the live OAuth flow runs
on a spawned task and reports back over a tokio::sync::mpsc channel:
OverlayCallbacks implements OAuthLoginCallbacks by sending OAuthProgress
events (Auth / Prompt / Progress) that the host folds into the open dialog
with apply_progress. A manual-code prompt parks a oneshot::Sender<String> the
dialog's Enter fulfils. entry_mode_for(AuthKind) maps a chosen provider to the
entry body (OAuthMode::Oauth vs OAuthMode::ApiKey); build_api_key_save
produces the vault-write for a submitted key; provider_model_rule binds the
provider's newest catalog model after sign-in (early entries 404).
Programmatic surface
The whole flow is async and its I/O is injectable (CredentialIo for the
console seam, OAuthSeam for the browser registry, AuthVault for the store), so
it can be driven over in-memory stand-ins with no real terminal, network, or disk.
| Name | Kind | Module | Purpose |
|---|---|---|---|
run_credential_command |
async fn | launch::credentials |
The signin/signout command; returns CredentialResult with handled/fault |
PROVIDER_DIRECTORY |
const | launch::credentials |
The 14-provider api-key directory |
find_provider |
fn | launch::credentials |
Case-insensitive provider lookup by id |
validate_api_key / validate_account_name |
fn | launch::credentials |
Format validators returning Option<CredentialFault> |
format_credential_fault |
fn | launch::credentials |
Render a CredentialFault as one human-facing message |
DiskAuthVault |
struct | launch::credentials |
The on-disk provider→account→record vault; status_entries for auth status |
AuthVault |
trait | launch::credentials |
The vault surface the command depends on |
SecretString |
struct | launch::credentials |
Redacting wrapper for a bearer secret |
CredentialFault / CredentialFaultKind |
struct / enum | launch::credentials |
Typed failure record (7 kinds) and its discriminant |
register_built_in_oauth_providers |
fn | launch::oauth |
Idempotent registry prime (anthropic, openai-codex, github-copilot) |
start_oauth_login |
async fn | launch::oauth |
Drive a browser sign-in and persist through the vault |
list_login_providers |
fn | launch::oauth |
The merged sign-in directory, tagged by AuthKind |
is_oauth_capable / has_registered_oauth_client_id |
fn | launch::oauth |
Registry-capability and sentinel-gate predicates |
open_login_url / extract_code |
fn | launch::oauth |
http(s)-only browser launcher and pasted-code extractor |
OAuthCredentials |
struct | launch::oauth |
Stored browser-sign-in tokens (access/refresh/expires) with redacting Debug |
use induscode::launch::credentials::{
run_credential_command, format_credential_fault, DiskAuthVault,
};
use induscode::launch::oauth::register_built_in_oauth_providers;
let _ = register_built_in_oauth_providers(); // explicit prime; idempotent
let vault = DiskAuthVault::new(workspace.auth_path.clone());
let result = run_credential_command(
&["signin".into(), "openai".into(), "--method".into(), "api-key".into()],
&vault, &io, &oauth_seam,
).await;
if let Some(fault) = result.fault {
eprintln!("{}", format_credential_fault(&fault)); // exit non-zero
}
A non-verb leading token short-circuits cleanly:
let res = run_credential_command(&["explain".into(), "this".into()], &v, &io, &o).await;
assert!(!res.handled); // the orchestrator falls through to a normal run
Notes
signin/signoutacceptlogin/logoutas natural-language aliases.- The CLI appends
--method api-keyto a baresignin(no method chosen), since the default-build OAuth client ids are unconfigured sentinels; pass--oauthto force the browser flow. - The env var is consulted before prompting, so an already-set
ANTHROPIC_API_KEYcan be stored without re-typing it. - Browser sign-in is paste-based: there is no local callback server. Approve in the browser, then paste the code (or the full redirect URL) back.
auth statusreads BOTH the agent's nested vault AND a framework single-record file, so an api-key sign-in is always visible.- State lives under
~/.indusagi/agent/by default; relocate it withINDUSAGI_CODING_AGENT_DIR. - For the underlying provider abstraction and the OAuth transport primitives, see the framework LLM gateway; for picking a model once authenticated, see the launch subsystem.
