Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
# Reserve reliability over theoretical savings: this repo has a proven
# Blacksmith 4-vCPU Windows path; a 2-vCPU dispatch queued instead of
# provisioning during the 2026-09-05 audit. Keep reserve CI dependable.
runs-on: blacksmith-4vcpu-windows-2025
runs-on: windows-latest
timeout-minutes: 25

steps:
Expand Down
2 changes: 2 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Exit codes (stable intent): `0` ok, `1` below threshold, usage errors for bad ar

```powershell
codexbar serve --port 8080
codexbar serve --request-timeout 20 # answer 504 after 20 s; 0 (default) waits
# Non-loopback binds need a dashboard token and --allow-plain-http (cleartext bearer).
# Prefer: $env:CODEXBAR_DASHBOARD_TOKEN = '...'
```
Expand All @@ -91,6 +92,7 @@ Typical endpoints: `/health`, `/usage`, `/cost` (and dashboard snapshot routes w

```powershell
codexbar config providers
codexbar config providers --json # [{provider, displayName, enabled, defaultEnabled}]
codexbar config enable -p cursor
codexbar config disable -p cursor
printf '%s' $env:OPENROUTER_API_KEY | codexbar config set-api-key -p openrouter --stdin
Expand Down
207 changes: 207 additions & 0 deletions rust/src/cli/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ pub enum ConfigCommand {
/// Provider CLI name or alias
provider: String,
},
/// Sign in to a provider
Login {
/// Provider CLI name
#[arg(long)]
provider: String,
/// Emit machine-readable device authorization details
#[arg(long)]
json: bool,
},
/// Store an API key for a provider
SetApiKey {
/// Provider CLI name or alias
Expand Down Expand Up @@ -70,6 +79,7 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> {
ConfigCommand::Providers => list_providers().await,
ConfigCommand::Enable { provider } => set_provider_enabled(&provider, true).await,
ConfigCommand::Disable { provider } => set_provider_enabled(&provider, false).await,
ConfigCommand::Login { provider, json } => login(&provider, json).await,
ConfigCommand::SetApiKey {
provider,
api_key,
Expand All @@ -80,6 +90,87 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> {
}
}

async fn login(provider: &str, json: bool) -> anyhow::Result<()> {
if parse_provider(provider)? != ProviderId::Copilot {
anyhow::bail!("Device login is only supported for copilot.");
}
if !cfg!(windows) {
anyhow::bail!("Copilot login requires Windows protected token storage.");
}
let flow = crate::providers::copilot::device_flow::CopilotDeviceFlow::new();
let device = flow.request_device_code().await?;
write_device_code(&mut std::io::stdout().lock(), &device, json)?;
let token = flow
.wait_for_token(&device.device_code, device.interval, device.expires_in)
.await?;
let identity = crate::providers::copilot::CopilotApi::new()
.fetch_identity_with_token(&token, None)
.await;
store_copilot_token(&TokenAccountStore::new(), token, identity)?;
Ok(())
}

fn write_device_code(
out: &mut impl std::io::Write,
device: &crate::providers::copilot::device_flow::DeviceCodeResponse,
json: bool,
) -> anyhow::Result<()> {
if json {
writeln!(
out,
"{}",
serde_json::json!({
"verificationURI": device.verification_uri,
"userCode": device.user_code,
"expiresIn": device.expires_in,
"status": "authorization_pending"
})
)?;
} else {
writeln!(
out,
"Visit {} and enter code {}.",
device.verification_uri, device.user_code
)?;
}
out.flush()?;
Ok(())
}

fn store_copilot_token(
store: &TokenAccountStore,
token: String,
identity: Result<crate::providers::copilot::GitHubIdentity, crate::core::ProviderError>,
) -> anyhow::Result<()> {
let identity = identity.map_err(|_| {
anyhow::anyhow!("Could not retrieve your GitHub identity. Please try signing in again.")
})?;
let id = identity.id.ok_or_else(|| {
anyhow::anyhow!("GitHub did not provide an account ID. Please try signing in again.")
})?;
let mut data = store.load_provider(ProviderId::Copilot)?;
let identifier = Some(format!("github:user:{id}"));
let label = identity.login;
if let Some(index) = identifier.as_ref().and_then(|id| {
data.accounts
.iter()
.position(|a| a.external_identifier.as_ref() == Some(id))
}) {
data.accounts[index].token = token;
data.accounts[index].label = label;
data.accounts[index].mark_used();
data.set_active(index);
} else {
let mut account = crate::core::TokenAccount::new(label, token);
account.external_identifier = identifier;
account.mark_used();
data.add_account(account);
data.set_active(data.accounts.len() - 1);
}
store.save_provider(ProviderId::Copilot, &data)?;
Ok(())
}

/// Validate configuration files
async fn validate_config() -> anyhow::Result<()> {
let mut report = ValidationReport::default();
Expand Down Expand Up @@ -484,6 +575,122 @@ mod tests {
use crate::settings::ManualCookies;
use serde_json::json;

#[test]
fn device_json_matches_mac_contract_and_hides_device_code() {
let device = crate::providers::copilot::device_flow::DeviceCodeResponse {
device_code: "secret-device-code".into(),
user_code: "ABCD-1234".into(),
verification_uri: "https://github.com/login/device".into(),
verification_uri_complete: None,
expires_in: 900,
interval: 5,
};
let mut output = Vec::new();
super::write_device_code(&mut output, &device, true).unwrap();
let output = String::from_utf8(output).unwrap();
assert_eq!(output.lines().count(), 1);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&output).unwrap(),
json!({
"verificationURI": "https://github.com/login/device", "userCode": "ABCD-1234",
"expiresIn": 900, "status": "authorization_pending"
})
);
assert!(!output.contains("secret-device-code"));
}

#[test]
fn login_persists_and_activates_account_without_losing_other_accounts() {
let dir = tempfile::tempdir().unwrap();
let store = crate::core::TokenAccountStore::with_path(dir.path().join("accounts.json"));
let mut other = crate::core::ProviderAccountData::new();
other.add_account(crate::core::TokenAccount::new("Other", "other-token"));
store
.save_provider(crate::core::ProviderId::OpenRouter, &other)
.unwrap();
let identity = || {
Ok(crate::providers::copilot::GitHubIdentity {
login: "test-user".into(),
id: Some(42),
name: None,
})
};
super::store_copilot_token(&store, "first-token".into(), identity()).unwrap();
super::store_copilot_token(&store, "second-token".into(), identity()).unwrap();
let data = store
.load_provider(crate::core::ProviderId::Copilot)
.unwrap();
assert_eq!(data.accounts.len(), 1);
assert_eq!(data.active_account().unwrap().token, "second-token");
assert_eq!(
data.active_account()
.unwrap()
.external_identifier
.as_deref(),
Some("github:user:42")
);
assert_eq!(
store
.load_provider(crate::core::ProviderId::OpenRouter)
.unwrap()
.active_account()
.unwrap()
.token,
"other-token"
);
}

#[cfg(not(windows))]
#[tokio::test]
async fn copilot_login_rejects_unprotected_platform_before_network_access() {
let error = super::login("copilot", true).await.unwrap_err();
assert!(
error
.to_string()
.contains("requires Windows protected token storage")
);
}

#[test]
fn failed_or_incomplete_identity_does_not_store_token() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("accounts.json");
let store = crate::core::TokenAccountStore::with_path(path.clone());
let failed = Err(crate::core::ProviderError::AuthRequired);
let error = super::store_copilot_token(&store, "token".into(), failed).unwrap_err();
assert!(
error
.to_string()
.contains("Could not retrieve your GitHub identity")
);
let missing_id = Ok(crate::providers::copilot::GitHubIdentity {
login: "test-user".into(),
id: None,
name: None,
});
let error = super::store_copilot_token(&store, "token".into(), missing_id).unwrap_err();
assert!(error.to_string().contains("did not provide an account ID"));
assert!(!path.exists());
}

#[tokio::test]
async fn login_rejects_other_providers_before_network_access() {
assert!(
super::login("claude", true)
.await
.unwrap_err()
.to_string()
.contains("only supported for copilot")
);
assert!(
super::login("not-a-provider", true)
.await
.unwrap_err()
.to_string()
.contains("Unknown provider")
);
}

#[cfg(windows)]
#[test]
fn validates_dpapi_protected_manual_cookies_without_plaintext() {
Expand Down
64 changes: 63 additions & 1 deletion rust/src/cli/serve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ pub struct ServeArgs {
/// request (upstream 0.50.1 #2960).
#[arg(long, value_parser = ["redacted", "full"])]
pub identity: Option<String>,

/// Total per-request deadline in seconds; 0 disables (default: 0)
///
/// CodexBar's own `serve` option. A request still unanswered after this
/// long gets `504 Gateway Timeout`; its work runs on to the end. Unlike
/// CodexBar, whose default is 30, the default here stays "no deadline",
/// which is how this server behaved before the option existed.
#[arg(
long = "request-timeout",
value_name = "SECONDS",
default_value_t = 0.0
)]
pub request_timeout: f64,
}

/// Normalized serve bind configuration after startup validation.
Expand All @@ -102,6 +115,9 @@ struct ServeConfig {
/// Dashboard snapshot identity mode. `None` means follow the app's
/// `hide_personal_info` setting per request (upstream 0.50.1 #2960).
identity: Option<DashboardIdentity>,
/// Deadline for answering one request (`--request-timeout`); `None`
/// waits for the answer however long it takes.
request_timeout: Option<Duration>,
/// Dashboard state (coordinator + producer). Always `Some` from `run`;
/// `None` only in pure-transport tests, where dashboard routes answer 503.
dashboard: Option<dashboard::DashboardState>,
Expand Down Expand Up @@ -198,10 +214,29 @@ fn validate_serve_args(args: &ServeArgs) -> anyhow::Result<ServeConfig> {
token_digest: token.as_ref().map(|t| sha256_digest(t.as_bytes())),
head_read_budget: HEAD_READ_TIMEOUT,
identity,
request_timeout: request_timeout(args.request_timeout)?,
dashboard: None,
})
}

/// Upstream's longest request deadline: one day.
const MAX_REQUEST_TIMEOUT_SECS: f64 = 86_400.0;

/// `--request-timeout` as CodexBar reads it: seconds, fractions allowed,
/// capped at a day; zero means no deadline; negative or non-finite is an
/// error.
fn request_timeout(seconds: f64) -> anyhow::Result<Option<Duration>> {
if !seconds.is_finite() || seconds < 0.0 {
anyhow::bail!("--request-timeout must be zero or greater.");
}
if seconds == 0.0 {
return Ok(None);
}
Ok(Some(Duration::from_secs_f64(
seconds.min(MAX_REQUEST_TIMEOUT_SECS),
)))
}

fn resolve_dashboard_token(cli_token: Option<&str>) -> anyhow::Result<Option<String>> {
// Prefer env (already merged by clap env=) but still reject empty/whitespace.
if let Some(raw) = cli_token {
Expand Down Expand Up @@ -326,14 +361,40 @@ async fn handle_client(mut stream: TcpStream, config: &ServeConfig) -> anyhow::R
};
let request = String::from_utf8_lossy(&head);
let response = match parse_request(&request) {
Ok(request) => route_request(&request, config).await,
Ok(request) => match config.request_timeout {
None => route_request(&request, config).await,
Some(deadline) => {
let config = config.clone();
answer_within(
deadline,
async move { route_request(&request, &config).await },
)
.await
}
},
Err(status) => json_response(status, serde_json::json!({ "error": "bad request" })),
};
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await?;
Ok(())
}

/// Answer of `work`, or `504 Gateway Timeout` once `deadline` has passed.
///
/// The work runs as its own task and is not stopped at the deadline: a slow
/// dashboard snapshot still finishes and lands in the coordinator's cache for
/// the next request, as upstream's `--request-timeout` describes.
async fn answer_within<F>(deadline: Duration, work: F) -> String
where
F: std::future::Future<Output = String> + Send + 'static,
{
match tokio::time::timeout(deadline, tokio::spawn(work)).await {
Ok(Ok(response)) => response,
Ok(Err(_)) => json_response(500, serde_json::json!({ "error": "internal error" })),
Err(_) => json_response(504, serde_json::json!({ "error": "request timed out" })),
}
}

/// Read one complete request head under one overall deadline.
///
/// Upstream 0.48.0 #2684 (`CLILocalHTTPServer.readRequest`): the deadline is a
Expand Down Expand Up @@ -693,6 +754,7 @@ fn http_response(
405 => "Method Not Allowed",
500 => "Internal Server Error",
503 => "Service Unavailable",
504 => "Gateway Timeout",
_ => "Internal Server Error",
};
let extra = extra_headers
Expand Down
Loading