@@ -16,6 +16,9 @@ const AGENT_TASK_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15);
1616
1717#[ derive( Clone , Debug , PartialEq , Eq ) ]
1818pub ( crate ) struct RegisteredAgentTask {
19+ pub ( crate ) binding_id : String ,
20+ pub ( crate ) chatgpt_account_id : String ,
21+ pub ( crate ) chatgpt_user_id : Option < String > ,
1922 pub ( crate ) agent_runtime_id : String ,
2023 pub ( crate ) task_id : String ,
2124 pub ( crate ) registered_at : String ,
@@ -41,7 +44,18 @@ impl AgentIdentityManager {
4144 let Some ( binding) = self . current_binding ( ) . await else {
4245 return Ok ( None ) ;
4346 } ;
44- let Some ( stored_identity) = self . ensure_registered_identity ( ) . await ? else {
47+
48+ self . register_task_for_binding ( binding) . await
49+ }
50+
51+ async fn register_task_for_binding (
52+ & self ,
53+ binding : AgentIdentityBinding ,
54+ ) -> Result < Option < RegisteredAgentTask > > {
55+ let Some ( stored_identity) = self
56+ . ensure_registered_identity_for_binding ( & binding)
57+ . await ?
58+ else {
4559 return Ok ( None ) ;
4660 } ;
4761
@@ -72,6 +86,9 @@ impl AgentIdentityManager {
7286 . await
7387 . with_context ( || format ! ( "failed to parse agent task response from {url}" ) ) ?;
7488 let registered_task = RegisteredAgentTask {
89+ binding_id : stored_identity. binding_id . clone ( ) ,
90+ chatgpt_account_id : stored_identity. chatgpt_account_id . clone ( ) ,
91+ chatgpt_user_id : stored_identity. chatgpt_user_id . clone ( ) ,
7592 agent_runtime_id : stored_identity. agent_runtime_id . clone ( ) ,
7693 task_id : decrypt_task_id_response (
7794 & stored_identity,
@@ -93,6 +110,22 @@ impl AgentIdentityManager {
93110 }
94111}
95112
113+ impl RegisteredAgentTask {
114+ pub ( super ) fn matches_binding ( & self , binding : & AgentIdentityBinding ) -> bool {
115+ binding. matches_parts (
116+ & self . binding_id ,
117+ & self . chatgpt_account_id ,
118+ self . chatgpt_user_id . as_deref ( ) ,
119+ )
120+ }
121+
122+ pub ( crate ) fn has_same_binding ( & self , other : & Self ) -> bool {
123+ self . binding_id == other. binding_id
124+ && self . chatgpt_account_id == other. chatgpt_account_id
125+ && self . chatgpt_user_id == other. chatgpt_user_id
126+ }
127+ }
128+
96129fn sign_task_registration_payload (
97130 stored_identity : & StoredAgentIdentity ,
98131 timestamp : & str ,
@@ -240,6 +273,9 @@ mod tests {
240273 assert_eq ! (
241274 task,
242275 RegisteredAgentTask {
276+ binding_id: "chatgpt-account-account-123" . to_string( ) ,
277+ chatgpt_account_id: "account-123" . to_string( ) ,
278+ chatgpt_user_id: Some ( "user-123" . to_string( ) ) ,
243279 agent_runtime_id: "agent-123" . to_string( ) ,
244280 task_id: "task_123" . to_string( ) ,
245281 registered_at: task. registered_at. clone( ) ,
@@ -292,6 +328,95 @@ mod tests {
292328 assert_eq ! ( task. task_id, "task_fallback" ) ;
293329 }
294330
331+ #[ tokio:: test]
332+ async fn register_task_for_binding_keeps_one_auth_snapshot ( ) {
333+ let server = MockServer :: start ( ) . await ;
334+ mount_human_biscuit ( & server) . await ;
335+ let tempdir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
336+ let keyring_store = Arc :: new ( MockKeyringStore :: default ( ) ) ;
337+ let secrets_manager = SecretsManager :: new_with_keyring_store (
338+ tempdir. path ( ) . to_path_buf ( ) ,
339+ SecretsBackendKind :: Local ,
340+ keyring_store,
341+ ) ;
342+ let auth_manager =
343+ AuthManager :: from_auth_for_testing ( make_chatgpt_auth ( "account-456" , Some ( "user-456" ) ) ) ;
344+ let manager = AgentIdentityManager :: new_for_tests (
345+ auth_manager,
346+ /*feature_enabled*/ true ,
347+ server. uri ( ) ,
348+ SessionSource :: Cli ,
349+ secrets_manager. clone ( ) ,
350+ ) ;
351+ let stored_identity =
352+ seed_stored_identity ( & manager, & secrets_manager, "agent-123" , "account-123" ) ;
353+ let encrypted_task_id =
354+ encrypt_task_id_for_identity ( & stored_identity, "task_123" ) . expect ( "task ciphertext" ) ;
355+ let binding = AgentIdentityBinding :: from_auth (
356+ & make_chatgpt_auth ( "account-123" , Some ( "user-123" ) ) ,
357+ /*forced_workspace_id*/ None ,
358+ )
359+ . expect ( "binding" ) ;
360+
361+ Mock :: given ( method ( "POST" ) )
362+ . and ( path ( "/v1/agent/agent-123/task/register" ) )
363+ . and ( header ( "x-openai-authorization" , "human-biscuit" ) )
364+ . respond_with ( ResponseTemplate :: new ( 200 ) . set_body_json ( serde_json:: json!( {
365+ "encrypted_task_id" : encrypted_task_id,
366+ } ) ) )
367+ . expect ( 1 )
368+ . mount ( & server)
369+ . await ;
370+
371+ let task = manager
372+ . register_task_for_binding ( binding)
373+ . await
374+ . unwrap ( )
375+ . expect ( "task should be registered" ) ;
376+
377+ assert_eq ! (
378+ task,
379+ RegisteredAgentTask {
380+ binding_id: "chatgpt-account-account-123" . to_string( ) ,
381+ chatgpt_account_id: "account-123" . to_string( ) ,
382+ chatgpt_user_id: Some ( "user-123" . to_string( ) ) ,
383+ agent_runtime_id: "agent-123" . to_string( ) ,
384+ task_id: "task_123" . to_string( ) ,
385+ registered_at: task. registered_at. clone( ) ,
386+ }
387+ ) ;
388+ }
389+
390+ #[ tokio:: test]
391+ async fn task_matches_current_binding_rejects_stale_auth_binding ( ) {
392+ let tempdir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
393+ let keyring_store = Arc :: new ( MockKeyringStore :: default ( ) ) ;
394+ let secrets_manager = SecretsManager :: new_with_keyring_store (
395+ tempdir. path ( ) . to_path_buf ( ) ,
396+ SecretsBackendKind :: Local ,
397+ keyring_store,
398+ ) ;
399+ let auth_manager =
400+ AuthManager :: from_auth_for_testing ( make_chatgpt_auth ( "account-456" , Some ( "user-456" ) ) ) ;
401+ let manager = AgentIdentityManager :: new_for_tests (
402+ auth_manager,
403+ /*feature_enabled*/ true ,
404+ "https://chatgpt.com/backend-api/" . to_string ( ) ,
405+ SessionSource :: Cli ,
406+ secrets_manager,
407+ ) ;
408+ let task = RegisteredAgentTask {
409+ binding_id : "chatgpt-account-account-123" . to_string ( ) ,
410+ chatgpt_account_id : "account-123" . to_string ( ) ,
411+ chatgpt_user_id : Some ( "user-123" . to_string ( ) ) ,
412+ agent_runtime_id : "agent-123" . to_string ( ) ,
413+ task_id : "task_123" . to_string ( ) ,
414+ registered_at : "2026-03-23T12:00:00Z" . to_string ( ) ,
415+ } ;
416+
417+ assert ! ( !manager. task_matches_current_binding( & task) . await ) ;
418+ }
419+
295420 async fn mount_human_biscuit ( server : & MockServer ) {
296421 Mock :: given ( method ( "GET" ) )
297422 . and ( path ( "/authenticate_app_v2" ) )
0 commit comments