From c74a2f31df4c45cb73bd64ce3fb847cecefd3859 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 21 Apr 2026 15:59:11 -0700 Subject: [PATCH] feat: load agent identity from jwt env --- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 4 +- codex-rs/agent-identity/src/lib.rs | 67 ++++++++++++ codex-rs/cli/src/lib.rs | 2 + codex-rs/cli/src/login.rs | 64 ++++++++++-- codex-rs/cli/src/main.rs | 19 +++- codex-rs/cli/tests/login.rs | 74 ++++++++++++++ codex-rs/deny.toml | 1 - codex-rs/login/src/auth/auth_tests.rs | 125 ++++++++++++++++++++++- codex-rs/login/src/auth/manager.rs | 40 +++++++- codex-rs/login/src/auth/storage.rs | 19 +++- codex-rs/login/src/auth/storage_tests.rs | 59 +++++++++-- codex-rs/login/src/lib.rs | 3 + 13 files changed, 446 insertions(+), 33 deletions(-) create mode 100644 codex-rs/cli/tests/login.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index eda359e2a0..d5218899ff 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1435,7 +1435,7 @@ "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", - "rustls-webpki_0.103.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", + "rustls-webpki_0.103.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", "rustyline_14.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.2\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"default_features\":false,\"name\":\"buffer-redux\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"clipboard-win\",\"req\":\"^5.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fd-lock\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"home\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"fs\",\"ioctl\",\"poll\",\"signal\",\"term\"],\"name\":\"nix\",\"req\":\"^0.28\",\"target\":\"cfg(unix)\"},{\"name\":\"radix_trie\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"bundled\",\"backup\"],\"name\":\"rusqlite\",\"optional\":true,\"req\":\"^0.31.0\"},{\"name\":\"rustyline-derive\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"signal-hook\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"skim\",\"optional\":true,\"req\":\"^0.10\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"name\":\"termios\",\"optional\":true,\"req\":\"^0.3.3\",\"target\":\"cfg(unix)\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.0\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"},{\"name\":\"utf8parse\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Security\",\"Win32_System_Threading\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.52.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"case_insensitive_history_search\":[\"regex\"],\"custom-bindings\":[\"radix_trie\"],\"default\":[\"custom-bindings\",\"with-dirs\",\"with-file-history\"],\"derive\":[\"rustyline-derive\"],\"with-dirs\":[\"home\"],\"with-file-history\":[\"fd-lock\"],\"with-fuzzy\":[\"skim\"],\"with-sqlite-history\":[\"rusqlite\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0aed75c7cb..b116dade41 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -10773,9 +10773,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index a6d7e25dfd..5240342c74 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -8,6 +8,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::SecondsFormat; use chrono::Utc; +use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::SessionSource; use crypto_box::SecretKey as Curve25519SecretKey; use ed25519_dalek::Signer as _; @@ -19,6 +20,7 @@ use rand::TryRngCore; use rand::rngs::OsRng; use serde::Deserialize; use serde::Serialize; +use serde::de::DeserializeOwned; use sha2::Digest as _; use sha2::Sha512; @@ -50,6 +52,18 @@ pub struct GeneratedAgentKeyMaterial { pub public_key_ssh: String, } +/// Claims carried by an Agent Identity JWT. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct AgentIdentityJwtClaims { + pub agent_runtime_id: String, + pub agent_private_key: String, + pub account_id: String, + pub chatgpt_user_id: String, + pub email: String, + pub plan_type: AccountPlanType, + pub chatgpt_account_is_fedramp: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] struct AgentAssertionEnvelope { agent_runtime_id: String, @@ -98,6 +112,10 @@ pub fn authorization_header_for_agent_task( Ok(format!("AgentAssertion {serialized_assertion}")) } +pub fn decode_agent_identity_jwt(jwt: &str) -> Result { + decode_jwt_payload(jwt).context("failed to decode agent identity JWT") +} + pub fn sign_task_registration_payload( key: AgentIdentityKey<'_>, timestamp: &str, @@ -295,6 +313,19 @@ fn serialize_agent_assertion(envelope: &AgentAssertionEnvelope) -> Result(jwt: &str) -> Result { + let mut parts = jwt.split('.'); + let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => anyhow::bail!("invalid JWT format"), + }; + + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_b64) + .context("JWT payload is not valid base64url")?; + serde_json::from_slice(&payload_bytes).context("JWT payload is not valid JSON") +} + fn curve25519_secret_key_from_signing_key(signing_key: &SigningKey) -> Curve25519SecretKey { let digest = Sha512::digest(signing_key.to_bytes()); let mut secret_key = [0u8; 32]; @@ -404,6 +435,34 @@ mod tests { ); } + #[test] + fn decode_agent_identity_jwt_reads_claims() { + let jwt = jwt_with_payload(serde_json::json!({ + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + + let claims = decode_agent_identity_jwt(&jwt).expect("JWT should decode"); + + assert_eq!( + claims, + AgentIdentityJwtClaims { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + } + ); + } + #[test] fn normalize_chatgpt_base_url_strips_codex_before_backend_api() { assert_eq!( @@ -411,4 +470,12 @@ mod tests { "https://chatgpt.com/backend-api" ); } + + fn jwt_with_payload(payload: serde_json::Value) -> String { + let encode = |bytes: &[u8]| URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("payload should serialize")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } } diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index cac34b3b61..3f3448c64c 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -9,8 +9,10 @@ use codex_utils_cli::CliConfigOverrides; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; pub use debug_sandbox::run_command_under_windows; +pub use login::read_agent_identity_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; +pub use login::run_login_with_agent_identity; pub use login::run_login_with_api_key; pub use login::run_login_with_chatgpt; pub use login::run_login_with_device_code; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 42241aa933..4fa7272ae4 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -13,6 +13,7 @@ use codex_core::config::Config; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; +use codex_login::login_with_agent_identity; use codex_login::login_with_api_key; use codex_login::logout_with_revoke; use codex_login::run_device_code_login; @@ -34,6 +35,8 @@ const CHATGPT_LOGIN_DISABLED_MESSAGE: &str = "ChatGPT login is disabled. Use API key login instead."; const API_KEY_LOGIN_DISABLED_MESSAGE: &str = "API key login is disabled. Use ChatGPT login instead."; +const AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE: &str = + "Agent Identity login is disabled. Use API key login instead."; const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in"; /// Installs a small file-backed tracing layer for direct `codex login` flows. @@ -187,31 +190,74 @@ pub async fn run_login_with_api_key( } } +pub async fn run_login_with_agent_identity( + cli_config_overrides: CliConfigOverrides, + agent_identity: String, +) -> ! { + let config = load_config_or_exit(cli_config_overrides).await; + let _login_log_guard = init_login_file_logging(&config); + tracing::info!("starting agent identity login flow"); + + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("{AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } + + match login_with_agent_identity( + &config.codex_home, + &agent_identity, + config.cli_auth_credentials_store_mode, + ) { + Ok(_) => { + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error logging in with Agent Identity: {e}"); + std::process::exit(1); + } + } +} + pub fn read_api_key_from_stdin() -> String { + read_stdin_secret( + "--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`.", + "Reading API key from stdin...", + "No API key provided via stdin.", + ) +} + +pub fn read_agent_identity_from_stdin() -> String { + read_stdin_secret( + "--with-agent-identity expects the Agent Identity token on stdin. Try piping it, e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`.", + "Reading Agent Identity token from stdin...", + "No Agent Identity token provided via stdin.", + ) +} + +fn read_stdin_secret(terminal_message: &str, reading_message: &str, empty_message: &str) -> String { let mut stdin = std::io::stdin(); if stdin.is_terminal() { - eprintln!( - "--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`." - ); + eprintln!("{terminal_message}"); std::process::exit(1); } - eprintln!("Reading API key from stdin..."); + eprintln!("{reading_message}"); let mut buffer = String::new(); if let Err(err) = stdin.read_to_string(&mut buffer) { - eprintln!("Failed to read API key from stdin: {err}"); + eprintln!("Failed to read stdin: {err}"); std::process::exit(1); } - let api_key = buffer.trim().to_string(); - if api_key.is_empty() { - eprintln!("No API key provided via stdin."); + let secret = buffer.trim().to_string(); + if secret.is_empty() { + eprintln!("{empty_message}"); std::process::exit(1); } - api_key + secret } /// Login using the OAuth device code flow. diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9852a2cd5f..37d3ebd5b8 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,8 +10,10 @@ use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; use codex_cli::WindowsCommand; +use codex_cli::read_agent_identity_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; +use codex_cli::run_login_with_agent_identity; use codex_cli::run_login_with_api_key; use codex_cli::run_login_with_chatgpt; use codex_cli::run_login_with_device_code; @@ -347,6 +349,12 @@ struct LoginCommand { )] with_api_key: bool, + #[arg( + long = "with-agent-identity", + help = "Read the experimental Agent Identity token from stdin (e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`)" + )] + with_agent_identity: bool, + #[arg( long = "api-key", num_args = 0..=1, @@ -903,7 +911,12 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_login_status(login_cli.config_overrides).await; } None => { - if login_cli.use_device_code { + if login_cli.with_api_key && login_cli.with_agent_identity { + eprintln!( + "Choose one login credential source: --with-api-key or --with-agent-identity." + ); + std::process::exit(1); + } else if login_cli.use_device_code { run_login_with_device_code( login_cli.config_overrides, login_cli.issuer_base_url, @@ -918,6 +931,10 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; + } else if login_cli.with_agent_identity { + let agent_identity = read_agent_identity_from_stdin(); + run_login_with_agent_identity(login_cli.config_overrides, agent_identity) + .await; } else { run_login_with_chatgpt(login_cli.config_overrides).await; } diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs new file mode 100644 index 0000000000..49488adc02 --- /dev/null +++ b/codex-rs/cli/tests/login.rs @@ -0,0 +1,74 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::Value; +use tempfile::TempDir; + +const FAKE_AGENT_IDENTITY_JWT: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhZ2VudF9ydW50aW1lX2lkIjoiYWdlbnQtcnVudGltZS1pZCIsImFnZW50X3ByaXZhdGVfa2V5IjoicHJpdmF0ZS1rZXkiLCJhY2NvdW50X2lkIjoiYWNjb3VudC0xMjMiLCJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyLWlkIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGxhbl90eXBlIjoicHJvIiwiY2hhdGdwdF9hY2NvdW50X2lzX2ZlZHJhbXAiOmZhbHNlfQ.c2ln"; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +fn write_file_auth_config(codex_home: &Path) -> Result<()> { + std::fs::write( + codex_home.join("config.toml"), + "cli_auth_credentials_store = \"file\"\n", + )?; + Ok(()) +} + +fn read_auth_json(codex_home: &Path) -> Result { + let auth_json = std::fs::read_to_string(codex_home.join("auth.json"))?; + Ok(serde_json::from_str(&auth_json)?) +} + +#[test] +fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args([ + "-c", + "forced_login_method=\"api\"", + "login", + "--with-api-key", + ]) + .write_stdin("sk-test\n") + .assert() + .success() + .stderr(contains("Successfully logged in")); + + let auth = read_auth_json(codex_home.path())?; + assert_eq!(auth["OPENAI_API_KEY"], "sk-test"); + assert!(auth.get("tokens").is_none()); + assert!(auth.get("agent_identity").is_none()); + + Ok(()) +} + +#[test] +fn login_with_agent_identity_reads_stdin_and_writes_auth_json() -> Result<()> { + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["login", "--with-agent-identity"]) + .write_stdin(format!("{FAKE_AGENT_IDENTITY_JWT}\n")) + .assert() + .success() + .stderr(contains("Successfully logged in")); + + let auth = read_auth_json(codex_home.path())?; + assert_eq!(auth["auth_mode"], "agentIdentity"); + assert_eq!(auth["agent_identity"], FAKE_AGENT_IDENTITY_JWT); + assert!(auth["OPENAI_API_KEY"].is_null()); + assert!(auth.get("tokens").is_none()); + + Ok(()) +} diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index bed4a4f2fa..b153ba80a8 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -78,7 +78,6 @@ ignore = [ # TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities. { id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, - { id = "RUSTSEC-2026-0097", reason = "rand 0.8.5 is pulled in via age v0.11.2/codex-secrets and zbus v4.4.0/keyring; no compatible rand 0.8 fixed release, remove when transitive dependencies move to rand >=0.9.3" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 6f17822e77..08f3c5ca07 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -78,6 +78,44 @@ fn login_with_api_key_overwrites_existing_auth_json() { assert!(auth.tokens.is_none(), "tokens should be cleared"); } +#[test] +fn login_with_agent_identity_writes_only_token() { + let dir = tempdir().unwrap(); + let auth_path = dir.path().join("auth.json"); + let record = agent_identity_record("account-123"); + let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); + + super::login_with_agent_identity(dir.path(), &agent_identity, AuthCredentialsStoreMode::File) + .expect("login_with_agent_identity should succeed"); + + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); + assert_eq!(auth.auth_mode, Some(AuthMode::AgentIdentity)); + assert_eq!( + auth.agent_identity.as_deref(), + Some(agent_identity.as_str()) + ); + assert!(auth.tokens.is_none(), "tokens should be cleared"); + assert!(auth.openai_api_key.is_none(), "API key should be cleared"); +} + +#[test] +fn login_with_agent_identity_rejects_invalid_jwt() { + let dir = tempdir().unwrap(); + + let err = + super::login_with_agent_identity(dir.path(), "not-a-jwt", AuthCredentialsStoreMode::File) + .expect_err("invalid Agent Identity token should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::Other); + assert!( + !get_auth_file(dir.path()).exists(), + "invalid Agent Identity token should not write auth.json" + ); +} + #[test] fn missing_auth_json_returns_none() { let dir = tempdir().unwrap(); @@ -87,7 +125,7 @@ fn missing_auth_json_returns_none() { } #[tokio::test] -#[serial(codex_api_key)] +#[serial(codex_auth_env)] async fn pro_account_with_no_api_key_uses_chatgpt_auth() { let codex_home = tempdir().unwrap(); let fake_jwt = write_auth_file( @@ -143,7 +181,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { } #[tokio::test] -#[serial(codex_api_key)] +#[serial(codex_auth_env)] async fn loads_api_key_from_auth_json() { let dir = tempdir().unwrap(); let auth_file = dir.path().join("auth.json"); @@ -581,7 +619,54 @@ impl Drop for EnvVarGuard { } } +#[test] +#[serial(codex_auth_env)] +fn load_auth_reads_agent_identity_from_env() { + let codex_home = tempdir().unwrap(); + let expected_record = agent_identity_record("account-123"); + let agent_identity = fake_agent_identity_jwt(&expected_record).expect("fake agent identity"); + let _agent_guard = EnvVarGuard::set(CODEX_AGENT_IDENTITY_ENV_VAR, &agent_identity); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ) + .expect("env auth should load") + .expect("env auth should be present"); + + let CodexAuth::AgentIdentity(agent_identity) = auth else { + panic!("env auth should load as agent identity"); + }; + assert_eq!(agent_identity.record(), &expected_record); + assert!( + !get_auth_file(codex_home.path()).exists(), + "env auth should not write auth.json" + ); +} + +#[test] +#[serial(codex_auth_env)] +fn load_auth_keeps_codex_api_key_env_precedence() { + let codex_home = tempdir().unwrap(); + let record = agent_identity_record("account-123"); + let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); + let _agent_guard = EnvVarGuard::set(CODEX_AGENT_IDENTITY_ENV_VAR, &agent_identity); + let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ true, + AuthCredentialsStoreMode::File, + ) + .expect("env auth should load") + .expect("env auth should be present"); + + assert_eq!(auth.api_key(), Some("sk-env")); +} + #[tokio::test] +#[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_method_mismatch() { let codex_home = tempdir().unwrap(); login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) @@ -604,7 +689,7 @@ async fn enforce_login_restrictions_logs_out_for_method_mismatch() { } #[tokio::test] -#[serial(codex_api_key)] +#[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { let codex_home = tempdir().unwrap(); let _jwt = write_auth_file( @@ -634,7 +719,7 @@ async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { } #[tokio::test] -#[serial(codex_api_key)] +#[serial(codex_auth_env)] async fn enforce_login_restrictions_allows_matching_workspace() { let codex_home = tempdir().unwrap(); let _jwt = write_auth_file( @@ -662,6 +747,7 @@ async fn enforce_login_restrictions_allows_matching_workspace() { } #[tokio::test] +#[serial(codex_auth_env)] async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() { let codex_home = tempdir().unwrap(); @@ -683,7 +769,7 @@ async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_f } #[tokio::test] -#[serial(codex_api_key)] +#[serial(codex_auth_env)] async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() { let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); let codex_home = tempdir().unwrap(); @@ -703,6 +789,35 @@ async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() { ); } +fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord { + AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: account_id.to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + } +} + +fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result { + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload = json!({ + "agent_runtime_id": record.agent_runtime_id, + "agent_private_key": record.agent_private_key, + "account_id": record.account_id, + "chatgpt_user_id": record.chatgpt_user_id, + "email": record.email, + "plan_type": record.plan_type, + "chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp, + }); + let payload_b64 = encode(&serde_json::to_vec(&payload)?); + let signature_b64 = encode(b"sig"); + Ok(format!("{header_b64}.{payload_b64}.{signature_b64}")) +} + #[test] fn plan_type_maps_known_plan() { let codex_home = tempdir().unwrap(); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 419c6a4bac..b74f4e466f 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -207,12 +207,12 @@ impl CodexAuth { return Ok(Self::from_api_key(api_key)); } if auth_mode == ApiAuthMode::AgentIdentity { - let Some(record) = auth_dot_json.agent_identity else { + let Some(agent_identity) = auth_dot_json.agent_identity else { return Err(std::io::Error::other( - "agent identity auth is missing an agent identity record.", + "agent identity auth is missing an agent identity token.", )); }; - return Ok(Self::AgentIdentity(AgentIdentityAuth::new(record))); + return Self::from_agent_identity_jwt(&agent_identity); } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); @@ -245,6 +245,11 @@ impl CodexAuth { ) } + pub fn from_agent_identity_jwt(jwt: &str) -> std::io::Result { + let record = AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; + Ok(Self::AgentIdentity(AgentIdentityAuth::new(record))) + } + pub fn auth_mode(&self) -> AuthMode { match self { Self::ApiKey(_) => AuthMode::ApiKey, @@ -474,6 +479,7 @@ impl ChatgptAuth { pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; +pub const CODEX_AGENT_IDENTITY_ENV_VAR: &str = "CODEX_AGENT_IDENTITY"; pub fn read_openai_api_key_from_env() -> Option { env::var(OPENAI_API_KEY_ENV_VAR) @@ -489,6 +495,13 @@ pub fn read_codex_api_key_from_env() -> Option { .filter(|value| !value.is_empty()) } +pub fn read_codex_agent_identity_from_env() -> Option { + env::var(CODEX_AGENT_IDENTITY_ENV_VAR) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + /// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` /// if a file was removed, `Ok(false)` if no auth file was present. pub fn logout( @@ -529,6 +542,23 @@ pub fn login_with_api_key( save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } +/// Writes an `auth.json` that contains only the Agent Identity token. +pub fn login_with_agent_identity( + codex_home: &Path, + agent_identity: &str, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + AgentIdentityAuthRecord::from_agent_identity_jwt(agent_identity)?; + let auth_dot_json = AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(agent_identity.to_string()), + }; + save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) +} + /// Writes an in-memory auth payload for externally managed ChatGPT tokens. pub fn login_with_chatgpt_auth_tokens( codex_home: &Path, @@ -714,6 +744,10 @@ fn load_auth( return Ok(None); } + if let Some(agent_identity) = read_codex_agent_identity_from_env() { + return CodexAuth::from_agent_identity_jwt(&agent_identity).map(Some); + } + // Fall back to the configured persistent store (file/keyring/auto) for managed auth. let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); let auth_dot_json = match storage.load()? { diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index e2e8011698..cd0921f844 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -19,6 +19,7 @@ use std::sync::Mutex; use tracing::warn; use crate::token_data::TokenData; +use codex_agent_identity::decode_agent_identity_jwt; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_keyring_store::DefaultKeyringStore; @@ -42,7 +43,7 @@ pub struct AuthDotJson { pub last_refresh: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_identity: Option, + pub agent_identity: Option, } #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] @@ -56,6 +57,22 @@ pub struct AgentIdentityAuthRecord { pub chatgpt_account_is_fedramp: bool, } +impl AgentIdentityAuthRecord { + pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result { + let claims = decode_agent_identity_jwt(jwt).map_err(std::io::Error::other)?; + + Ok(Self { + agent_runtime_id: claims.agent_runtime_id, + agent_private_key: claims.agent_private_key, + account_id: claims.account_id, + chatgpt_user_id: claims.chatgpt_user_id, + email: claims.email, + plan_type: claims.plan_type, + chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp, + }) + } +} + pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { codex_home.join("auth.json") } diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index c06a8cfde4..625c838586 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -7,7 +7,6 @@ use serde_json::json; use tempfile::tempdir; use codex_keyring_store::tests::MockKeyringStore; -use codex_protocol::account::PlanType as AccountPlanType; use keyring::Error as KeyringError; #[tokio::test] @@ -59,20 +58,21 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let agent_identity = jwt_with_payload(json!({ + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); let auth_dot_json = AuthDotJson { auth_mode: Some(AuthMode::AgentIdentity), openai_api_key: None, tokens: None, last_refresh: None, - agent_identity: Some(AgentIdentityAuthRecord { - agent_runtime_id: "agent-runtime-id".to_string(), - agent_private_key: "private-key".to_string(), - account_id: "account-id".to_string(), - chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), - plan_type: AccountPlanType::Pro, - chatgpt_account_is_fedramp: false, - }), + agent_identity: Some(agent_identity), }; storage.save(&auth_dot_json)?; @@ -82,6 +82,37 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn file_storage_loads_agent_identity_as_jwt() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let agent_identity_jwt = jwt_with_payload(json!({ + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write( + &auth_file, + serde_json::to_string_pretty(&json!({ + "auth_mode": "agentIdentity", + "agent_identity": agent_identity_jwt, + }))?, + )?; + + let loaded = storage.load()?; + + assert_eq!( + loaded.expect("auth should load").agent_identity.as_deref(), + Some(agent_identity_jwt.as_str()) + ); + Ok(()) +} + #[test] fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { let dir = tempdir()?; @@ -217,6 +248,14 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson { } } +fn jwt_with_payload(payload: serde_json::Value) -> String { + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("payload should serialize")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} + #[test] fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index d69a77a97d..3049b6f6bc 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -22,6 +22,7 @@ pub use auth::AuthDotJson; pub use auth::AuthManager; pub use auth::AuthManagerConfig; pub use auth::CLIENT_ID; +pub use auth::CODEX_AGENT_IDENTITY_ENV_VAR; pub use auth::CODEX_API_KEY_ENV_VAR; pub use auth::CodexAuth; pub use auth::ExternalAuth; @@ -37,9 +38,11 @@ pub use auth::UnauthorizedRecovery; pub use auth::default_client; pub use auth::enforce_login_restrictions; pub use auth::load_auth_dot_json; +pub use auth::login_with_agent_identity; pub use auth::login_with_api_key; pub use auth::logout; pub use auth::logout_with_revoke; +pub use auth::read_codex_agent_identity_from_env; pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry;