@@ -20,8 +20,8 @@ use crate::errors::{Result, TraceDecayError};
2020
2121use super :: {
2222 load_json_file, load_json_file_strict, load_toml_file, safe_write_json_file,
23- safe_write_text_file, tool_names , write_toml_file, AgentIntegration , DoctorCounters ,
24- HealthcheckContext , InstallContext , InstallScope , UpdatePluginOutcome ,
23+ safe_write_text_file, write_toml_file, AgentIntegration , DoctorCounters , HealthcheckContext ,
24+ InstallContext , InstallScope , UpdatePluginOutcome ,
2525} ;
2626
2727/// `OpenAI` Codex CLI agent.
@@ -125,10 +125,10 @@ impl AgentIntegration for CodexIntegration {
125125 return Ok ( UpdatePluginOutcome :: Refreshed ( refreshed) ) ;
126126 }
127127
128- let target = if codex_plugin_manifest_path ( & ctx. home ) . exists ( ) {
128+ let target = if codex_plugin_manifest_path ( & ctx. home ) . exists ( )
129+ || codex_legacy_config_has_tracedecay ( & ctx. home )
130+ {
129131 Some ( plugin_dir. clone ( ) )
130- } else if Self :: has_legacy_config_install ( & ctx. home ) {
131- return Ok ( UpdatePluginOutcome :: ConfigOnly ) ;
132132 } else {
133133 None
134134 } ;
@@ -191,23 +191,20 @@ impl AgentIntegration for CodexIntegration {
191191
192192 fn healthcheck ( & self , dc : & mut DoctorCounters , ctx : & HealthcheckContext ) {
193193 eprintln ! ( "\n \x1b [1mCodex CLI integration\x1b [0m" ) ;
194- let local_codex_dir = ctx. project_path . join ( ".codex" ) ;
195194 let local_plugin_dir = codex_repo_plugin_install_dir ( & ctx. project_path ) ;
196195 if local_plugin_dir. join ( ".codex-plugin/plugin.json" ) . exists ( ) {
197- doctor_check_plugin_dir ( dc, & local_plugin_dir) ;
196+ doctor_check_plugin_dir (
197+ dc,
198+ & local_plugin_dir,
199+ Some ( & ctx. home . join ( ".codex/config.toml" ) ) ,
200+ ) ;
198201 doctor_check_marketplace_entry (
199202 dc,
200203 & codex_repo_marketplace_path ( & ctx. project_path ) ,
201204 "repo marketplace" ,
202205 "./plugins/tracedecay" ,
203206 "tracedecay install --local --agent codex" ,
204207 ) ;
205- } else if local_codex_dir. join ( "config.toml" ) . exists ( )
206- || local_codex_dir. join ( "hooks.json" ) . exists ( )
207- {
208- doctor_check_config ( dc, & local_codex_dir. join ( "config.toml" ) ) ;
209- doctor_check_prompt_file ( dc, & ctx. project_path . join ( "AGENTS.md" ) ) ;
210- doctor_check_hooks ( dc, & local_codex_dir. join ( "hooks.json" ) ) ;
211208 } else {
212209 doctor_check_plugin ( dc, & ctx. home ) ;
213210 }
@@ -228,29 +225,21 @@ impl AgentIntegration for CodexIntegration {
228225 }
229226
230227 fn has_tracedecay ( & self , home : & Path ) -> bool {
231- if !codex_plugin_cached_install_dirs ( home) . is_empty ( )
228+ !codex_plugin_cached_install_dirs ( home) . is_empty ( )
232229 || codex_plugin_manifest_path ( home) . exists ( )
233- {
234- return true ;
235- }
236- Self :: has_legacy_config_install ( home)
237230 }
238231}
239232
240- impl CodexIntegration {
241- fn has_legacy_config_install ( home : & Path ) -> bool {
242- let config = home. join ( ".codex" ) . join ( "config.toml" ) ;
243- if !config. exists ( ) {
244- return false ;
245- }
246- // If the file is unparseable, conservatively report "not installed"
247- // so the caller treats it like a fresh install path.
248- super :: load_toml_file ( & config) . is_ok_and ( |toml| {
249- toml. get ( "mcp_servers" )
250- . and_then ( |v| v. get ( "tracedecay" ) )
251- . is_some ( )
252- } )
233+ fn codex_legacy_config_has_tracedecay ( home : & Path ) -> bool {
234+ let config = home. join ( ".codex" ) . join ( "config.toml" ) ;
235+ if !config. exists ( ) {
236+ return false ;
253237 }
238+ super :: load_toml_file ( & config) . is_ok_and ( |toml| {
239+ toml. get ( "mcp_servers" )
240+ . and_then ( |v| v. get ( "tracedecay" ) )
241+ . is_some ( )
242+ } )
254243}
255244
256245// ---------------------------------------------------------------------------
@@ -577,6 +566,60 @@ const CODEX_MANAGED_HOOKS: &[CodexManagedHook] = &[
577566/// the current bundle no longer registers them.
578567const CODEX_LEGACY_HOOK_SUBCOMMANDS : & [ & str ] = & [ "hook-codex-pre-tool-use" ] ;
579568
569+ #[ derive( Debug , PartialEq , Eq ) ]
570+ enum CodexHookTrustState {
571+ Trusted ,
572+ Missing ( Vec < String > ) ,
573+ }
574+
575+ fn codex_hook_state_event_key ( event : & str ) -> String {
576+ let mut key = String :: new ( ) ;
577+ for ( index, ch) in event. chars ( ) . enumerate ( ) {
578+ if ch. is_ascii_uppercase ( ) && index > 0 {
579+ key. push ( '_' ) ;
580+ }
581+ key. push ( ch. to_ascii_lowercase ( ) ) ;
582+ }
583+ key
584+ }
585+
586+ fn codex_plugin_hook_trust_state ( config : & toml:: Value ) -> CodexHookTrustState {
587+ let Some ( state) = config
588+ . get ( "hooks" )
589+ . and_then ( |hooks| hooks. get ( "state" ) )
590+ . and_then ( |state| state. as_table ( ) )
591+ else {
592+ return CodexHookTrustState :: Missing (
593+ CODEX_MANAGED_HOOKS
594+ . iter ( )
595+ . map ( |hook| codex_hook_state_event_key ( hook. event ) )
596+ . collect ( ) ,
597+ ) ;
598+ } ;
599+
600+ let missing: Vec < String > = CODEX_MANAGED_HOOKS
601+ . iter ( )
602+ . map ( |hook| codex_hook_state_event_key ( hook. event ) )
603+ . filter ( |event_key| {
604+ let suffix = format ! ( ":hooks/hooks.json:{event_key}:0:0" ) ;
605+ !state. iter ( ) . any ( |( key, entry) | {
606+ key. starts_with ( "tracedecay@" )
607+ && key. ends_with ( & suffix)
608+ && entry
609+ . get ( "trusted_hash" )
610+ . and_then ( |hash| hash. as_str ( ) )
611+ . is_some_and ( |hash| hash. starts_with ( "sha256:" ) )
612+ } )
613+ } )
614+ . collect ( ) ;
615+
616+ if missing. is_empty ( ) {
617+ CodexHookTrustState :: Trusted
618+ } else {
619+ CodexHookTrustState :: Missing ( missing)
620+ }
621+ }
622+
580623fn codex_plugin_hooks ( raw : & str , tracedecay_bin : & str ) -> Result < String > {
581624 let mut hooks: serde_json:: Value = serde_json:: from_str ( raw) ?;
582625 for hook in CODEX_MANAGED_HOOKS {
@@ -1121,25 +1164,18 @@ fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path) {
11211164 let cached_dirs = codex_plugin_cached_install_dirs ( home) ;
11221165 if !cached_dirs. is_empty ( ) {
11231166 for plugin_dir in cached_dirs {
1124- doctor_check_plugin_dir ( dc, & plugin_dir) ;
1167+ doctor_check_plugin_dir ( dc, & plugin_dir, Some ( & home . join ( ".codex/config.toml" ) ) ) ;
11251168 }
11261169 return ;
11271170 }
11281171
11291172 let plugin_dir = codex_plugin_install_dir ( home) ;
11301173 let manifest_path = plugin_dir. join ( ".codex-plugin/plugin.json" ) ;
11311174 if !manifest_path. exists ( ) {
1132- if CodexIntegration :: has_legacy_config_install ( home) {
1133- doctor_check_config ( dc, & home. join ( ".codex/config.toml" ) ) ;
1134- dc. warn (
1135- "Codex uses a legacy config-managed tracedecay install — run `tracedecay install --agent codex` to install the Codex plugin bundle" ,
1136- ) ;
1137- } else {
1138- dc. warn ( & format ! (
1139- "{} not found — run `tracedecay install --agent codex` if you use Codex CLI" ,
1140- manifest_path. display( )
1141- ) ) ;
1142- }
1175+ dc. warn ( & format ! (
1176+ "{} not found — run `tracedecay install --agent codex` or `tracedecay update-plugin` to install the Codex plugin bundle" ,
1177+ manifest_path. display( )
1178+ ) ) ;
11431179 return ;
11441180 }
11451181
@@ -1181,7 +1217,11 @@ fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path) {
11811217 mcp_path. display( )
11821218 ) ) ;
11831219 }
1184- doctor_check_hooks ( dc, & plugin_dir. join ( "hooks/hooks.json" ) ) ;
1220+ doctor_check_hooks (
1221+ dc,
1222+ & plugin_dir. join ( "hooks/hooks.json" ) ,
1223+ Some ( & home. join ( ".codex/config.toml" ) ) ,
1224+ ) ;
11851225
11861226 doctor_check_marketplace_entry (
11871227 dc,
@@ -1231,7 +1271,7 @@ fn doctor_check_marketplace_entry(
12311271 }
12321272}
12331273
1234- fn doctor_check_plugin_dir ( dc : & mut DoctorCounters , plugin_dir : & Path ) {
1274+ fn doctor_check_plugin_dir ( dc : & mut DoctorCounters , plugin_dir : & Path , config_path : Option < & Path > ) {
12351275 let manifest_path = plugin_dir. join ( ".codex-plugin/plugin.json" ) ;
12361276 let manifest = load_json_file ( & manifest_path) ;
12371277 if manifest. get ( "name" ) . and_then ( |value| value. as_str ( ) ) == Some ( "tracedecay" ) {
@@ -1271,95 +1311,12 @@ fn doctor_check_plugin_dir(dc: &mut DoctorCounters, plugin_dir: &Path) {
12711311 mcp_path. display( )
12721312 ) ) ;
12731313 }
1274- doctor_check_hooks ( dc, & plugin_dir. join ( "hooks/hooks.json" ) ) ;
1275- }
1276-
1277- /// Check config.toml has tracedecay registered.
1278- fn doctor_check_config ( dc : & mut DoctorCounters , config_path : & Path ) {
1279- if !config_path. exists ( ) {
1280- dc. warn ( & format ! (
1281- "{} not found — run `tracedecay install --agent codex` if you use Codex CLI" ,
1282- config_path. display( )
1283- ) ) ;
1284- return ;
1285- }
1286-
1287- let config = match load_toml_file ( config_path) {
1288- Ok ( c) => c,
1289- Err ( e) => {
1290- dc. fail ( & format ! ( "{e}" ) ) ;
1291- return ;
1292- }
1293- } ;
1294- let has_server = config
1295- . get ( "mcp_servers" )
1296- . and_then ( |v| v. get ( "tracedecay" ) )
1297- . and_then ( |v| v. as_table ( ) )
1298- . is_some ( ) ;
1299-
1300- if !has_server {
1301- dc. fail ( & format ! (
1302- "MCP server NOT registered in {} — run `tracedecay install --agent codex`" ,
1303- config_path. display( )
1304- ) ) ;
1305- return ;
1306- }
1307- dc. pass ( & format ! (
1308- "MCP server registered in {}" ,
1309- config_path. display( )
1310- ) ) ;
1311-
1312- // Check tool auto-approval
1313- let tools = config
1314- . get ( "mcp_servers" )
1315- . and_then ( |v| v. get ( "tracedecay" ) )
1316- . and_then ( |v| v. get ( "tools" ) )
1317- . and_then ( |v| v. as_table ( ) ) ;
1318-
1319- let auto_count = tools. map_or ( 0 , |t| {
1320- t. values ( )
1321- . filter ( |v| v. get ( "approval_mode" ) . and_then ( |m| m. as_str ( ) ) == Some ( "auto" ) )
1322- . count ( )
1323- } ) ;
1324-
1325- let tools = tool_names ( ) ;
1326- let tools_len = tools. len ( ) ;
1327- if auto_count >= tools_len {
1328- dc. pass ( & format ! ( "All {tools_len} tools set to auto-approve" ) ) ;
1329- } else if auto_count > 0 {
1330- dc. warn ( & format ! (
1331- "{auto_count}/{tools_len} tools auto-approved — run `tracedecay install --agent codex` to update"
1332- ) ) ;
1333- } else {
1334- dc. warn ( "No tools auto-approved — Codex will prompt for each tool call" ) ;
1335- }
1336- }
1337-
1338- /// Check AGENTS.md contains tracedecay rules.
1339- fn doctor_check_prompt_file ( dc : & mut DoctorCounters , agents_md : & Path ) {
1340- if agents_md. exists ( ) {
1341- let has_rules = std:: fs:: read_to_string ( agents_md)
1342- . unwrap_or_default ( )
1343- . contains ( "tracedecay" ) ;
1344- if has_rules {
1345- dc. pass ( & format ! (
1346- "AGENTS.md contains tracedecay rules in {}" ,
1347- agents_md. display( )
1348- ) ) ;
1349- } else {
1350- dc. fail ( & format ! (
1351- "AGENTS.md missing tracedecay rules in {} — run `tracedecay install --local --agent codex` or `tracedecay install --agent codex`" ,
1352- agents_md. display( )
1353- ) ) ;
1354- }
1355- } else {
1356- dc. warn ( & format ! ( "{} does not exist" , agents_md. display( ) ) ) ;
1357- }
1314+ doctor_check_hooks ( dc, & plugin_dir. join ( "hooks/hooks.json" ) , config_path) ;
13581315}
13591316
1360- /// Check hooks.json registers the tracedecay lifecycle hooks, and remind the
1361- /// user that Codex requires trusting them via `/hooks` before they run .
1362- fn doctor_check_hooks ( dc : & mut DoctorCounters , hooks_path : & Path ) {
1317+ /// Check hooks.json registers the tracedecay lifecycle hooks, and report Codex
1318+ /// hook trust state when the user-level config is available .
1319+ fn doctor_check_hooks ( dc : & mut DoctorCounters , hooks_path : & Path , config_path : Option < & Path > ) {
13631320 if !hooks_path. exists ( ) {
13641321 dc. warn ( & format ! (
13651322 "{} not found — run `tracedecay install --agent codex` to add lifecycle hooks" ,
@@ -1380,9 +1337,22 @@ fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path) {
13801337 CODEX_MANAGED_HOOKS . len( ) ,
13811338 hooks_path. display( )
13821339 ) ) ;
1383- dc. info (
1384- "Codex skips new/changed command hooks until trusted — run `/hooks` in Codex to trust the tracedecay hooks" ,
1385- ) ;
1340+ match config_path. and_then ( |path| load_toml_file ( path) . ok ( ) . map ( |config| ( path, config) ) ) {
1341+ Some ( ( path, config) ) => match codex_plugin_hook_trust_state ( & config) {
1342+ CodexHookTrustState :: Trusted => dc. info ( & format ! (
1343+ "Codex hook trust entries recorded in {}" ,
1344+ path. display( )
1345+ ) ) ,
1346+ CodexHookTrustState :: Missing ( missing) => dc. info ( & format ! (
1347+ "Codex skips new/changed command hooks until trusted — missing trust for {} in {}; run `/hooks` in Codex" ,
1348+ missing. join( ", " ) ,
1349+ path. display( )
1350+ ) ) ,
1351+ } ,
1352+ None => dc. info (
1353+ "Codex skips new/changed command hooks until trusted — run `/hooks` in Codex to trust the tracedecay hooks" ,
1354+ ) ,
1355+ }
13861356 } else {
13871357 dc. warn ( & format ! (
13881358 "tracedecay hook(s) missing for {} in {} — run `tracedecay install --local --agent codex` or `tracedecay install --agent codex`" ,
@@ -1478,6 +1448,57 @@ mod tests {
14781448 assert ! ( !codex_native_memories_injection_enabled( & parse( "" ) ) ) ;
14791449 }
14801450
1451+ #[ test]
1452+ fn codex_hook_trust_state_reports_all_trusted_entries ( ) {
1453+ let config = r#"
1454+ [hooks.state]
1455+
1456+ [hooks.state."tracedecay@personal:hooks/hooks.json:post_tool_use:0:0"]
1457+ trusted_hash = "sha256:post"
1458+
1459+ [hooks.state."tracedecay@personal:hooks/hooks.json:session_start:0:0"]
1460+ trusted_hash = "sha256:session"
1461+
1462+ [hooks.state."tracedecay@personal:hooks/hooks.json:user_prompt_submit:0:0"]
1463+ trusted_hash = "sha256:prompt"
1464+
1465+ [hooks.state."tracedecay@personal:hooks/hooks.json:subagent_start:0:0"]
1466+ trusted_hash = "sha256:subagent"
1467+
1468+ [hooks.state."tracedecay@personal:hooks/hooks.json:post_compact:0:0"]
1469+ trusted_hash = "sha256:compact"
1470+ "# ;
1471+ let config = toml:: from_str :: < toml:: Value > ( config) . unwrap ( ) ;
1472+
1473+ assert_eq ! (
1474+ codex_plugin_hook_trust_state( & config) ,
1475+ CodexHookTrustState :: Trusted
1476+ ) ;
1477+ }
1478+
1479+ #[ test]
1480+ fn codex_hook_trust_state_reports_missing_entries ( ) {
1481+ let config = toml:: from_str :: < toml:: Value > (
1482+ r#"
1483+ [hooks.state]
1484+
1485+ [hooks.state."tracedecay@personal:hooks/hooks.json:post_tool_use:0:0"]
1486+ trusted_hash = "sha256:post"
1487+ "# ,
1488+ )
1489+ . unwrap ( ) ;
1490+
1491+ assert_eq ! (
1492+ codex_plugin_hook_trust_state( & config) ,
1493+ CodexHookTrustState :: Missing ( vec![
1494+ "session_start" . to_string( ) ,
1495+ "user_prompt_submit" . to_string( ) ,
1496+ "subagent_start" . to_string( ) ,
1497+ "post_compact" . to_string( ) ,
1498+ ] )
1499+ ) ;
1500+ }
1501+
14811502 #[ test]
14821503 fn remove_legacy_codex_native_automation_deletes_stale_record ( ) {
14831504 let home = tempfile:: tempdir ( ) . expect ( "tempdir should create" ) ;
0 commit comments