mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(chatgpt): preserve account receipts on the official own-grant route
Reconcile the official open-source integration with incoming account-label changes. Engine guidance and reasoning scope share the selected owned credential snapshot; terminal status/list/get use the same canonical endpoint rule and protected registration check. Imported Codex credentials and ambient tokens remain inactive for the public plan API. Preserve route-bound custom API keys and xAI diagnostics, account labels, and activation replacement receipts. Clarify returning login versus explicit new-account/workspace registration in English and Chinese. Keep raw URL credentials out of terminal diagnostics.
Validation on shared wave over e94014909:
- Focused Engine/TUI nextest: 362 passed, 0 failed, 14326 skipped.
- Normal CLI dependency compile initially failed on two test-only legacy helpers; restricted them to cfg(test), then library compilation passed.
- Focused CLI nextest: 17 run, 15 passed, 2 fixture failures (masked-key prefix and create-versus-replace setup). Corrected only those fixture assertions/setup; failed-case rerun: 2 passed, 483 skipped. All 17 selected cases validated; production source unchanged after the 15 passing cases.
- npm test && npm run check:web: exit 0. Web 65 files/653 tests passed; extension host 91 passed/1 skipped/0 failed; package and runtime SDK tests passed. Web lint/types/facts/docs/tokens and 840-page build passed. Package/SDK count was not retained for this run.
- Existing fresh audit: 0 vulnerabilities across727 dependencies; existing bincode informational maintenance warning. Dependency manifest/lock unchanged by rebase. AWS-LC JWT backend; Windows x64 upstream assembly fallback.
- Scoped rustfmt and git diff --check passed. macOS TUI test linker warns about oversized unwind section; tests succeeded.
Earlier config qualification: 139 passed/1 legacy assertion failed, then corrected case passed on focused rerun. No real ChatGPT OAuth login, live catalog request, inference/allowance spend, deploy, native app QA, commercial approval, or release. Platform/exhaustive CI remains separate.
Refs: SHA-6830
This commit is contained in:
1 parent
6a73315951
commit
c58a853f77
5 files changed
+519
-230
No files matched your search
+458
-192
@@ -1658,7 +1658,7 @@ enum AuthCommand {
|
||||
/// account is signed in.
|
||||
#[command(name = "xai-device")]
|
||||
XaiDevice,
|
||||
/// Sign in with ChatGPT for Codex subscription access; run again to switch accounts.
|
||||
/// Sign in with ChatGPT; use CODEWHALE_CHATGPT_NEW_ACCOUNT=1 to register another account.
|
||||
///
|
||||
/// Opens the ChatGPT sign-in page (PKCE loopback) and asks you to sign
|
||||
/// in, so you can choose a different account than the one the browser
|
||||
@@ -3241,12 +3241,12 @@ impl XaiAuthDiagnostics {
|
||||
/// redacted tail prevents the presentation layer from accidentally retaining a
|
||||
/// plaintext credential after it has derived the effective route.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct XaiRuntimeApiKey {
|
||||
struct RuntimeAuthApiKey {
|
||||
source: Option<RuntimeApiKeySource>,
|
||||
last4: Option<String>,
|
||||
}
|
||||
|
||||
impl XaiRuntimeApiKey {
|
||||
impl RuntimeAuthApiKey {
|
||||
fn source_name(&self) -> Option<&'static str> {
|
||||
match self.source {
|
||||
Some(RuntimeApiKeySource::Cli) => Some("cli"),
|
||||
@@ -3371,13 +3371,13 @@ fn xai_runtime_api_key(
|
||||
store: &ConfigStore,
|
||||
secrets: &Secrets,
|
||||
runtime_overrides: &CliRuntimeOverrides,
|
||||
) -> XaiRuntimeApiKey {
|
||||
) -> RuntimeAuthApiKey {
|
||||
let resolved = store.config.resolve_runtime_options_with_secrets(
|
||||
&runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
|
||||
secrets,
|
||||
);
|
||||
debug_assert_eq!(resolved.provider, ProviderKind::Xai);
|
||||
XaiRuntimeApiKey {
|
||||
RuntimeAuthApiKey {
|
||||
source: resolved.api_key_source,
|
||||
last4: resolved.api_key.as_deref().map(last4_label),
|
||||
}
|
||||
@@ -3401,7 +3401,7 @@ fn api_key_source_name(
|
||||
|
||||
fn xai_status_summary_source(
|
||||
diagnostics: &XaiAuthDiagnostics,
|
||||
api_key: Option<&XaiRuntimeApiKey>,
|
||||
api_key: Option<&RuntimeAuthApiKey>,
|
||||
) -> String {
|
||||
match diagnostics.route {
|
||||
XaiAuthDiagnosticRoute::OwnedOAuth => {
|
||||
@@ -3409,7 +3409,7 @@ fn xai_status_summary_source(
|
||||
}
|
||||
XaiAuthDiagnosticRoute::NeedsRepair => {
|
||||
let api_key = api_key
|
||||
.and_then(XaiRuntimeApiKey::source_name)
|
||||
.and_then(RuntimeAuthApiKey::source_name)
|
||||
.unwrap_or("no runtime-effective API key");
|
||||
format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
|
||||
}
|
||||
@@ -3417,7 +3417,7 @@ fn xai_status_summary_source(
|
||||
"external consent configured/unprobed".to_string()
|
||||
}
|
||||
XaiAuthDiagnosticRoute::ApiKey => api_key
|
||||
.and_then(XaiRuntimeApiKey::source_name)
|
||||
.and_then(RuntimeAuthApiKey::source_name)
|
||||
.unwrap_or("unset")
|
||||
.to_string(),
|
||||
}
|
||||
@@ -3425,7 +3425,7 @@ fn xai_status_summary_source(
|
||||
|
||||
fn xai_credential_route_label(
|
||||
diagnostics: &XaiAuthDiagnostics,
|
||||
api_key: Option<&XaiRuntimeApiKey>,
|
||||
api_key: Option<&RuntimeAuthApiKey>,
|
||||
) -> String {
|
||||
match diagnostics.route {
|
||||
XaiAuthDiagnosticRoute::OwnedOAuth => {
|
||||
@@ -3434,7 +3434,7 @@ fn xai_credential_route_label(
|
||||
}
|
||||
XaiAuthDiagnosticRoute::NeedsRepair => {
|
||||
let api_key = api_key
|
||||
.and_then(XaiRuntimeApiKey::source_with_last4)
|
||||
.and_then(RuntimeAuthApiKey::source_with_last4)
|
||||
.unwrap_or_else(|| "no runtime-effective API key".to_string());
|
||||
format!(
|
||||
"xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
|
||||
@@ -3444,13 +3444,13 @@ fn xai_credential_route_label(
|
||||
"external read-only consent configured/unprobed".to_string()
|
||||
}
|
||||
XaiAuthDiagnosticRoute::ApiKey => api_key
|
||||
.and_then(XaiRuntimeApiKey::source_with_last4)
|
||||
.and_then(RuntimeAuthApiKey::source_with_last4)
|
||||
.unwrap_or_else(|| "missing".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn xai_table_storage_status(
|
||||
api_key: Option<&XaiRuntimeApiKey>,
|
||||
api_key: Option<&RuntimeAuthApiKey>,
|
||||
source: RuntimeApiKeySource,
|
||||
) -> &'static str {
|
||||
match api_key {
|
||||
@@ -3463,7 +3463,7 @@ fn xai_table_storage_status(
|
||||
}
|
||||
|
||||
fn xai_list_storage_status(
|
||||
api_key: Option<&XaiRuntimeApiKey>,
|
||||
api_key: Option<&RuntimeAuthApiKey>,
|
||||
source: RuntimeApiKeySource,
|
||||
) -> &'static str {
|
||||
match api_key {
|
||||
@@ -3475,7 +3475,7 @@ fn xai_list_storage_status(
|
||||
|
||||
fn xai_list_route(
|
||||
diagnostics: &XaiAuthDiagnostics,
|
||||
api_key: Option<&XaiRuntimeApiKey>,
|
||||
api_key: Option<&RuntimeAuthApiKey>,
|
||||
) -> &'static str {
|
||||
match diagnostics.route {
|
||||
XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
|
||||
@@ -3493,7 +3493,7 @@ fn xai_list_route(
|
||||
|
||||
fn xai_storage_detail(
|
||||
diagnostics: &XaiAuthDiagnostics,
|
||||
api_key: Option<&XaiRuntimeApiKey>,
|
||||
api_key: Option<&RuntimeAuthApiKey>,
|
||||
source: RuntimeApiKeySource,
|
||||
) -> String {
|
||||
match api_key {
|
||||
@@ -3536,13 +3536,13 @@ fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
|
||||
fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&RuntimeAuthApiKey>) -> String {
|
||||
match diagnostics.route {
|
||||
XaiAuthDiagnosticRoute::OwnedOAuth => {
|
||||
"xai: configured (source: Codewhale-owned OAuth generation; valid pointer; token availability unprobed)".to_string()
|
||||
}
|
||||
XaiAuthDiagnosticRoute::NeedsRepair => {
|
||||
let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
|
||||
let api_key = match api_key.and_then(RuntimeAuthApiKey::source_name) {
|
||||
Some("config") => "config-file".to_string(),
|
||||
Some("secret store") => "secret-store".to_string(),
|
||||
Some("env") => "env".to_string(),
|
||||
@@ -3557,7 +3557,7 @@ fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApi
|
||||
XaiAuthDiagnosticRoute::ExternalConsent => {
|
||||
"xai: configured (source: external read-only consent; availability unprobed)".to_string()
|
||||
}
|
||||
XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
|
||||
XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(RuntimeAuthApiKey::source_name) {
|
||||
Some("config") => "xai: set (source: config-file)".to_string(),
|
||||
Some("secret store") => "xai: set (source: secret-store)".to_string(),
|
||||
Some("env") => "xai: set (source: env)".to_string(),
|
||||
@@ -3568,6 +3568,148 @@ fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApi
|
||||
}
|
||||
}
|
||||
|
||||
/// Describe the selected ChatGPT route without refreshing credentials or
|
||||
/// consulting ambient tokens, API-key storage, or external CLI files for the
|
||||
/// official plan endpoint. Custom routes use the dispatcher's bound-key resolver.
|
||||
struct ChatgptAuthDiagnostics {
|
||||
official_endpoint: bool,
|
||||
source: String,
|
||||
api_key: Option<RuntimeAuthApiKey>,
|
||||
}
|
||||
|
||||
fn chatgpt_auth_diagnostics(
|
||||
store: &ConfigStore,
|
||||
secrets: &Secrets,
|
||||
runtime_overrides: &CliRuntimeOverrides,
|
||||
) -> ChatgptAuthDiagnostics {
|
||||
let overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::OpenaiCodex);
|
||||
let mut route_overrides = overrides.clone();
|
||||
route_overrides.api_key = None;
|
||||
route_overrides.auth_mode = Some("none".to_string());
|
||||
let resolved = store.config.resolve_runtime_options(&route_overrides);
|
||||
let official_endpoint = codewhale_tui::is_official_chatgpt_api_base(&resolved.base_url);
|
||||
let api_key = (!official_endpoint).then(|| {
|
||||
let resolved = store
|
||||
.config
|
||||
.resolve_runtime_options_with_secrets(&overrides, secrets);
|
||||
RuntimeAuthApiKey {
|
||||
source: resolved.api_key_source,
|
||||
last4: resolved.api_key.as_deref().map(last4_label),
|
||||
}
|
||||
});
|
||||
let source = if official_endpoint {
|
||||
if store.config.providers.openai_codex.auth_mode.as_deref() != Some("oauth") {
|
||||
"missing (run `codewhale auth chatgpt` to register an official grant)".to_string()
|
||||
} else {
|
||||
match owned_oauth_account(store, ProviderKind::OpenaiCodex) {
|
||||
Ok(Some(account)) => format!(
|
||||
"Codewhale-owned ChatGPT sign-in as {account} (verified grant; no refresh or network)"
|
||||
),
|
||||
Ok(None) => {
|
||||
"Codewhale-owned ChatGPT sign-in (verified grant; no refresh or network)"
|
||||
.to_string()
|
||||
}
|
||||
Err(reason) => format!(
|
||||
"missing (Codewhale-owned ChatGPT sign-in is unusable: {reason}; run `codewhale auth chatgpt`)"
|
||||
),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
api_key
|
||||
.as_ref()
|
||||
.and_then(RuntimeAuthApiKey::source_with_last4)
|
||||
.unwrap_or_else(|| {
|
||||
"missing (custom endpoint requires an explicit or route-bound API key)".to_string()
|
||||
})
|
||||
};
|
||||
ChatgptAuthDiagnostics {
|
||||
official_endpoint,
|
||||
source,
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn chatgpt_auth_status_lines(
|
||||
store: &ConfigStore,
|
||||
secrets: &Secrets,
|
||||
runtime_overrides: &CliRuntimeOverrides,
|
||||
) -> Vec<String> {
|
||||
let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
|
||||
let marker = if store.config.provider == ProviderKind::OpenaiCodex {
|
||||
" (active provider)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let storage_detail = |source| {
|
||||
if diagnostics.official_endpoint {
|
||||
"inactive for official ChatGPT sign-in".to_string()
|
||||
} else if let Some(key) = diagnostics.api_key.as_ref().filter(|key| key.uses(source)) {
|
||||
key.last4
|
||||
.as_deref()
|
||||
.map(|tail| format!("runtime-effective, last4: {tail}"))
|
||||
.unwrap_or_else(|| "runtime-effective".to_string())
|
||||
} else {
|
||||
"not eligible or not selected for this custom endpoint".to_string()
|
||||
}
|
||||
};
|
||||
let mut lines = vec![
|
||||
format!("provider: openai-codex{marker}"),
|
||||
format!(
|
||||
"route: {}",
|
||||
if diagnostics.official_endpoint {
|
||||
"official ChatGPT plan API"
|
||||
} else {
|
||||
"custom API-key endpoint"
|
||||
}
|
||||
),
|
||||
format!(
|
||||
"model: {}",
|
||||
store
|
||||
.config
|
||||
.providers
|
||||
.openai_codex
|
||||
.model
|
||||
.as_deref()
|
||||
.unwrap_or("(default)")
|
||||
),
|
||||
format!(
|
||||
"auth mode: {}",
|
||||
if diagnostics.official_endpoint {
|
||||
"oauth"
|
||||
} else {
|
||||
"api_key"
|
||||
}
|
||||
),
|
||||
format!("active source: {}", diagnostics.source),
|
||||
if diagnostics.official_endpoint {
|
||||
"lookup order: verified Codewhale-owned ChatGPT sign-in only; ambient tokens and external CLI credentials are inactive".to_string()
|
||||
} else {
|
||||
"lookup order: endpoint-bound API key only (explicit CLI key or route-bound config key)"
|
||||
.to_string()
|
||||
},
|
||||
format!(
|
||||
"config file: {} ({})",
|
||||
codewhale_config::quote_os_path(store.path()),
|
||||
storage_detail(RuntimeApiKeySource::ConfigFile)
|
||||
),
|
||||
format!(
|
||||
"secret store: {} ({})",
|
||||
secrets.backend_name(),
|
||||
storage_detail(RuntimeApiKeySource::Keyring)
|
||||
),
|
||||
format!(
|
||||
"env var: {} ({})",
|
||||
provider_env_vars(ProviderKind::OpenaiCodex).join("/"),
|
||||
storage_detail(RuntimeApiKeySource::Env)
|
||||
),
|
||||
"external credentials: inactive for this route (no file was probed)".to_string(),
|
||||
];
|
||||
if diagnostics.official_endpoint {
|
||||
lines.push("switch account: `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt` (choose another account, then restart open Codewhale sessions); `/auth chatgpt` reauthorizes the selected account".to_string());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn auth_get_line_with_runtime(
|
||||
store: &ConfigStore,
|
||||
secrets: &Secrets,
|
||||
@@ -3583,6 +3725,19 @@ fn auth_get_line_with_runtime(
|
||||
return xai_get_line(&diagnostics, api_key.as_ref());
|
||||
}
|
||||
|
||||
if provider == ProviderKind::OpenaiCodex {
|
||||
let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
|
||||
return format!(
|
||||
"{slot}: {} (source: {})",
|
||||
if diagnostics.source.starts_with("missing") {
|
||||
"not set"
|
||||
} else {
|
||||
"configured"
|
||||
},
|
||||
diagnostics.source
|
||||
);
|
||||
}
|
||||
|
||||
let config_key = provider_config_api_key(store, provider);
|
||||
let keyring_key = config_key
|
||||
.is_none()
|
||||
@@ -3647,6 +3802,35 @@ fn auth_status_all_providers_with_runtime(
|
||||
continue;
|
||||
}
|
||||
|
||||
if provider == ProviderKind::OpenaiCodex {
|
||||
let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
|
||||
let status = |source| {
|
||||
if diagnostics
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_some_and(|key| key.uses(source))
|
||||
{
|
||||
"set"
|
||||
} else {
|
||||
"-"
|
||||
}
|
||||
};
|
||||
lines.push(format!(
|
||||
"{:<14} {:<8} {:<10} {:<8} {}{}",
|
||||
provider.as_str(),
|
||||
status(RuntimeApiKeySource::ConfigFile),
|
||||
status(RuntimeApiKeySource::Keyring),
|
||||
status(RuntimeApiKeySource::Env),
|
||||
diagnostics.source,
|
||||
if provider == active_provider {
|
||||
" *"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let config_key = provider_config_api_key(store, provider);
|
||||
let keyring_key = provider_keyring_api_key(secrets, provider);
|
||||
let env_key = provider_env_value(provider);
|
||||
@@ -3656,18 +3840,7 @@ fn auth_status_all_providers_with_runtime(
|
||||
let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
|
||||
let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
|
||||
|
||||
let source = if provider == ProviderKind::OpenaiCodex {
|
||||
// Keep the summary consistent with `auth status`: Codex auth is
|
||||
// OAuth-file (or env token) based — config/keyring keys are not
|
||||
// consulted for it.
|
||||
if env_key.is_some() {
|
||||
"env".to_string()
|
||||
} else if external_selected {
|
||||
"external consent (not probed)".to_string()
|
||||
} else {
|
||||
"unset".to_string()
|
||||
}
|
||||
} else if external_selected {
|
||||
let source = if external_selected {
|
||||
"external consent (not probed)".to_string()
|
||||
} else if config_key.is_some() {
|
||||
"config".to_string()
|
||||
@@ -3960,37 +4133,31 @@ fn auth_list_lines_with_runtime(
|
||||
continue;
|
||||
}
|
||||
|
||||
if provider == ProviderKind::OpenaiCodex {
|
||||
let diagnostics = chatgpt_auth_diagnostics(store, secrets, runtime_overrides);
|
||||
let status = |source| {
|
||||
yes_no(
|
||||
diagnostics
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_some_and(|key| key.uses(source)),
|
||||
)
|
||||
};
|
||||
lines.push(format!(
|
||||
"{label:<12} {} {} {} {}",
|
||||
status(RuntimeApiKeySource::ConfigFile),
|
||||
status(RuntimeApiKeySource::Keyring),
|
||||
status(RuntimeApiKeySource::Env),
|
||||
diagnostics.source
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let file = provider_config_set(store, provider);
|
||||
let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
|
||||
let env = provider_env_set(provider);
|
||||
let external_selected = external_oauth_selected(store, provider);
|
||||
let active = if provider == ProviderKind::OpenaiCodex {
|
||||
// Same lookup order as `auth status`: env, then the
|
||||
// Codewhale-owned sign-in, then consented Codex CLI import.
|
||||
if env {
|
||||
"env".to_string()
|
||||
} else if store
|
||||
.config
|
||||
.providers
|
||||
.openai_codex
|
||||
.oauth_credential_generation
|
||||
.as_deref()
|
||||
.is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
|
||||
{
|
||||
// An unusable owned sign-in falls through to consent at
|
||||
// runtime (`codex_credentials`), so name what is really used.
|
||||
match owned_oauth_account(store, provider) {
|
||||
Ok(Some(account)) => format!("owned-oauth ({account})"),
|
||||
Ok(None) => "owned-oauth".to_string(),
|
||||
Err(_) if external_selected => "external-consent".to_string(),
|
||||
Err(_) => "owned-oauth-unusable".to_string(),
|
||||
}
|
||||
} else if external_selected {
|
||||
"external-consent".to_string()
|
||||
} else {
|
||||
"missing".to_string()
|
||||
}
|
||||
} else if external_selected {
|
||||
let active = if external_selected {
|
||||
"external-consent".to_string()
|
||||
} else if file {
|
||||
"config".to_string()
|
||||
@@ -4035,46 +4202,18 @@ fn auth_status_lines_for_provider_with_runtime(
|
||||
return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
|
||||
}
|
||||
|
||||
if provider == ProviderKind::OpenaiCodex {
|
||||
return chatgpt_auth_status_lines(store, secrets, runtime_overrides);
|
||||
}
|
||||
|
||||
let config_key = provider_config_api_key(store, provider);
|
||||
let keyring_key = provider_keyring_api_key(secrets, provider);
|
||||
let env_key = provider_env_value(provider);
|
||||
let external = external_consent(store, provider);
|
||||
let external_selected = external_oauth_selected(store, provider);
|
||||
|
||||
let owned_chatgpt_source = (provider == ProviderKind::OpenaiCodex
|
||||
&& store
|
||||
.config
|
||||
.providers
|
||||
.openai_codex
|
||||
.oauth_credential_generation
|
||||
.as_deref()
|
||||
.is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation))
|
||||
.then(|| match owned_oauth_account(store, provider) {
|
||||
Ok(Some(account)) => {
|
||||
format!("codewhale-owned ChatGPT sign-in as {account} (availability not probed)")
|
||||
}
|
||||
Ok(None) => "codewhale-owned ChatGPT sign-in (availability not probed)".to_string(),
|
||||
// The runtime skips an unusable owned sign-in and falls through to
|
||||
// consent (`codex_credentials`), so say so rather than name it.
|
||||
Err(reason) if external_selected => format!(
|
||||
"external read-only consent (availability not probed; the Codewhale-owned ChatGPT sign-in is unusable: {reason})"
|
||||
),
|
||||
Err(reason) => format!(
|
||||
"missing (the Codewhale-owned ChatGPT sign-in is unusable: {reason}; run `codewhale auth chatgpt`)"
|
||||
),
|
||||
});
|
||||
let active_label = {
|
||||
let active_source = if provider == ProviderKind::OpenaiCodex {
|
||||
if env_key.is_some() {
|
||||
"env"
|
||||
} else if let Some(source) = owned_chatgpt_source.as_deref() {
|
||||
source
|
||||
} else if external_selected {
|
||||
"external read-only consent (availability not probed)"
|
||||
} else {
|
||||
"missing"
|
||||
}
|
||||
} else if external_selected {
|
||||
let active_source = if external_selected {
|
||||
"external read-only consent (availability not probed)"
|
||||
} else if config_key.is_some() {
|
||||
"config"
|
||||
@@ -4085,14 +4224,10 @@ fn auth_status_lines_for_provider_with_runtime(
|
||||
} else {
|
||||
"missing"
|
||||
};
|
||||
let active_last4 = if provider == ProviderKind::OpenaiCodex {
|
||||
env_key.as_ref().map(|(_, value)| last4_label(value))
|
||||
} else {
|
||||
config_key
|
||||
.map(last4_label)
|
||||
.or_else(|| keyring_key.as_deref().map(last4_label))
|
||||
.or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
|
||||
};
|
||||
let active_last4 = config_key
|
||||
.map(last4_label)
|
||||
.or_else(|| keyring_key.as_deref().map(last4_label))
|
||||
.or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));
|
||||
active_last4
|
||||
.map(|last4| format!("{active_source} (last4: {last4})"))
|
||||
.unwrap_or_else(|| active_source.to_string())
|
||||
@@ -4114,22 +4249,13 @@ fn auth_status_lines_for_provider_with_runtime(
|
||||
let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
|
||||
let model = provider_cfg.model.as_deref().unwrap_or("(default)");
|
||||
|
||||
let lookup_order = if provider == ProviderKind::OpenaiCodex {
|
||||
"lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
|
||||
.to_string()
|
||||
} else {
|
||||
"lookup order: config -> secret store -> env".to_string()
|
||||
};
|
||||
let auth_mode = if provider == ProviderKind::OpenaiCodex {
|
||||
"codex_oauth".to_string()
|
||||
} else {
|
||||
provider_cfg
|
||||
.auth_mode
|
||||
.as_deref()
|
||||
.or(store.config.auth_mode.as_deref())
|
||||
.unwrap_or("api_key")
|
||||
.to_string()
|
||||
};
|
||||
let lookup_order = "lookup order: config -> secret store -> env".to_string();
|
||||
let auth_mode = provider_cfg
|
||||
.auth_mode
|
||||
.as_deref()
|
||||
.or(store.config.auth_mode.as_deref())
|
||||
.unwrap_or("api_key")
|
||||
.to_string();
|
||||
|
||||
let mut lines = vec![
|
||||
format!("provider: {}{}", provider.as_str(), active_marker),
|
||||
@@ -4150,18 +4276,6 @@ fn auth_status_lines_for_provider_with_runtime(
|
||||
),
|
||||
format!("env var: {env_var_label} ({env_status})"),
|
||||
];
|
||||
if provider == ProviderKind::OpenaiCodex {
|
||||
// An env token outranks every sign-in, so a new login alone would
|
||||
// change nothing (the client leaves its switch hint out likewise).
|
||||
lines.push(match env_key.as_ref() {
|
||||
Some((name, _)) => format!(
|
||||
"switch account: unset {name} first (it outranks every sign-in), then run `codewhale auth chatgpt` and choose the other ChatGPT account"
|
||||
),
|
||||
None => "switch account: `codewhale auth chatgpt` (choose the other ChatGPT account; replaces the Codewhale-owned sign-in)"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok((source, expected_path)) = external_credential_target(provider, None) {
|
||||
let status = codewhale_config::external_credential_consent_status(
|
||||
external,
|
||||
@@ -9818,8 +9932,9 @@ verbosity = "concise"
|
||||
assert!(
|
||||
ProviderKind::ALL
|
||||
.iter()
|
||||
.filter(|p| **p != ProviderKind::OpenaiCodex)
|
||||
.all(|p| probed.contains(&provider_slot(*p).to_string())),
|
||||
"every known provider should be probed by auth list: {:?}",
|
||||
"API-key providers should be probed by auth list: {:?}",
|
||||
*probed
|
||||
);
|
||||
|
||||
@@ -10005,79 +10120,167 @@ verbosity = "concise"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
|
||||
use codewhale_secrets::InMemoryKeyringStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn chatgpt_auth_diagnostics_ignore_ambient_tokens_and_external_consent() {
|
||||
let _lock = env_lock();
|
||||
let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
|
||||
let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
|
||||
|
||||
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
let home = dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.expect("canonical root")
|
||||
.join("codewhale-home");
|
||||
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
|
||||
let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "ambient-secret-9911");
|
||||
let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "alias-secret-9922");
|
||||
let auth_path = dir.path().join("auth.json");
|
||||
std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
|
||||
.expect("write auth file");
|
||||
let auth_path_str = auth_path.to_string_lossy().into_owned();
|
||||
let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
|
||||
|
||||
let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
|
||||
let external_raw = r#"{"tokens":{"access_token":"external-secret-9933"}}"#;
|
||||
std::fs::write(&auth_path, external_raw).expect("external trap");
|
||||
let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path.to_string_lossy());
|
||||
let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
|
||||
store.config.provider = ProviderKind::OpenaiCodex;
|
||||
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
||||
|
||||
let output =
|
||||
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
|
||||
|
||||
assert!(output.contains("provider: openai-codex"));
|
||||
assert!(output.contains("auth mode: codex_oauth"));
|
||||
assert!(output.contains("active source: missing"));
|
||||
assert!(output.contains(
|
||||
"lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
|
||||
));
|
||||
assert!(output.contains("external credentials: disabled"));
|
||||
assert!(output.contains("scope_valid=false"));
|
||||
assert!(output.contains("disabled; no external-credential probing, reading"));
|
||||
assert!(output.contains("file not probed"));
|
||||
assert!(!output.contains("secret-token"));
|
||||
|
||||
store.config.providers.openai_codex.external_credentials =
|
||||
Some(codewhale_config::ExternalCredentialConsentToml::read_only(
|
||||
ProviderKind::OpenaiCodex,
|
||||
codewhale_config::ExternalCredentialSource::CodexCli,
|
||||
auth_path.clone(),
|
||||
));
|
||||
let output =
|
||||
let keyring = std::sync::Arc::new(RecordingKeyringStore::default());
|
||||
let secrets = Secrets::new(keyring.clone());
|
||||
let runtime = CliRuntimeOverrides::default();
|
||||
let status =
|
||||
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
|
||||
let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
|
||||
let list = auth_list_lines(&store, &secrets).join("\n");
|
||||
let summary = auth_status_all_providers(&store, &secrets).join("\n");
|
||||
assert!(status.contains("auth mode: oauth"), "{status}");
|
||||
assert!(status.contains("active source: missing"), "{status}");
|
||||
assert!(
|
||||
output.contains("active source: external read-only consent (availability not probed)")
|
||||
status.contains("verified Codewhale-owned ChatGPT sign-in only"),
|
||||
"{status}"
|
||||
);
|
||||
assert!(output.contains("external credentials: read_only"));
|
||||
assert!(output.contains("provider=openai-codex"));
|
||||
assert!(output.contains("source=codex_cli"));
|
||||
assert!(output.contains(&format!(
|
||||
"path={}",
|
||||
codewhale_config::quote_os_path(&auth_path)
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"consent_version={}",
|
||||
codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
|
||||
)));
|
||||
assert!(output.contains("file not probed"));
|
||||
assert!(!output.contains("secret-token"));
|
||||
assert!(
|
||||
status.contains("CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt"),
|
||||
"{status}"
|
||||
);
|
||||
assert!(get.starts_with("openai-codex: not set"), "{get}");
|
||||
for output in [&status, &get, &list, &summary] {
|
||||
assert!(!output.contains("secret-99"), "{output}");
|
||||
assert!(!output.contains("active source: env"), "{output}");
|
||||
assert!(!output.contains("external-consent"), "{output}");
|
||||
}
|
||||
assert!(
|
||||
!keyring.queried().iter().any(|slot| slot == "openai-codex"),
|
||||
"official ChatGPT must not inspect API-key storage"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(auth_path).expect("external trap unchanged"),
|
||||
external_raw
|
||||
);
|
||||
}
|
||||
|
||||
let ambient_path = dir.path().join("new-ambient-auth.json");
|
||||
let ambient_path_str = ambient_path.to_string_lossy().into_owned();
|
||||
let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
|
||||
let changed =
|
||||
#[test]
|
||||
fn chatgpt_custom_endpoint_diagnostics_use_only_route_bound_api_keys() {
|
||||
let _lock = env_lock();
|
||||
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let home = dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.expect("canonical root")
|
||||
.join("codewhale-home");
|
||||
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
|
||||
let _token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "ambient-secret-9944");
|
||||
let _alias = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "alias-secret-9955");
|
||||
let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
|
||||
store.config.provider = ProviderKind::OpenaiCodex;
|
||||
let provider = &mut store.config.providers.openai_codex;
|
||||
provider.base_url = Some("https://custom.example/v1".to_string());
|
||||
provider.api_key = Some("route-bound-secret-9966".to_string());
|
||||
provider.auth_mode = Some("oauth".to_string());
|
||||
provider.oauth_credential_generation = Some("../must-not-read.json".to_string());
|
||||
let secrets = no_keyring_secrets();
|
||||
let runtime = CliRuntimeOverrides::default();
|
||||
let status =
|
||||
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
|
||||
assert!(changed.contains("state=active"), "{changed}");
|
||||
assert!(changed.contains("ambient_path_changed=true"), "{changed}");
|
||||
assert!(changed.contains("consent remains pinned"), "{changed}");
|
||||
let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
|
||||
let list = auth_list_lines(&store, &secrets).join("\n");
|
||||
let summary = auth_status_all_providers(&store, &secrets).join("\n");
|
||||
assert!(status.contains("auth mode: api_key"), "{status}");
|
||||
assert!(
|
||||
changed.contains(&codewhale_config::quote_os_path(&auth_path)),
|
||||
"{changed}"
|
||||
status.contains("active source: config (last4: ...9966)"),
|
||||
"{status}"
|
||||
);
|
||||
assert!(!changed.contains(&ambient_path_str), "{changed}");
|
||||
for output in [&status, &get, &list, &summary] {
|
||||
assert!(output.contains("config (last4: ...9966)"), "{output}");
|
||||
assert!(!output.contains("secret-99"), "{output}");
|
||||
assert!(!output.contains("verified grant"), "{output}");
|
||||
}
|
||||
assert!(!status.contains("switch account:"), "{status}");
|
||||
|
||||
let another_endpoint = CliRuntimeOverrides {
|
||||
base_url: Some("https://other.example/v1".to_string()),
|
||||
..CliRuntimeOverrides::default()
|
||||
};
|
||||
let get = auth_get_line_with_runtime(
|
||||
&store,
|
||||
&secrets,
|
||||
ProviderKind::OpenaiCodex,
|
||||
&another_endpoint,
|
||||
);
|
||||
assert!(get.starts_with("openai-codex: not set"), "{get}");
|
||||
assert!(
|
||||
!get.contains("9966"),
|
||||
"key must remain bound to its configured endpoint: {get}"
|
||||
);
|
||||
let explicit = CliRuntimeOverrides {
|
||||
api_key: Some("explicit-secret-9977".to_string()),
|
||||
..another_endpoint
|
||||
};
|
||||
let get =
|
||||
auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &explicit);
|
||||
assert!(get.contains("cli (last4: ...9977)"), "{get}");
|
||||
assert!(!get.contains("explicit-secret"), "{get}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_auth_diagnostics_never_display_custom_url_credentials() {
|
||||
let _lock = env_lock();
|
||||
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let home = dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.expect("canonical root")
|
||||
.join("codewhale-home");
|
||||
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
|
||||
let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("store");
|
||||
store.config.provider = ProviderKind::OpenaiCodex;
|
||||
let raw_url = "https://private-user:[email protected]/v1?token=private-query-token#private-fragment";
|
||||
store.config.providers.openai_codex.base_url = Some(raw_url.to_string());
|
||||
store.config.providers.openai_codex.api_key = Some("route-bound-secret-9988".to_string());
|
||||
let secrets = no_keyring_secrets();
|
||||
let runtime = CliRuntimeOverrides::default();
|
||||
let status =
|
||||
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
|
||||
assert!(
|
||||
status.contains("route: custom API-key endpoint"),
|
||||
"{status}"
|
||||
);
|
||||
let get = auth_get_line_with_runtime(&store, &secrets, ProviderKind::OpenaiCodex, &runtime);
|
||||
let list = auth_list_lines(&store, &secrets).join("\n");
|
||||
let summary = auth_status_all_providers(&store, &secrets).join("\n");
|
||||
for output in [&status, &get, &list, &summary] {
|
||||
for secret in [
|
||||
raw_url,
|
||||
"private-user",
|
||||
"private-password",
|
||||
"private-query-token",
|
||||
"private-fragment",
|
||||
"route-bound-secret",
|
||||
] {
|
||||
assert!(
|
||||
!output.contains(secret),
|
||||
"URL or credential leaked in diagnostic output: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -10103,6 +10306,7 @@ verbosity = "concise"
|
||||
.providers
|
||||
.openai_codex
|
||||
.oauth_credential_generation = Some(chatgpt_generation.to_string());
|
||||
store.config.providers.openai_codex.auth_mode = Some("oauth".to_string());
|
||||
store.config.providers.xai.auth_mode = Some("oauth".to_string());
|
||||
store.config.providers.xai.oauth_credential_generation = Some(xai_generation.to_string());
|
||||
|
||||
@@ -10136,14 +10340,54 @@ verbosity = "concise"
|
||||
.expect("seed Codewhale-owned sign-ins");
|
||||
let secrets = no_keyring_secrets();
|
||||
|
||||
let legacy =
|
||||
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
|
||||
assert!(legacy.contains("active source: missing"), "{legacy}");
|
||||
assert!(
|
||||
!legacy.contains("[email protected]"),
|
||||
"legacy claims must not name an official account: {legacy}"
|
||||
);
|
||||
|
||||
// Stored-proof fixture; signed JWT verification is exercised in the
|
||||
// OAuth tests. Only the protected, verified grant may supply a label.
|
||||
let official_file = serde_json::json!({
|
||||
"https://auth.openai.com::oaiapp_codewhale_test": {
|
||||
"access_token": "chatgpt-access-secret-9911",
|
||||
"refresh_token": "chatgpt-refresh-secret-9912",
|
||||
"id_token": format!("hdr.{chatgpt_claims}.sig-secret-9913"),
|
||||
"account_id": "test-sub",
|
||||
"oidc_issuer": "https://auth.openai.com",
|
||||
"oidc_client_id": "oaiapp_codewhale_test",
|
||||
"siwc_registration": {
|
||||
"issuer": "https://auth.openai.com",
|
||||
"client_id": "oaiapp_codewhale_test",
|
||||
"subject": "test-sub",
|
||||
"email": "[email protected]",
|
||||
"host_id": "urn:uuid:01234567-89ab-cdef-0123-456789abcdef"
|
||||
},
|
||||
"siwc_scope": "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
|
||||
"siwc_token_sha256": "oPH6E3xSo6jmzfjPkkNVow-MDP5cTEzxdTLxk_woXeY"
|
||||
}
|
||||
});
|
||||
codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
|
||||
owned.write(
|
||||
chatgpt_generation,
|
||||
official_file.to_string().as_bytes(),
|
||||
true,
|
||||
)
|
||||
})
|
||||
.expect("install protected verified-grant fixture");
|
||||
|
||||
let codex =
|
||||
auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
|
||||
assert!(
|
||||
codex.contains("active source: codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
|
||||
codex.contains("active source: Codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
|
||||
"{codex}"
|
||||
);
|
||||
assert!(
|
||||
codex.contains("switch account: `codewhale auth chatgpt`"),
|
||||
codex.contains(
|
||||
"switch account: `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`"
|
||||
),
|
||||
"{codex}"
|
||||
);
|
||||
let xai = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
|
||||
@@ -10153,7 +10397,10 @@ verbosity = "concise"
|
||||
"{xai}"
|
||||
);
|
||||
let list = auth_list_lines(&store, &secrets).join("\n");
|
||||
assert!(list.contains("owned-oauth ([email protected] (pro))"), "{list}");
|
||||
assert!(
|
||||
list.contains("Codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
|
||||
"{list}"
|
||||
);
|
||||
assert!(
|
||||
list.contains("owned-oauth-configured ([email protected])"),
|
||||
"{list}"
|
||||
@@ -10164,21 +10411,36 @@ verbosity = "concise"
|
||||
}
|
||||
}
|
||||
|
||||
// An env token outranks the sign-in: a re-login alone switches
|
||||
// nothing, so status must not advertise it as the switch.
|
||||
// Ambient tokens never replace the verified own grant or its label.
|
||||
{
|
||||
let _token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "env-token-secret-9931");
|
||||
let codex = auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex)
|
||||
.join("\n");
|
||||
assert!(
|
||||
codex.contains("switch account: unset OPENAI_CODEX_ACCESS_TOKEN first"),
|
||||
codex.contains("Codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
|
||||
"{codex}"
|
||||
);
|
||||
assert!(
|
||||
!codex.contains("replaces the Codewhale-owned sign-in"),
|
||||
codex.contains("CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt"),
|
||||
"{codex}"
|
||||
);
|
||||
assert!(
|
||||
!codex.contains("unset OPENAI_CODEX_ACCESS_TOKEN first"),
|
||||
"{codex}"
|
||||
);
|
||||
assert!(!codex.contains("env-token-secret-9931"), "{codex}");
|
||||
let get = auth_get_line_with_runtime(
|
||||
&store,
|
||||
&secrets,
|
||||
ProviderKind::OpenaiCodex,
|
||||
&CliRuntimeOverrides::default(),
|
||||
);
|
||||
assert!(get.contains("[email protected] (pro)"), "{get}");
|
||||
let summary = auth_status_all_providers(&store, &secrets).join("\n");
|
||||
assert!(
|
||||
summary.contains("Codewhale-owned ChatGPT sign-in as [email protected] (pro)"),
|
||||
"{summary}"
|
||||
);
|
||||
}
|
||||
|
||||
// A missing generation file is reported, not silently dropped.
|
||||
@@ -10707,7 +10969,7 @@ verbosity = "concise"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_list_uses_persisted_consent_without_probing_codex_file() {
|
||||
fn auth_list_keeps_legacy_codex_consent_inactive_without_probing_file() {
|
||||
use codewhale_secrets::InMemoryKeyringStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -10738,7 +11000,11 @@ verbosity = "concise"
|
||||
.lines()
|
||||
.find(|line| line.starts_with("openai-codex"))
|
||||
.unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
|
||||
assert!(row.ends_with("external-consent"), "{row}");
|
||||
assert!(
|
||||
row.contains("missing (run `codewhale auth chatgpt`"),
|
||||
"{row}"
|
||||
);
|
||||
assert!(!row.contains("external-consent"), "{row}");
|
||||
assert!(!output.contains("secret-token"));
|
||||
}
|
||||
|
||||
|
||||
@@ -2282,6 +2282,13 @@ pub fn owned_oauth_account_label(
|
||||
oauth::owned_account_label_for_generation(provider, generation)
|
||||
}
|
||||
|
||||
/// Whether a resolved route is the canonical public ChatGPT API endpoint.
|
||||
/// CLI diagnostics use the same destination check as inference dispatch.
|
||||
#[must_use]
|
||||
pub fn is_official_chatgpt_api_base(base_url: &str) -> bool {
|
||||
pricing::is_official_chatgpt_api(base_url)
|
||||
}
|
||||
|
||||
/// Compatibility command to explicitly enable usage under the current policy.
|
||||
/// This optional choice never arms the current process.
|
||||
pub fn accept_telemetry_notice(config_path: Option<PathBuf>, version: u32) -> Result<String> {
|
||||
|
||||
@@ -140,6 +140,7 @@ fn load_credentials(grant: &ExternalCredentialReadGrant) -> Result<Option<CodexC
|
||||
/// this to find out whether the consented file *still* holds a usable token,
|
||||
/// because a record that outlives its token would otherwise read as stored.
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub fn stored_credentials_present(grant: &ExternalCredentialReadGrant) -> bool {
|
||||
load_credentials(grant)
|
||||
.ok()
|
||||
@@ -1332,6 +1333,7 @@ pub fn parse_callback_query(params: &OAuthProviderParams, query: &str) -> Result
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn accept_callback(expected_state: &str, outcome: CallbackOutcome) -> Result<String> {
|
||||
accept_callback_with_client(expected_state, outcome).map(|(code, _)| code)
|
||||
}
|
||||
|
||||
+46
-36
@@ -106,7 +106,9 @@ and never changes the saved provider or model. A command-line API key is
|
||||
confined to the active provider; other routes are reported as skipped for
|
||||
that invocation.
|
||||
|
||||
Plain `models` lists the active provider's saved catalog without provider requests. Successful refreshes are saved under Codewhale's
|
||||
Plain `models` lists the active provider's saved catalog without provider requests.
|
||||
For ChatGPT, it validates the selected registration locally before reading
|
||||
that account's saved roster. Successful refreshes are saved under Codewhale's
|
||||
catalog directory and used by the model/provider pickers. Cache files are
|
||||
scoped to provider identity and endpoint; a failed refresh preserves prior
|
||||
rows. Text output reports source, last successful fetch time (Unix seconds),
|
||||
@@ -660,43 +662,51 @@ OAuth remains blocked on a vendor-registered Codewhale identity.
|
||||
|
||||
Codewhale keeps one sign-in of its own per subscription provider. It is
|
||||
separate from the browser, the ChatGPT or Grok apps, and the Codex or Grok
|
||||
CLIs, so those can be signed in to a different account.
|
||||
CLIs, so those can be signed in to a different account. A successful login
|
||||
replaces the previous owned grant; there is no picker for multiple saved
|
||||
ChatGPT accounts.
|
||||
|
||||
- **See which account is signed in.** `codewhale auth status --provider
|
||||
openai-codex` (or `--provider xai`) and `codewhale auth list` show the
|
||||
account email and, for ChatGPT, the plan; a team, business, enterprise or
|
||||
other workspace plan also shows the first characters of the workspace's
|
||||
account id, so two workspaces on one email are told apart. The label comes
|
||||
from the ID token the issuer returned at sign-in, decoded locally; no token
|
||||
is printed. A sign-in that can no longer produce a token (expired, no
|
||||
refresh token, file missing) shows no account and says why. The provider
|
||||
picker shows the label of the sign-in the route would use (for xAI that
|
||||
includes a consented Grok CLI import) next to the credential, and a
|
||||
successful login ends with `Signed in to ChatGPT as <email> (<plan>).` (in
|
||||
the TUI, in its interface language).
|
||||
- **Switch accounts.** Inside Codewhale, run `/auth chatgpt` (or
|
||||
`/auth xai-device`); this switches the running session. From a shell, run
|
||||
`codewhale auth chatgpt` (or `codewhale auth xai-device`) and restart any
|
||||
open Codewhale session, which keeps the credential it started with. The
|
||||
ChatGPT sign-in page is opened with `prompt=login`, asking the issuer to
|
||||
show the sign-in step instead of reusing the browser's current account; set
|
||||
`CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1` to leave that parameter out if the
|
||||
issuer ever refuses it. The xAI device page approves with whichever account
|
||||
the browser holds. For either, if the browser is signed in to the wrong
|
||||
account, sign out there or open the printed URL in a private window. The
|
||||
new account replaces the Codewhale-owned sign-in outright, and the login
|
||||
names the account it replaced, or says it is the same account as before.
|
||||
- **Usage limits.** When the ChatGPT subscription reports its usage limit
|
||||
(`usage_limit_reached`), says the plan does not include Codex
|
||||
(`usage_not_included`), or a provider reports plan quota exhausted on a
|
||||
subscription sign-in, the error names the account whose credential made the
|
||||
request and how to switch. Codewhale does not retry that error, because
|
||||
the limit resets on the plan's schedule. An xAI route set to OAuth that fell
|
||||
back to an API key gets no sign-in guidance, since no sign-in was used.
|
||||
- **Precedence.** `OPENAI_CODEX_ACCESS_TOKEN` / `CODEX_ACCESS_TOKEN` outrank
|
||||
the Codewhale-owned ChatGPT sign-in, and the owned sign-in outranks a
|
||||
consented Codex CLI import. Unset the token variable if a re-login seems to
|
||||
have no effect; `auth status` and the login itself say so when one is set.
|
||||
openai-codex` (or `--provider xai`) and `codewhale auth list` show an account
|
||||
label when the ID token contains an email. ChatGPT plan and workspace
|
||||
details appear only when those claims are present; a workspace label uses
|
||||
the first characters of its account ID. The label is display metadata, not
|
||||
proof of plan entitlement. A valid sign-in may have no email label. Status
|
||||
reads local credential state without contacting the issuer: an expired
|
||||
access token with a stored refresh token remains structurally usable, while
|
||||
missing or invalid storage, or an expired token without a refresh token,
|
||||
is reported unusable. The provider picker shows the label of the sign-in
|
||||
the route would use (for xAI this includes a consented Grok CLI import).
|
||||
Successful login reports the account when known and whether it replaced
|
||||
an earlier sign-in, in the TUI's selected language. No token is printed.
|
||||
- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the
|
||||
running session, or `codewhale auth chatgpt` from a shell and restart open
|
||||
Codewhale sessions. Ordinary login reuses the selected account/workspace's
|
||||
issued client ID and checks that the returned account is the same. The
|
||||
browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`
|
||||
omits that parameter if the issuer refuses it.
|
||||
- **Replace the ChatGPT account or workspace.** From a shell, run
|
||||
`CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its
|
||||
model catalog and restart open Codewhale sessions. If the browser holds the
|
||||
wrong account, sign out there or open the printed URL in a private window.
|
||||
Only a fully validated new grant replaces the active owned sign-in.
|
||||
- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or
|
||||
`codewhale auth xai-device` from a shell and restart open sessions. The
|
||||
device page approves with whichever account the browser holds; sign out
|
||||
there or use a private window to approve another account. The login names
|
||||
the account it replaced, or says it is the same account as before.
|
||||
- **Usage limits.** ChatGPT plan requests can fail with
|
||||
`subscription_sharing_usage_limit_exceeded` or
|
||||
`subscription_sharing_usage_unavailable`. These errors, and subscription
|
||||
quota errors from other providers, name the account that made the request
|
||||
when its label is known and offer recovery guidance. Codewhale does not
|
||||
retry these terminal errors or silently choose another billing route. An
|
||||
xAI route set to OAuth that fell back to an API key gets no sign-in
|
||||
guidance, since no sign-in was used.
|
||||
- **ChatGPT credential authority.** The official ChatGPT plan route uses
|
||||
only its selected, verified Codewhale-owned grant.
|
||||
`OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI
|
||||
tokens cannot override or authorize this route.
|
||||
|
||||
### External CLI credential consent
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 英文原文:[PROVIDERS.md](../PROVIDERS.md)。
|
||||
> 最后与英文同步日期(last synced with English revision):2026-09-29。
|
||||
> ChatGPT 登录相关内容于 2026-10-01 按当前实现更新。
|
||||
> ChatGPT 登录相关内容于 2026-10-02 按当前实现更新。
|
||||
|
||||
本注册表描述已接入当前 Codewhale 代码库的提供商行为。它刻意保持保守:随附条目仅限于代码已知的提供商 ID、配置键、认证路径、base URL、模型解析和能力元数据。
|
||||
|
||||
@@ -54,7 +54,7 @@ codewhale models --provider openai --json
|
||||
|
||||
`models --update`(也可写作 `--refresh`)会更新共享的 Models.dev 元数据,并用每个已配置提供商各自的凭据调用其现有的 `/models` 端点。`--provider ID` 把刷新限定在该提供商,包括具名的自定义端点。它不发起任何推理请求,也不会更改已保存的提供商或模型。命令行传入的 API 密钥只限于当前提供商;本次调用中其他路由会被报告为已跳过。
|
||||
|
||||
不带 `--update` 的 `models` 会列出当前提供商已保存的目录,不发起提供商请求。刷新成功后,结果保存在 Codewhale 的目录文件夹下,供模型/提供商选择器使用。缓存文件按提供商身份和端点划分;刷新失败时会保留原有行。文本输出报告来源、最近一次成功获取的时间(Unix 秒)和新鲜度。`--update --json` 会额外给出每个来源的回执和汇总计数;部分失败时,会在写完这些回执后返回非零退出码。普通的 `models --json` 仍保持模型数组格式。随附的或已配置的回退项并不能证明账户可以使用列出的每个模型。
|
||||
不带 `--update` 的 `models` 会列出当前提供商已保存的目录,不发起提供商请求。ChatGPT 路由会先在本地验证所选注册,再读取该账户的缓存目录。刷新成功后,结果保存在 Codewhale 的目录文件夹下,供模型/提供商选择器使用。缓存文件按提供商身份和端点划分;刷新失败时会保留原有行。文本输出报告来源、最近一次成功获取的时间(Unix 秒)和新鲜度。`--update --json` 会额外给出每个来源的回执和汇总计数;部分失败时,会在写完这些回执后返回非零退出码。普通的 `models --json` 仍保持模型数组格式。随附的或已配置的回退项并不能证明账户可以使用列出的每个模型。
|
||||
|
||||
### 使用 ChatGPT 登录
|
||||
|
||||
@@ -68,6 +68,10 @@ codewhale --provider openai-codex
|
||||
|
||||
登录通过系统浏览器、回环回调和 PKCE 完成。Codewhale 验证返回的身份与套餐使用权限,把注册信息和可续期令牌保存在自己的受保护凭据存储中。身份登录不等于套餐授权;未授予 `chatgpt.tokens.use.direct` 权限时,推理会停止。使用 `codewhale auth chatgpt-revoke` 退出 Codewhale 的登录会话。当前实验版只保留一个活动账户注册,尚未提供已保存账户的切换界面。
|
||||
|
||||
在 Codewhale 内运行 `/auth chatgpt` 会重新授权当前账户和工作区,并更新正在运行的会话;普通重新登录会沿用已签发的客户端 ID,验证返回的仍是同一账户。要更换账户或工作区,请在终端运行 `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`,刷新新账户的模型目录,再重启已打开的 Codewhale 会话。只有通过完整验证的新授权才会替换现有登录。浏览器若登录了错误的账户,可先退出该账户,或用隐私窗口打开打印出的登录链接。
|
||||
|
||||
`codewhale auth status --provider openai-codex` 和 `codewhale auth list` 会在 ID 令牌含有邮箱时显示账户标签;套餐和工作区信息只在相应字段存在时显示。标签用于识别账户,不能证明套餐权限;没有邮箱标签也不代表已经退出登录。状态检查只读取本地凭据,不向签发方发起请求:访问令牌已过期但仍存有刷新令牌时,会被视为结构上可用,这不保证续期一定成功。登录成功后,会显示已知的账户标签,并说明是否替换了先前的登录。
|
||||
|
||||
模型目录使用已获授权的令牌请求 `GET https://api.openai.com/v1/models`;推理使用公共端点 `POST https://api.openai.com/v1/responses`。选择器保留官方显示名称和顺序,仅列出 `visibility: list` 的模型。目录不含凭据,并按已验证的签发方、签发的客户端 ID 和账户主体隔离;模型列表不能证明上下文窗口或思考档位。目录缺失、无效或超过 24 小时时,选择器不会列出任何账户模型。Codex CLI 导入令牌和旧版进程令牌变量不能授权这条官方路由。
|
||||
|
||||
符合条件的请求消耗 ChatGPT 套餐额度或点数。Plus 的五小时额度在各应用之间共享,不会为每个应用单独分配;这项五小时限制不适用于 Pro。应用还可能有各自的使用上限。可在 [ChatGPT 设置 → 使用情况](https://chatgpt.com/settings/usage) 查看额度和授权。额度耗尽时,Codewhale 不会自动换用 API 密钥或其他提供商。
|
||||
|
||||
Reference in new issue
Block a user