修复 API Key app-server 认证重试循环 (#205)
## 修复摘要 Close #187 Issue #187 的根因是:API Key/provider-proxy 模式使用隔离 `CODEX_HOME`,其中没有 ChatGPT `auth.json`;Codex app-server 默认启动 remote-control 后,会在 `desired_state=Unknown` 时每约 1 秒重试认证,形成持续的认证日志循环。 本 PR 的最终修复是启动级禁用,而不是单纯屏蔽日志: - API Key 和 provider-proxy 会话在启动 Codex app-server 子进程前设置 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,使 remote-control 从启动时即处于 `desired_state=Disabled`。 - 删除此前握手后调用 `remoteControl/disable` 的方案;真实 smoke 证明该 RPC 在无 ChatGPT 登录态时本身会返回 `remote control requires ChatGPT authentication`,无法阻止认证循环。 - 真实 AuthBridge 登录态不设置该变量,保持原有 remote-control 行为。 - API Key 子进程额外设置 `RUST_LOG=warn`,仅收敛剩余预期噪音,不承担修复职责。 - 更新 Unix fake app-server fixture、JSON-RPC 响应 ID 序列和凭据边界回归测试。 - 同步更新技术方案,明确启动环境变量、认证边界和验证结论。 ## 真实 smoke 使用临时 API Key、隔离 `CODEX_HOME` 和 Codex `0.150.0-alpha.8`: - 未设置启动变量时:出现 `remote control requires ChatGPT authentication`,并持续输出 `Reloading auth` / `Reloaded auth`。 - 设置 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1` 后:状态为 `desired_state=Disabled`,5 秒观察窗口内不再出现认证重试循环,仅剩少量启动/退出生命周期日志。 - `--disable remote_control` 与 `-c features.remote_control=false` 未达到同等效果。 ## 验证 - `cargo test --bin genarrative-ai-game-creator-shell codex_app_server`:36 passed,1 ignored,0 failed - `cargo check --bin genarrative-ai-game-creator-shell`:通过 - `rustfmt --edition 2021 --check src/agent/codex_app_server.rs`:通过 - `npm run ai-game-creator-shell:typecheck`:通过 - `npm run check:encoding`:通过 - `git diff --check`:通过 Windows 本机不会执行 Unix-only fake app-server 分支;相关 fixture 已更新,交由 Linux CI 覆盖。真实 smoke 只验证启动与认证循环,没有执行模型请求。 安全说明:临时 API Key 仅用于本次隔离 smoke,未写入仓库、提交或 PR;测试完成后应立即撤销/轮换该 Key。 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/205 Co-authored-by: 董羽秦 <suzmii@qq.com> Co-committed-by: 董羽秦 <suzmii@qq.com>
This commit was merged in pull request #205.
This commit is contained in:
@@ -11,6 +11,8 @@ use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str =
|
||||
"CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED";
|
||||
const GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v3";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
@@ -69,6 +71,19 @@ impl CodexAppServerCredential {
|
||||
matches!(self, Self::AppDataKey { .. })
|
||||
}
|
||||
|
||||
fn remote_control_disable_reason(
|
||||
&self,
|
||||
bridge_through_provider_proxy: bool,
|
||||
) -> Option<&'static str> {
|
||||
if bridge_through_provider_proxy {
|
||||
Some("provider-proxy-auth")
|
||||
} else if self.uses_app_data_key() {
|
||||
Some("api-key-auth")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_provider_route<'a>(
|
||||
&'a self,
|
||||
llm: &'a GameCreatorLlmConfig,
|
||||
@@ -1444,6 +1459,8 @@ impl CodexAppServerConnection {
|
||||
.then(|| credential.direct_provider_route(llm))
|
||||
.flatten()
|
||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string()));
|
||||
let remote_control_disable_reason =
|
||||
credential.remote_control_disable_reason(direct_provider_route.is_some());
|
||||
let isolated_codex_home = prepare_isolated_game_creator_codex_home(
|
||||
working_dir.path(),
|
||||
credential,
|
||||
@@ -1549,6 +1566,16 @@ impl CodexAppServerConnection {
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
game_creator_codex_cli_minimal_environment(&mut command);
|
||||
if remote_control_disable_reason.is_some() {
|
||||
// API-key and provider-proxy sessions have no ChatGPT auth.json.
|
||||
// Disable remote-control before its websocket task can enter the
|
||||
// authentication retry loop.
|
||||
command.env(
|
||||
GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV,
|
||||
"1",
|
||||
);
|
||||
command.env("RUST_LOG", "warn");
|
||||
}
|
||||
if let Some(tool_bridge) = tool_bridge.as_ref() {
|
||||
command.env(DIRECT_TOOL_BRIDGE_URL_ENV, tool_bridge.url());
|
||||
}
|
||||
@@ -1634,6 +1661,9 @@ impl CodexAppServerConnection {
|
||||
.notify("initialized", serde_json::json!({}))
|
||||
.await
|
||||
.map_err(platform_llm::LlmError::Transport)?;
|
||||
if let Some(reason) = remote_control_disable_reason {
|
||||
eprintln!("agent.codex_app_server.remote_control disabled reason={reason}");
|
||||
}
|
||||
if let Some(skill_root) = connection.inner._skill_root.as_ref() {
|
||||
connection
|
||||
.request(
|
||||
@@ -3213,8 +3243,8 @@ mod tests {
|
||||
std::fs::create_dir_all(&project_root).expect("project root");
|
||||
std::fs::create_dir(&assets).expect("assets directory");
|
||||
std::fs::create_dir(&agent).expect("agent directory");
|
||||
let workspace = resolve_direct_codex_game_workspace(&project_root)
|
||||
.expect("resolve project workspace");
|
||||
let workspace =
|
||||
resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace");
|
||||
assert_eq!(
|
||||
workspace,
|
||||
project_root.canonicalize().expect("canonical project root")
|
||||
@@ -3695,6 +3725,7 @@ case "$GENARRATIVE_AGC_CODEX_API_KEY" in
|
||||
agc-provider-session-*) ;;
|
||||
*) exit 81 ;;
|
||||
esac
|
||||
[ "$CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED" = "1" ] || exit 90
|
||||
[ "$GENARRATIVE_AGC_CODEX_API_KEY" != "fixture-secret" ] || exit 82
|
||||
case " $* " in *"fixture-secret"*) exit 83 ;; esac
|
||||
case " $* " in *'--disable hooks'*) ;; *) exit 84 ;; esac
|
||||
@@ -4016,6 +4047,32 @@ while IFS= read -r line; do :; done
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_control_disable_policy_matches_the_credential_boundary() {
|
||||
let api_key = CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: "api-key".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
api_key.remote_control_disable_reason(false),
|
||||
Some("api-key-auth")
|
||||
);
|
||||
assert_eq!(
|
||||
api_key.remote_control_disable_reason(true),
|
||||
Some("provider-proxy-auth")
|
||||
);
|
||||
|
||||
let auth_bridge = CodexAppServerCredential::AuthBridge {
|
||||
fingerprint: "auth-bridge".to_string(),
|
||||
auth_json: br#"{"tokens":{"access_token":"fixture"}}"#.to_vec(),
|
||||
api_key: None,
|
||||
};
|
||||
assert_eq!(auth_bridge.remote_control_disable_reason(false), None);
|
||||
assert_eq!(
|
||||
auth_bridge.remote_control_disable_reason(true),
|
||||
Some("provider-proxy-auth")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_app_server_auth_bridge_snapshot_drives_pool_and_isolated_home() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -210,6 +210,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
|
||||
- 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。
|
||||
- LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。
|
||||
- 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。
|
||||
- remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。
|
||||
- 资源与退出:app-server pool 按实际凭据快照/base URL/API kind/CLI 版本和节点 run 身份隔离并做有界 LRU;空 AppData Key 必须读取同一份有界 `auth.json` 字节来生成池指纹并桥接隔离登录态,继承的 `CODEX_API_KEY` 始终移除,node thread 也只淘汰 inactive LRU。Runner 正常、强制和 watchdog 退出都显式关池,Linux child 绑定 parent-death signal,防止强杀 Runner 后遗留带凭据孤儿进程。stdout NDJSON 与 stderr 无换行记录均有硬上限;stderr 原文不写入诊断,只记录固定分类、总字节数、SHA-256 和可取得的退出状态。
|
||||
- 旧配置迁移:既有 AppData 若没有 `agentMode`,只有全局和逐 Agent 路由均为 `openai_responses` 时迁移到 `codex_app_server`;存在 `openai_chat / anthropic` 时显式保留 `provider`,避免打开项目自动恢复时把所有节点批量写成 `invalid-config`。用户确认端点支持 Responses 后,可在设置中显式切换并保留原 model/base URL/API Key。
|
||||
- 验收:fake JSON-RPC fixture、三态 UI/config、配置指纹、unknown-terminal 零重放、旧两种模式回归和显式 ignored 真实 smoke 全部通过后,才可视为模式切换完成。
|
||||
|
||||
Reference in New Issue
Block a user