Skip to content

Commit d87f87e

Browse files
authored
Add forced_chatgpt_workspace_id and forced_login_method configuration options (#5303)
This PR adds support for configs to specify a forced login method (chatgpt or api) as well as a forced chatgpt account id. This lets enterprises uses [managed configs](https://developers.openai.com/codex/security#managed-configuration) to force all employees to use their company's workspace instead of their own or any other. When a workspace id is set, a query param is sent to the login flow which auto-selects the given workspace or errors if the user isn't a member of it. This PR is large but a large % of it is tests, wiring, and required formatting changes. API login with chatgpt forced <img width="1592" height="116" alt="CleanShot 2025-10-19 at 22 40 04" src="https://github.com/user-attachments/assets/560c6bb4-a20a-4a37-95af-93df39d057dd" /> ChatGPT login with api forced <img width="1018" height="100" alt="CleanShot 2025-10-19 at 22 40 29" src="https://github.com/user-attachments/assets/d010bbbb-9c8d-4227-9eda-e55bf043b4af" /> Onboarding with api forced <img width="892" height="460" alt="CleanShot 2025-10-19 at 22 41 02" src="https://github.com/user-attachments/assets/cc0ed45c-b257-4d62-a32e-6ca7514b5edd" /> Onboarding with ChatGPT forced <img width="1154" height="426" alt="CleanShot 2025-10-19 at 22 41 27" src="https://github.com/user-attachments/assets/41c41417-dc68-4bb4-b3e7-3b7769f7e6a1" /> Logging in with the wrong workspace <img width="2222" height="84" alt="CleanShot 2025-10-19 at 22 42 31" src="https://github.com/user-attachments/assets/0ff4222c-f626-4dd3-b035-0b7fe998a046" />
1 parent d01f91e commit d87f87e

File tree

19 files changed

+920
-66
lines changed

19 files changed

+920
-66
lines changed

codex-rs/app-server-protocol/src/protocol.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::JSONRPCNotification;
55
use crate::JSONRPCRequest;
66
use crate::RequestId;
77
use codex_protocol::ConversationId;
8+
use codex_protocol::config_types::ForcedLoginMethod;
89
use codex_protocol::config_types::ReasoningEffort;
910
use codex_protocol::config_types::ReasoningSummary;
1011
use codex_protocol::config_types::SandboxMode;
@@ -473,6 +474,11 @@ pub struct UserSavedConfig {
473474
#[serde(skip_serializing_if = "Option::is_none")]
474475
pub sandbox_settings: Option<SandboxSettings>,
475476

477+
#[serde(skip_serializing_if = "Option::is_none")]
478+
pub forced_chatgpt_workspace_id: Option<String>,
479+
#[serde(skip_serializing_if = "Option::is_none")]
480+
pub forced_login_method: Option<ForcedLoginMethod>,
481+
476482
/// Model-specific configuration
477483
#[serde(skip_serializing_if = "Option::is_none")]
478484
pub model: Option<String>,

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ use codex_login::ServerOptions as LoginServerOptions;
8484
use codex_login::ShutdownHandle;
8585
use codex_login::run_login_server;
8686
use codex_protocol::ConversationId;
87+
use codex_protocol::config_types::ForcedLoginMethod;
8788
use codex_protocol::models::ContentItem;
8889
use codex_protocol::models::ResponseItem;
8990
use codex_protocol::protocol::InputMessageKind;
@@ -243,6 +244,19 @@ impl CodexMessageProcessor {
243244
}
244245

245246
async fn login_api_key(&mut self, request_id: RequestId, params: LoginApiKeyParams) {
247+
if matches!(
248+
self.config.forced_login_method,
249+
Some(ForcedLoginMethod::Chatgpt)
250+
) {
251+
let error = JSONRPCErrorError {
252+
code: INVALID_REQUEST_ERROR_CODE,
253+
message: "API key login is disabled. Use ChatGPT login instead.".to_string(),
254+
data: None,
255+
};
256+
self.outgoing.send_error(request_id, error).await;
257+
return;
258+
}
259+
246260
{
247261
let mut guard = self.active_login.lock().await;
248262
if let Some(active) = guard.take() {
@@ -278,9 +292,23 @@ impl CodexMessageProcessor {
278292
async fn login_chatgpt(&mut self, request_id: RequestId) {
279293
let config = self.config.as_ref();
280294

295+
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
296+
let error = JSONRPCErrorError {
297+
code: INVALID_REQUEST_ERROR_CODE,
298+
message: "ChatGPT login is disabled. Use API key login instead.".to_string(),
299+
data: None,
300+
};
301+
self.outgoing.send_error(request_id, error).await;
302+
return;
303+
}
304+
281305
let opts = LoginServerOptions {
282306
open_browser: false,
283-
..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())
307+
..LoginServerOptions::new(
308+
config.codex_home.clone(),
309+
CLIENT_ID.to_string(),
310+
config.forced_chatgpt_workspace_id.clone(),
311+
)
284312
};
285313

286314
enum LoginChatGptReply {

codex-rs/app-server/tests/suite/auth.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use app_test_support::to_response;
55
use codex_app_server_protocol::AuthMode;
66
use codex_app_server_protocol::GetAuthStatusParams;
77
use codex_app_server_protocol::GetAuthStatusResponse;
8+
use codex_app_server_protocol::JSONRPCError;
89
use codex_app_server_protocol::JSONRPCResponse;
910
use codex_app_server_protocol::LoginApiKeyParams;
1011
use codex_app_server_protocol::LoginApiKeyResponse;
@@ -57,6 +58,19 @@ sandbox_mode = "danger-full-access"
5758
)
5859
}
5960

61+
fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> {
62+
let config_toml = codex_home.join("config.toml");
63+
let contents = format!(
64+
r#"
65+
model = "mock-model"
66+
approval_policy = "never"
67+
sandbox_mode = "danger-full-access"
68+
forced_login_method = "{forced_method}"
69+
"#
70+
);
71+
std::fs::write(config_toml, contents)
72+
}
73+
6074
async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) {
6175
let request_id = mcp
6276
.send_login_api_key_request(LoginApiKeyParams {
@@ -221,3 +235,38 @@ async fn get_auth_status_with_api_key_no_include_token() {
221235
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
222236
assert!(status.auth_token.is_none(), "token must be omitted");
223237
}
238+
239+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
240+
async fn login_api_key_rejected_when_forced_chatgpt() {
241+
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
242+
create_config_toml_forced_login(codex_home.path(), "chatgpt")
243+
.unwrap_or_else(|err| panic!("write config.toml: {err}"));
244+
245+
let mut mcp = McpProcess::new(codex_home.path())
246+
.await
247+
.expect("spawn mcp process");
248+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
249+
.await
250+
.expect("init timeout")
251+
.expect("init failed");
252+
253+
let request_id = mcp
254+
.send_login_api_key_request(LoginApiKeyParams {
255+
api_key: "sk-test-key".to_string(),
256+
})
257+
.await
258+
.expect("send loginApiKey");
259+
260+
let err: JSONRPCError = timeout(
261+
DEFAULT_READ_TIMEOUT,
262+
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
263+
)
264+
.await
265+
.expect("loginApiKey error timeout")
266+
.expect("loginApiKey error");
267+
268+
assert_eq!(
269+
err.error.message,
270+
"API key login is disabled. Use ChatGPT login instead."
271+
);
272+
}

codex-rs/app-server/tests/suite/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use codex_app_server_protocol::SandboxSettings;
1111
use codex_app_server_protocol::Tools;
1212
use codex_app_server_protocol::UserSavedConfig;
1313
use codex_core::protocol::AskForApproval;
14+
use codex_protocol::config_types::ForcedLoginMethod;
1415
use codex_protocol::config_types::ReasoningEffort;
1516
use codex_protocol::config_types::ReasoningSummary;
1617
use codex_protocol::config_types::SandboxMode;
@@ -33,6 +34,8 @@ model_reasoning_summary = "detailed"
3334
model_reasoning_effort = "high"
3435
model_verbosity = "medium"
3536
profile = "test"
37+
forced_chatgpt_workspace_id = "12345678-0000-0000-0000-000000000000"
38+
forced_login_method = "chatgpt"
3639
3740
[sandbox_workspace_write]
3841
writable_roots = ["/tmp"]
@@ -92,6 +95,8 @@ async fn get_config_toml_parses_all_fields() {
9295
exclude_tmpdir_env_var: Some(true),
9396
exclude_slash_tmp: Some(true),
9497
}),
98+
forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()),
99+
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
95100
model: Some("gpt-5-codex".into()),
96101
model_reasoning_effort: Some(ReasoningEffort::High),
97102
model_reasoning_summary: Some(ReasoningSummary::Detailed),
@@ -149,6 +154,8 @@ async fn get_config_toml_empty() {
149154
approval_policy: None,
150155
sandbox_mode: None,
151156
sandbox_settings: None,
157+
forced_chatgpt_workspace_id: None,
158+
forced_login_method: None,
152159
model: None,
153160
model_reasoning_effort: None,
154161
model_reasoning_summary: None,

codex-rs/app-server/tests/suite/login.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use codex_app_server_protocol::CancelLoginChatGptParams;
77
use codex_app_server_protocol::CancelLoginChatGptResponse;
88
use codex_app_server_protocol::GetAuthStatusParams;
99
use codex_app_server_protocol::GetAuthStatusResponse;
10+
use codex_app_server_protocol::JSONRPCError;
1011
use codex_app_server_protocol::JSONRPCResponse;
1112
use codex_app_server_protocol::LoginChatGptResponse;
1213
use codex_app_server_protocol::LogoutChatGptResponse;
@@ -144,3 +145,97 @@ async fn login_and_cancel_chatgpt() {
144145
eprintln!("warning: did not observe login_chat_gpt_complete notification after cancel");
145146
}
146147
}
148+
149+
fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> {
150+
let config_toml = codex_home.join("config.toml");
151+
let contents = format!(
152+
r#"
153+
model = "mock-model"
154+
approval_policy = "never"
155+
sandbox_mode = "danger-full-access"
156+
forced_login_method = "{forced_method}"
157+
"#
158+
);
159+
std::fs::write(config_toml, contents)
160+
}
161+
162+
fn create_config_toml_forced_workspace(
163+
codex_home: &Path,
164+
workspace_id: &str,
165+
) -> std::io::Result<()> {
166+
let config_toml = codex_home.join("config.toml");
167+
let contents = format!(
168+
r#"
169+
model = "mock-model"
170+
approval_policy = "never"
171+
sandbox_mode = "danger-full-access"
172+
forced_chatgpt_workspace_id = "{workspace_id}"
173+
"#
174+
);
175+
std::fs::write(config_toml, contents)
176+
}
177+
178+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
179+
async fn login_chatgpt_rejected_when_forced_api() {
180+
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
181+
create_config_toml_forced_login(codex_home.path(), "api")
182+
.unwrap_or_else(|err| panic!("write config.toml: {err}"));
183+
184+
let mut mcp = McpProcess::new(codex_home.path())
185+
.await
186+
.expect("spawn mcp process");
187+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
188+
.await
189+
.expect("init timeout")
190+
.expect("init failed");
191+
192+
let request_id = mcp
193+
.send_login_chat_gpt_request()
194+
.await
195+
.expect("send loginChatGpt");
196+
let err: JSONRPCError = timeout(
197+
DEFAULT_READ_TIMEOUT,
198+
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
199+
)
200+
.await
201+
.expect("loginChatGpt error timeout")
202+
.expect("loginChatGpt error");
203+
204+
assert_eq!(
205+
err.error.message,
206+
"ChatGPT login is disabled. Use API key login instead."
207+
);
208+
}
209+
210+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
211+
async fn login_chatgpt_includes_forced_workspace_query_param() {
212+
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
213+
create_config_toml_forced_workspace(codex_home.path(), "ws-forced")
214+
.unwrap_or_else(|err| panic!("write config.toml: {err}"));
215+
216+
let mut mcp = McpProcess::new(codex_home.path())
217+
.await
218+
.expect("spawn mcp process");
219+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
220+
.await
221+
.expect("init timeout")
222+
.expect("init failed");
223+
224+
let request_id = mcp
225+
.send_login_chat_gpt_request()
226+
.await
227+
.expect("send loginChatGpt");
228+
let resp: JSONRPCResponse = timeout(
229+
DEFAULT_READ_TIMEOUT,
230+
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
231+
)
232+
.await
233+
.expect("loginChatGpt timeout")
234+
.expect("loginChatGpt response");
235+
236+
let login: LoginChatGptResponse = to_response(resp).expect("deserialize login resp");
237+
assert!(
238+
login.auth_url.contains("allowed_workspace_id=ws-forced"),
239+
"auth URL should include forced workspace"
240+
);
241+
}

codex-rs/cli/src/login.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,20 @@ use codex_core::config::ConfigOverrides;
99
use codex_login::ServerOptions;
1010
use codex_login::run_device_code_login;
1111
use codex_login::run_login_server;
12+
use codex_protocol::config_types::ForcedLoginMethod;
1213
use std::io::IsTerminal;
1314
use std::io::Read;
1415
use std::path::PathBuf;
1516

16-
pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
17-
let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string());
17+
pub async fn login_with_chatgpt(
18+
codex_home: PathBuf,
19+
forced_chatgpt_workspace_id: Option<String>,
20+
) -> std::io::Result<()> {
21+
let opts = ServerOptions::new(
22+
codex_home,
23+
CLIENT_ID.to_string(),
24+
forced_chatgpt_workspace_id,
25+
);
1826
let server = run_login_server(opts)?;
1927

2028
eprintln!(
@@ -28,7 +36,14 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
2836
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
2937
let config = load_config_or_exit(cli_config_overrides).await;
3038

31-
match login_with_chatgpt(config.codex_home).await {
39+
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
40+
eprintln!("ChatGPT login is disabled. Use API key login instead.");
41+
std::process::exit(1);
42+
}
43+
44+
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
45+
46+
match login_with_chatgpt(config.codex_home, forced_chatgpt_workspace_id).await {
3247
Ok(_) => {
3348
eprintln!("Successfully logged in");
3449
std::process::exit(0);
@@ -46,6 +61,11 @@ pub async fn run_login_with_api_key(
4661
) -> ! {
4762
let config = load_config_or_exit(cli_config_overrides).await;
4863

64+
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) {
65+
eprintln!("API key login is disabled. Use ChatGPT login instead.");
66+
std::process::exit(1);
67+
}
68+
4969
match login_with_api_key(&config.codex_home, &api_key) {
5070
Ok(_) => {
5171
eprintln!("Successfully logged in");
@@ -92,9 +112,15 @@ pub async fn run_login_with_device_code(
92112
client_id: Option<String>,
93113
) -> ! {
94114
let config = load_config_or_exit(cli_config_overrides).await;
115+
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
116+
eprintln!("ChatGPT login is disabled. Use API key login instead.");
117+
std::process::exit(1);
118+
}
119+
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
95120
let mut opts = ServerOptions::new(
96121
config.codex_home,
97122
client_id.unwrap_or(CLIENT_ID.to_string()),
123+
forced_chatgpt_workspace_id,
98124
);
99125
if let Some(iss) = issuer_base_url {
100126
opts.issuer = iss;

0 commit comments

Comments
 (0)