From 3ed4910d5f90272ddef4cef89e67872b630e9919 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Thu, 3 Sep 2026 14:34:57 -0700 Subject: [PATCH] windows: check the grok global-install manifests as JSON The Windows hook command carries the JSON-quoted launcher path, so the path's backslashes are escaped once inside the command and again by the manifest file itself. Read the manifest as JSON and look for either quoting form instead of counting escaping layers in a raw substring match. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- crates/skills/tests/drift_ports_tests.rs | 33 ++++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/skills/tests/drift_ports_tests.rs b/crates/skills/tests/drift_ports_tests.rs index 70a58ae87..7fc5f440f 100644 --- a/crates/skills/tests/drift_ports_tests.rs +++ b/crates/skills/tests/drift_ports_tests.rs @@ -465,13 +465,23 @@ fn inferred_home_rooted_updates_refresh_stale_or_missing_copilot_user_agents() { // ─── Grok project hooks on global installs (49571365, #642) ────────────────── -/// The launcher path the installer writes into a hook manifest, in the host's -/// path form and escaped the way it lands inside the JSON file (a Windows path -/// carries backslashes, which JSON doubles). -fn manifest_skill_path(home: &str, provider: &str) -> String { +/// True when a hook manifest names this provider's launcher. The file is read +/// as JSON so the check does not depend on how many escaping layers the host's +/// path form needs: a Windows command carries the JSON-quoted path (its +/// backslashes doubled), the sh form carries the path verbatim. +fn manifest_names_launcher(manifest: &str, home: &str, provider: &str) -> bool { + fn any_string(v: &Value, pred: &dyn Fn(&str) -> bool) -> bool { + match v { + Value::String(s) => pred(s), + Value::Array(a) => a.iter().any(|c| any_string(c, pred)), + Value::Object(o) => o.values().any(|c| any_string(c, pred)), + _ => false, + } + } let p = jsp::join(&[home, provider, "skills", "impeccable", "scripts", "impeccable"]); - let quoted = serde_json::to_string(&p).unwrap(); - quoted[1..quoted.len() - 1].to_string() + let escaped = p.replace('\\', "\\\\"); + let value: Value = serde_json::from_str(&read(manifest)).unwrap(); + any_string(&value, &|s: &str| s.contains(&p) || s.contains(&escaped)) } #[test] @@ -504,12 +514,13 @@ fn global_install_rewrites_grok_project_hooks_to_the_global_skill_path() { } // Launcher-era adaptation: the JS asserted the rewritten hook.mjs path; // the engine writes the launcher form pointing at the same global root. - assert!(read(&format!("{tmp}/.claude/settings.local.json")).contains(&manifest_skill_path(&home, ".claude"))); - assert!(read(&format!("{tmp}/.codex/hooks.json")).contains(&manifest_skill_path(&home, ".agents"))); - assert!(read(&format!("{tmp}/.cursor/hooks.json")).contains(&manifest_skill_path(&home, ".cursor"))); - let grok_hooks = read(&format!("{tmp}/.grok/hooks/impeccable.json")); + assert!(manifest_names_launcher(&format!("{tmp}/.claude/settings.local.json"), &home, ".claude")); + assert!(manifest_names_launcher(&format!("{tmp}/.codex/hooks.json"), &home, ".agents")); + assert!(manifest_names_launcher(&format!("{tmp}/.cursor/hooks.json"), &home, ".cursor")); + let grok_manifest = format!("{tmp}/.grok/hooks/impeccable.json"); + let grok_hooks = read(&grok_manifest); assert!( - grok_hooks.contains(&manifest_skill_path(&home, ".grok")), + manifest_names_launcher(&grok_manifest, &home, ".grok"), "grok hook not rewritten to the global skill path: {grok_hooks}" ); assert!(