diff --git a/README.md b/README.md index 8fd28012..d5c399ea 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,9 @@ Project contracts are managed outside checkouts under - `WORKFLOW.md` for execution policy The redacted template for a project config lives at `decodex.example.toml`. +When a project enables `[codex.accounts]`, the shared ChatGPT account pool is +`~/.codex/decodex/accounts.jsonl`; it is global Decodex state, not a project-local +file, and project configs do not own an account-pool path override. `decodex diagnose --json` writes the local agent evidence index under `~/.codex/decodex/agent-evidence//` and prints the same handoff index for diff --git a/apps/decodex/src/agent/codex_accounts.rs b/apps/decodex/src/agent/codex_accounts.rs index d2628f25..05b95b7c 100644 --- a/apps/decodex/src/agent/codex_accounts.rs +++ b/apps/decodex/src/agent/codex_accounts.rs @@ -15,7 +15,7 @@ use serde_json::Value; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ - config::ProjectCodexAccountsConfig, prelude::eyre, state::CodexAccountActivitySummary, + config::ProjectCodexAccountsConfig, prelude::eyre, runtime, state::CodexAccountActivitySummary, }; const DEFAULT_USAGE_ENDPOINT: &str = "https://chatgpt.com/backend-api/wham/usage"; @@ -23,6 +23,7 @@ const DEFAULT_REFRESH_ENDPOINT: &str = "https://auth.openai.com/oauth/token"; const CODEX_USER_AGENT: &str = "codex-cli"; const CHATGPT_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const HTTP_TIMEOUT: Duration = Duration::from_secs(10); +const TOKEN_REFRESH_INTERVAL_SECONDS: i64 = 8 * 24 * 60 * 60; pub(crate) trait CodexAccountProvider { fn select_account(&self) -> crate::prelude::Result; @@ -42,7 +43,7 @@ pub(crate) struct CodexAccountPool { impl CodexAccountPool { pub(crate) fn from_config(config: &ProjectCodexAccountsConfig) -> crate::prelude::Result { Self::new( - config.path(), + runtime::accounts_path()?, config.usage_endpoint().unwrap_or(DEFAULT_USAGE_ENDPOINT), config.refresh_endpoint().unwrap_or(DEFAULT_REFRESH_ENDPOINT), ) @@ -137,8 +138,36 @@ impl CodexAccountPool { continue; } + let refresh_status = match self.proactive_refresh_record(record, now) { + Ok(status) => { + if status == RefreshStatus::Succeeded { + records_changed = true; + } + + status.as_str() + }, + Err(error) if error.requires_skip => { + skipped.push(format!( + "{} proactive refresh failed: {}", + record.display_name(), + error.source + )); + + continue; + }, + Err(error) => { + skipped.push(format!( + "{} proactive refresh failed; probing existing token: {}", + record.display_name(), + error.source + )); + + RefreshStatus::Failed.as_str() + }, + }; + match self.probe_record_usage(record) { - Ok(usage) => candidates.push(record.login_from_usage(usage, "not_needed")?), + Ok(usage) => candidates.push(record.login_from_usage(usage, refresh_status)?), Err(error) if error.unauthorized && record.refresh_token().is_some() => { self.refresh_record(record)?; @@ -176,9 +205,20 @@ impl CodexAccountPool { selected.mark_selected(now); + if let Some(record) = + records.iter_mut().find(|record| record.account_id() == Some(selected.account_id())) + { + record.last_selected_at_unix_epoch = Some(now); + records_changed = true; + } + let account_summaries = account_summaries(&selected, &candidates); let selected = selected.with_account_summaries(account_summaries); + if records_changed { + self.save_records(records)?; + } + self.remember_selected_account(&selected.account_id)?; Ok(selected) @@ -203,9 +243,29 @@ impl CodexAccountPool { continue; } + let refresh_status = match self.proactive_refresh_record(record, now) { + Ok(status) => { + if status == RefreshStatus::Succeeded { + records_changed = true; + } + + status.as_str() + }, + Err(error) if error.requires_skip => { + summaries.push(record.probe_failed_activity_summary( + now, + "failed", + &error.source, + )); + + continue; + }, + Err(_error) => "failed", + }; + match self.probe_record_usage(record) { Ok(usage) => { - summaries.push(record.activity_summary_from_usage(usage, "not_needed")?); + summaries.push(record.activity_summary_from_usage(usage, refresh_status)?); }, Err(error) if error.unauthorized && record.refresh_token().is_some() => { match self.refresh_record(record) { @@ -253,6 +313,30 @@ impl CodexAccountPool { Ok(summaries) } + fn proactive_refresh_record( + &self, + record: &mut AccountPoolRecord, + now_unix_epoch: i64, + ) -> std::result::Result { + let Some(reason) = record.proactive_refresh_reason(now_unix_epoch) else { + return Ok(RefreshStatus::NotNeeded); + }; + + if record.refresh_token().is_none() { + return Err(ProactiveRefreshError { + source: ReportableRefreshError::new(format!("missing refresh token for {reason}")), + requires_skip: reason.requires_valid_token(), + }); + } + + self.refresh_record(record).map(|()| RefreshStatus::Succeeded).map_err(|error| { + ProactiveRefreshError { + source: ReportableRefreshError::new(error.to_string()), + requires_skip: reason.requires_valid_token(), + } + }) + } + fn refresh_from_records( &self, records: &mut [AccountPoolRecord], @@ -276,6 +360,8 @@ impl CodexAccountPool { selected.mark_selected(now); + records[record_index].last_selected_at_unix_epoch = Some(now); + let selected_summary = selected.summary().clone(); let selected = selected.with_account_summaries(vec![selected_summary]); @@ -416,6 +502,7 @@ pub(crate) struct CodexAccountLogin { access_token: String, account_id: String, plan_type: Option, + last_selected_at_unix_epoch: Option, summary: CodexAccountActivitySummary, account_summaries: Vec, } @@ -470,6 +557,8 @@ enum AccountPoolLine { cooldown_until_unix_epoch: Option, #[serde(skip_serializing_if = "Option::is_none")] cooldown_until: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_selected_at_unix_epoch: Option, auth: AuthDotJson, }, Flat(AccountPoolRecord), @@ -478,17 +567,24 @@ impl AccountPoolLine { fn into_record(self) -> AccountPoolRecord { match self { Self::Flat(record) => record, - Self::Wrapped { email, disabled, cooldown_until_unix_epoch, cooldown_until, auth } => - AccountPoolRecord { - email: first_nonblank_string(email, auth.email), - disabled, - cooldown_until_unix_epoch, - cooldown_until, - auth_mode: auth.auth_mode, - openai_api_key: auth.openai_api_key, - tokens: auth.tokens, - last_refresh: auth.last_refresh, - }, + Self::Wrapped { + email, + disabled, + cooldown_until_unix_epoch, + cooldown_until, + last_selected_at_unix_epoch, + auth, + } => AccountPoolRecord { + email: first_nonblank_string(email, auth.email), + disabled, + cooldown_until_unix_epoch, + cooldown_until, + last_selected_at_unix_epoch, + auth_mode: auth.auth_mode, + openai_api_key: auth.openai_api_key, + tokens: auth.tokens, + last_refresh: auth.last_refresh, + }, } } } @@ -518,6 +614,8 @@ struct AccountPoolRecord { #[serde(skip_serializing_if = "Option::is_none")] cooldown_until: Option, #[serde(skip_serializing_if = "Option::is_none")] + last_selected_at_unix_epoch: Option, + #[serde(skip_serializing_if = "Option::is_none")] auth_mode: Option, #[serde(rename = "OPENAI_API_KEY", skip_serializing_if = "Option::is_none")] openai_api_key: Option, @@ -657,6 +755,7 @@ impl AccountPoolRecord { access_token, account_id, plan_type: summary.plan_type.clone(), + last_selected_at_unix_epoch: self.last_selected_at_unix_epoch, summary, account_summaries: Vec::new(), }) @@ -688,6 +787,20 @@ impl AccountPoolRecord { summary } + + fn proactive_refresh_reason(&self, now_unix_epoch: i64) -> Option { + let tokens = self.tokens.as_ref()?; + + if let Some(expires_at) = jwt_expiration_unix_epoch(&tokens.access_token) { + return (expires_at <= now_unix_epoch) + .then_some(ProactiveRefreshReason::AccessTokenExpired); + } + + let last_refresh = self.last_refresh.as_deref().and_then(rfc3339_unix_epoch)?; + + (last_refresh < now_unix_epoch.saturating_sub(TOKEN_REFRESH_INTERVAL_SECONDS)) + .then_some(ProactiveRefreshReason::LastRefreshStale) + } } #[derive(Clone, Deserialize, Serialize)] @@ -716,6 +829,63 @@ struct RefreshResponse { refresh_token: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RefreshStatus { + NotNeeded, + Succeeded, + Failed, +} +impl RefreshStatus { + const fn as_str(self) -> &'static str { + match self { + Self::NotNeeded => "not_needed", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProactiveRefreshReason { + AccessTokenExpired, + LastRefreshStale, +} +impl ProactiveRefreshReason { + const fn requires_valid_token(self) -> bool { + matches!(self, Self::AccessTokenExpired) + } +} +impl Display for ProactiveRefreshReason { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::AccessTokenExpired => formatter.write_str("expired access token"), + Self::LastRefreshStale => formatter.write_str("stale refresh timestamp"), + } + } +} + +#[derive(Debug)] +struct ProactiveRefreshError { + source: ReportableRefreshError, + requires_skip: bool, +} + +#[derive(Debug)] +struct ReportableRefreshError { + message: String, +} +impl ReportableRefreshError { + fn new(message: String) -> Self { + Self { message } + } +} +impl Display for ReportableRefreshError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} +impl Error for ReportableRefreshError {} + #[derive(Clone, Debug, Default, Eq, PartialEq)] struct AccountUsageSnapshot { plan_type: Option, @@ -889,6 +1059,18 @@ fn jwt_email_claim(id_token: Option<&str>) -> Option { claims.get("email").and_then(json_scalar_to_string) } +fn jwt_expiration_unix_epoch(jwt: &str) -> Option { + let payload = jwt.split('.').nth(1)?; + let payload_bytes = parse_base64_url(payload)?; + let claims = serde_json::from_slice::(&payload_bytes).ok()?; + + claims.get("exp").and_then(number_as_i64) +} + +fn rfc3339_unix_epoch(input: &str) -> Option { + OffsetDateTime::parse(input, &Rfc3339).ok().map(|timestamp| timestamp.unix_timestamp()) +} + fn parse_base64_url(input: &str) -> Option> { let mut output = Vec::with_capacity(input.len() * 3 / 4); let mut accumulator = 0_u32; @@ -922,6 +1104,11 @@ const fn base64_url_value(byte: u8) -> Option { fn compare_account_candidates(left: &CodexAccountLogin, right: &CodexAccountLogin) -> Ordering { account_candidate_score(right) .cmp(&account_candidate_score(left)) + .then_with(|| { + left.last_selected_at_unix_epoch + .unwrap_or(0) + .cmp(&right.last_selected_at_unix_epoch.unwrap_or(0)) + }) .then_with(|| left.summary.account_fingerprint.cmp(&right.summary.account_fingerprint)) } @@ -972,7 +1159,7 @@ const fn is_false(value: &bool) -> bool { mod tests { use crate::agent::codex_accounts::{ self, AccountPoolRecord, CodexAccountActivitySummary, CodexAccountLogin, CodexTokenData, - CreditsSnapshot, Path, UsageWindow, compare_account_candidates, + CreditsSnapshot, Path, ProactiveRefreshReason, UsageWindow, compare_account_candidates, }; #[test] @@ -1085,6 +1272,7 @@ mod tests { disabled: false, cooldown_until_unix_epoch: None, cooldown_until: None, + last_selected_at_unix_epoch: None, auth_mode: Some(String::from("chatgpt")), openai_api_key: None, tokens: Some(CodexTokenData { @@ -1137,6 +1325,40 @@ mod tests { assert!(!available_summary.is_limited()); } + #[test] + fn proactive_refresh_prefers_access_token_expiration_then_last_refresh() { + let mut record = AccountPoolRecord { + email: Some(String::from("refresh@example.com")), + disabled: false, + cooldown_until_unix_epoch: None, + cooldown_until: None, + last_selected_at_unix_epoch: None, + auth_mode: Some(String::from("chatgpt")), + openai_api_key: None, + tokens: Some(CodexTokenData { + email: None, + id_token: None, + access_token: String::from("x.eyJleHAiOjEwMDB9.y"), + refresh_token: String::from("refresh"), + account_id: Some(String::from("acct_refresh")), + }), + last_refresh: Some(String::from("2099-01-01T00:00:00Z")), + }; + + assert_eq!( + record.proactive_refresh_reason(1_001), + Some(ProactiveRefreshReason::AccessTokenExpired) + ); + + record.tokens.as_mut().expect("tokens should exist").access_token = String::from("opaque"); + record.last_refresh = Some(String::from("2026-01-01T00:00:00Z")); + + assert_eq!( + record.proactive_refresh_reason(1_768_000_000), + Some(ProactiveRefreshReason::LastRefreshStale) + ); + } + #[test] fn account_candidate_sort_prefers_remaining_usage() { let mut candidates = [ @@ -1144,6 +1366,7 @@ mod tests { access_token: String::from("a"), account_id: String::from("acct_a"), plan_type: Some(String::from("pro")), + last_selected_at_unix_epoch: None, summary: CodexAccountActivitySummary { account_fingerprint: String::from("...acct_a"), primary_remaining_percent: Some(10), @@ -1156,6 +1379,7 @@ mod tests { access_token: String::from("b"), account_id: String::from("acct_b"), plan_type: Some(String::from("pro")), + last_selected_at_unix_epoch: None, summary: CodexAccountActivitySummary { account_fingerprint: String::from("...acct_b"), primary_remaining_percent: Some(70), @@ -1178,6 +1402,7 @@ mod tests { access_token: String::from("a"), account_id: String::from("acct_a"), plan_type: Some(String::from("pro")), + last_selected_at_unix_epoch: None, summary: CodexAccountActivitySummary { account_fingerprint: String::from("...acct_a"), primary_remaining_percent: Some(86), @@ -1192,6 +1417,7 @@ mod tests { access_token: String::from("b"), account_id: String::from("acct_b"), plan_type: Some(String::from("pro")), + last_selected_at_unix_epoch: None, summary: CodexAccountActivitySummary { account_fingerprint: String::from("...acct_b"), primary_remaining_percent: Some(100), @@ -1208,4 +1434,40 @@ mod tests { assert_eq!(candidates[0].account_id(), "acct_b"); } + + #[test] + fn account_candidate_sort_balances_tied_usage_by_last_selection() { + let mut candidates = [ + CodexAccountLogin { + access_token: String::from("a"), + account_id: String::from("acct_a"), + plan_type: Some(String::from("pro")), + last_selected_at_unix_epoch: Some(20), + summary: CodexAccountActivitySummary { + account_fingerprint: String::from("...acct_a"), + primary_remaining_percent: Some(70), + secondary_remaining_percent: Some(40), + ..CodexAccountActivitySummary::default() + }, + account_summaries: Vec::new(), + }, + CodexAccountLogin { + access_token: String::from("b"), + account_id: String::from("acct_b"), + plan_type: Some(String::from("pro")), + last_selected_at_unix_epoch: Some(10), + summary: CodexAccountActivitySummary { + account_fingerprint: String::from("...acct_b"), + primary_remaining_percent: Some(70), + secondary_remaining_percent: Some(40), + ..CodexAccountActivitySummary::default() + }, + account_summaries: Vec::new(), + }, + ]; + + candidates.sort_by(compare_account_candidates); + + assert_eq!(candidates[0].account_id(), "acct_b"); + } } diff --git a/apps/decodex/src/config.rs b/apps/decodex/src/config.rs index dfe0aeca..c0f0b6f6 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -180,9 +180,11 @@ impl ProjectCodexConfig { self.accounts.as_ref() } - fn resolve_paths(mut self, config_dir: &Path) -> Result { + fn resolve_paths(mut self, _config_dir: &Path) -> Result { if let Some(accounts) = self.accounts.take() { - self.accounts = Some(accounts.resolve_paths(config_dir)?); + accounts.validate()?; + + self.accounts = Some(accounts); } Ok(self) @@ -211,16 +213,10 @@ impl Default for ProjectCodexConfig { #[derive(Clone, Debug, Eq, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProjectCodexAccountsConfig { - path: PathBuf, usage_endpoint: Option, refresh_endpoint: Option, } impl ProjectCodexAccountsConfig { - /// JSONL file containing one composite auth.json-style account per line. - pub fn path(&self) -> &Path { - &self.path - } - /// Override for ChatGPT usage probes. Defaults to the Codex `/wham/usage` endpoint. pub fn usage_endpoint(&self) -> Option<&str> { self.usage_endpoint.as_deref() @@ -232,7 +228,6 @@ impl ProjectCodexAccountsConfig { } fn validate(&self) -> Result<()> { - validate_nonempty_path("codex.accounts.path", &self.path)?; validate_optional_nonempty_string( "codex.accounts.usage_endpoint", self.usage_endpoint.as_deref(), @@ -244,12 +239,6 @@ impl ProjectCodexAccountsConfig { Ok(()) } - - fn resolve_paths(mut self, config_dir: &Path) -> Result { - self.path = resolve_config_path(config_dir, &self.path)?; - - Ok(self) - } } /// Optional service-level path overrides. @@ -700,32 +689,6 @@ fn resolve_relative_path(base: &Path, path: &Path) -> PathBuf { normalize_path(&resolved) } -fn resolve_config_path(base: &Path, path: &Path) -> Result { - let expanded = expand_home_path(path)?; - - Ok(resolve_relative_path(base, &expanded)) -} - -fn expand_home_path(path: &Path) -> Result { - let Some(path_text) = path.to_str() else { - return Ok(path.to_path_buf()); - }; - let Some(rest) = path_text.strip_prefix("~/") else { - if path_text == "~" { - return env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| eyre::eyre!("`HOME` is required to expand `~`.")); - } - - return Ok(path.to_path_buf()); - }; - let Some(home) = env::var_os("HOME") else { - eyre::bail!("`HOME` is required to expand `{path_text}`."); - }; - - Ok(PathBuf::from(home).join(rest)) -} - fn normalize_path(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); @@ -1118,30 +1081,47 @@ mod tests { service_id = "pubfi" [tracker] - api_key_env_var = "HOME" + api_key_env_var = "HOME" - [github] - token_env_var = "HOME" + [github] + token_env_var = "HOME" - [codex.accounts] - path = "accounts/codex-auth.jsonl" - usage_endpoint = "http://127.0.0.1:1234/wham/usage" - refresh_endpoint = "http://127.0.0.1:1234/oauth/token" - "#, + [codex.accounts] + usage_endpoint = "http://127.0.0.1:1234/wham/usage" + refresh_endpoint = "http://127.0.0.1:1234/oauth/token" + "#, ); let config = ServiceConfig::from_path(&config_path).expect("accounts should parse"); let accounts = config.codex().accounts().expect("accounts should be configured"); - let expected_path = temp_dir - .path() - .canonicalize() - .expect("temp dir should canonicalize") - .join("accounts/codex-auth.jsonl"); - assert_eq!(accounts.path(), expected_path); assert_eq!(accounts.usage_endpoint(), Some("http://127.0.0.1:1234/wham/usage")); assert_eq!(accounts.refresh_endpoint(), Some("http://127.0.0.1:1234/oauth/token")); } + #[test] + fn rejects_legacy_codex_accounts_path_override() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let config_path = write_config_file( + temp_dir.path(), + r#" + service_id = "pubfi" + + [tracker] + api_key_env_var = "HOME" + + [github] + token_env_var = "HOME" + + [codex.accounts] + path = "accounts/codex-auth.jsonl" + "#, + ); + let error = ServiceConfig::from_path(&config_path) + .expect_err("legacy account path override should fail"); + + assert!(error.to_string().contains("path")); + } + #[test] fn rejects_unknown_codex_internal_review_mode() { let temp_dir = TempDir::new().expect("temp dir should exist"); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs index 060847db..7b80c49d 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs @@ -254,13 +254,17 @@ fn idle_operator_status_snapshot_has_no_runtime_or_recovery_noise() { #[test] fn idle_operator_status_snapshot_includes_configured_codex_accounts() { - let (_temp_dir, base_config, _workflow) = temp_project_layout(); - let accounts_path = service_config_dir(base_config.repo_root()).join("codex-auth.jsonl"); + let (temp_dir, base_config, _workflow) = temp_project_layout(); + let _home_guard = + TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("home should be utf-8")); + let accounts_path = temp_dir.path().join(".codex/decodex/accounts.jsonl"); let usage_endpoint = start_codex_usage_fixture_server(vec![ r#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":7,"limit_window_seconds":18000,"reset_at":1800018000},"secondary_window":{"used_percent":11,"limit_window_seconds":604800,"reset_at":1800604800}},"credits":{"has_credits":true,"unlimited":false,"balance":"12.34"}}"#, r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":22,"limit_window_seconds":18000,"reset_at":1800019000},"secondary_window":{"used_percent":33,"limit_window_seconds":604800,"reset_at":1800605800}},"credits":{"has_credits":false,"unlimited":false,"balance":"0"}}"#, ]); + std::fs::create_dir_all(accounts_path.parent().expect("accounts path should have parent")) + .expect("accounts dir should exist"); std::fs::write( &accounts_path, r#"{"email":"default@example.com","auth_mode":"chatgpt","tokens":{"access_token":"access-default","refresh_token":"refresh-default","account_id":"acct_default"}} @@ -277,8 +281,7 @@ fn idle_operator_status_snapshot_includes_configured_codex_accounts() { ); config_toml.push_str(&format!( - "\n[codex.accounts]\npath = \"{}\"\nusage_endpoint = \"{}\"\n", - accounts_path.display(), + "\n[codex.accounts]\nusage_endpoint = \"{}\"\n", usage_endpoint )); diff --git a/apps/decodex/src/runtime.rs b/apps/decodex/src/runtime.rs index d2c3ea8d..ca56ac62 100644 --- a/apps/decodex/src/runtime.rs +++ b/apps/decodex/src/runtime.rs @@ -26,6 +26,11 @@ pub(crate) fn global_config_path() -> Result { Ok(decodex_home_dir()?.join("config.toml")) } +/// Resolve the global ChatGPT account-pool JSONL path. +pub(crate) fn accounts_path() -> Result { + Ok(decodex_home_dir()?.join("accounts.jsonl")) +} + /// Resolve the directory that stores project contract directories managed outside repos. pub(crate) fn project_config_dir() -> Result { Ok(decodex_home_dir()?.join("projects")) @@ -162,6 +167,17 @@ mod tests { ); } + #[test] + fn account_pool_path_lives_under_decodex_home() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let _home_guard = set_test_home(temp_dir.path()); + + assert_eq!( + runtime::accounts_path().expect("accounts path should resolve"), + temp_dir.path().join(".codex/decodex/accounts.jsonl") + ); + } + #[test] fn agent_evidence_path_lives_under_decodex_home() { let temp_dir = TempDir::new().expect("temp dir should create"); diff --git a/decodex.example.toml b/decodex.example.toml index 87f6a9c6..ba1b5c4b 100644 --- a/decodex.example.toml +++ b/decodex.example.toml @@ -12,6 +12,10 @@ token_env_var = "GITHUB_TOKEN" external_review_enabled = true internal_review_mode = "loop" +# Optional shared Codex account pool. +# Uncomment the block to use the global `~/.codex/decodex/accounts.jsonl` pool. +# [codex.accounts] + # Required project paths. Project configs are managed outside checkouts, for example under # `~/.codex/decodex/projects//project.toml`. # Omit `worktree_root` to use `/.worktrees`. diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 91d425d2..dcec569c 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -112,7 +112,7 @@ durable outside the local operator surface. | Section | Meaning | | --- | --- | -| `Accounts` | Codex account pool and usage table. Account identity can be obscured from the `Account` column header eye without changing the underlying snapshot. | +| `Accounts` | Shared Codex account pool and usage table from `~/.codex/decodex/accounts.jsonl` when `[codex.accounts]` is enabled for a project. Account identity can be obscured from the `Account` column header eye without changing the underlying snapshot. | | `Projects` | Fleet-level project table. The section-level filter toggles between active project work and the full registry. Location is its own compact path column and can be obscured from the location header eye. `Activity` shows a relative timestamp or `-`; `Work` is `running/waiting/attention`. It should not duplicate per-lane details already shown below. | | `Running Lanes` | Active leased or live-executing issue lanes. A lane here is currently owned by this local control plane, or a live process/thread/protocol marker still explains active execution even when the queue lease is not held. It shows issue identity, phase, operation, attempt, queue lease state, execution liveness, thread/protocol status, child-agent activity when captured, timing, branch, and worktree. | | `Intake Queue` | Queued tracker issues before execution. Candidates are classified as `ready`, capacity-waiting, claimed without a matching local lane, blocked, or closed/stale. A blocked queued candidate can still show an attached `.worktrees/XY-*` path when the queue owns the attention state; if that worktree has tracked changes after retries, the candidate is partial retained progress and not just a generic retry-budget hold. Running lanes are not repeated as normal intake work. | diff --git a/docs/reference/workspace-layout.md b/docs/reference/workspace-layout.md index 174e2b9b..2c466020 100644 --- a/docs/reference/workspace-layout.md +++ b/docs/reference/workspace-layout.md @@ -98,6 +98,8 @@ Runtime state that belongs to the local operator, not to this repository, lives - `runtime.sqlite3` is the single-machine control-plane database for all registered projects. +- `accounts.jsonl` stores the optional shared ChatGPT account pool used for + Codex app-server auth token injection and refresh. - `logs/` stores Decodex process logs. - `agent-evidence//` stores local agent-readable diagnosis artifacts, including `handoff-index.json`, `events.jsonl`, `blockers/*.json`, and diff --git a/docs/runbook/self-dogfood-pilot.md b/docs/runbook/self-dogfood-pilot.md index ab6ab83c..de37e078 100644 --- a/docs/runbook/self-dogfood-pilot.md +++ b/docs/runbook/self-dogfood-pilot.md @@ -39,6 +39,7 @@ For the recommended first deployment, collect each project contract under `~/.co ```text ~/.codex/decodex/ config.toml + accounts.jsonl runtime.sqlite3 logs/ projects/ @@ -69,6 +70,11 @@ decodex project list `.codex` history, repo-local config files, or currently open worktrees to infer projects. +If the project uses managed ChatGPT accounts, enable `[codex.accounts]` in +`project.toml` and keep the JSONL pool at `~/.codex/decodex/accounts.jsonl`. Do not +store the shared pool under a project directory or configure a project-local account +path. + After restarting `decodex serve`, verify the registry still points at the centralized project directory: diff --git a/docs/spec/app-server.md b/docs/spec/app-server.md index 46481cb9..bd872699 100644 --- a/docs/spec/app-server.md +++ b/docs/spec/app-server.md @@ -87,11 +87,13 @@ unless an existing lifecycle event summarizes them. 3. Run the bounded capability preflight with `config/read`, `model/list`, `modelProvider/capabilities/read`, `skills/list`, `plugin/list`, and `mcpServerStatus/list`. -4. Send `thread/start`. -5. Send `turn/start`. -6. Consume notifications until that turn reaches a terminal outcome. -7. If the project-owned continuation policy allows another same-thread turn, send another `turn/start` on the same thread. -8. Persist the local run journal and classify the bounded run result. +4. When `[codex.accounts]` is enabled, select a shared ChatGPT account and send + `account/login/start` with `chatgptAuthTokens`. +5. Send `thread/start`. +6. Send `turn/start`. +7. Consume notifications until that turn reaches a terminal outcome. +8. If the project-owned continuation policy allows another same-thread turn, send another `turn/start` on the same thread. +9. Persist the local run journal and classify the bounded run result. The capability preflight is observational. It may inspect the effective app-server config, model inventory, provider capabilities, skill inventory, plugin inventory, @@ -111,6 +113,25 @@ The client-side dynamic bridge may expose narrow Decodex-owned tools that are lo If app-server sends an invalid or undeclared `item/tool/call`, `decodex` must respond with a failed `DynamicToolCallResponse`, record an operator-local `item/tool/call/failure` diagnostic with a normalized failure class and next action, and fail the run as an app-server dynamic-tool protocol failure. If a declared Decodex tool returns `success = false`, `decodex` records the same local diagnostic but leaves the turn alive so the model can correct arguments or backing state within the same run. +When `[codex.accounts]` is enabled, the account pool is a global Decodex file at +`~/.codex/decodex/accounts.jsonl`; project configs do not own an account-pool path +override. The pool accepts flat `auth.json`-style JSONL records or records wrapped as +`{ "auth": ... }`. +Before login, Decodex probes configured accounts through the ChatGPT usage endpoint, +skips disabled, cooling-down, and incomplete records, penalizes usage-limited records, +prefers the highest remaining usage, and uses the least-recently selected account to +break equal usage scores. Successful selection writes +`last_selected_at_unix_epoch` back to the JSONL file so later runs can rotate tied +accounts across process restarts. + +Decodex owns token freshness for injected `chatgptAuthTokens`. It proactively refreshes +an account before probing when the access-token JWT `exp` is expired. If no expiration +claim is available, it refreshes when `last_refresh` is more than eight days old. If +the app-server later sends `account/chatgptAuthTokens/refresh`, Decodex refreshes the +previous account id supplied by the request, updates the JSONL record with returned +tokens and `last_refresh`, records a redacted local protocol event, and responds with +fresh `chatgptAuthTokens`. + ## `initialize` Method: diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index aadf07ea..18b02dda 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -296,7 +296,7 @@ and idempotency fields are defined by ## Local operational state -The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. +The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, the optional shared Codex account pool lives at `~/.codex/decodex/accounts.jsonl`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. `decodex serve` loads enabled registered projects from the global runtime database. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects.