返回 CodeWhale
hooks.rs
根目录 / crates / tui / src / conformance / hooks.rs
1 //! Hook-receipt family: what a configured shell hook receives and what its
2 //! answer does, before hook orchestration moves to the host (Phase 2).
3 //!
4 //! A case is user hook configuration (the `[[hooks.hooks]]` shape, as JSON)
5 //! plus one tool call. `tool_call_before` cases run the engine's admission
6 //! fold (`turn_loop::run_tool_call_before_hooks`, the gate every frontend
7 //! shares); `tool_call_after` cases build the completion context the way the
8 //! TUI and Runtime API do (`HookContext::with_tool_outcome`) and run
9 //! `HookExecutor::execute`. Each hook command records its stdin and
10 //! environment under `{{capture}}`; the golden pins the verdict (or the
11 //! observer results), the schema-1 stdin document, and the documented
12 //! `DEEPSEEK_*` / `CODEWHALE_*` environment contract.
13 //!
14 //! POSIX shell fixtures: Unix only. The hook session id and temp paths are
15 //! masked.
16
17 use std::path::Path;
18 use std::sync::Arc;
19
20 use serde_json::{Value, json};
21
22 use codewhale_config::AppMode;
23
24 use super::golden::{self, Failures, Sandbox};
25 use crate::hooks::{HookContext, HookEvent, HookExecutor, HooksConfig};
26 use crate::tools::spec::{ToolError, ToolResult};
27
28 const FAMILY: &str = "hooks";
29
30 /// The environment keys `HookContext::to_env_vars` documents. Anything else a
31 /// hook child sees is inherited process state, not contract.
32 const HOOK_ENV_CONTRACT: &[&str] = &[
33 "CODEWHALE_SESSION_ID",
34 "CODEWHALE_TOOL_CALL_ID",
35 "DEEPSEEK_ERROR",
36 "DEEPSEEK_MESSAGE",
37 "DEEPSEEK_MODE",
38 "DEEPSEEK_MODEL",
39 "DEEPSEEK_PREVIOUS_MODE",
40 "DEEPSEEK_SESSION_COST",
41 "DEEPSEEK_SESSION_ID",
42 "DEEPSEEK_TOOL_ARGS",
43 "DEEPSEEK_TOOL_CALL_ID",
44 "DEEPSEEK_TOOL_EXECUTION_RECEIPT",
45 "DEEPSEEK_TOOL_EXIT_CODE",
46 "DEEPSEEK_TOOL_NAME",
47 "DEEPSEEK_TOOL_RESULT",
48 "DEEPSEEK_TOOL_STATUS",
49 "DEEPSEEK_TOOL_SUCCESS",
50 "DEEPSEEK_TOTAL_TOKENS",
51 "DEEPSEEK_WORKSPACE",
52 ];
53
54 fn shell_quote(path: &Path) -> String {
55 format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
56 }
57
58 /// Write the capture helper a fixture command invokes as `{{capture}} <name>`:
59 /// it saves the hook's stdin and every contract variable that is set, as
60 /// NUL-separated key/value pairs (values may span lines).
61 fn install_capture_helper(capture: &Path) -> String {
62 let script = capture.join("capture.sh");
63 let keys = HOOK_ENV_CONTRACT.join(" ");
64 std::fs::write(
65 &script,
66 format!(
67 "dir=$(dirname \"$0\")\n\
68 cat > \"$dir/$1.stdin\"\n\
69 for key in {keys}; do\n\
70 \x20 eval \"present=\\${{$key+x}}\"\n\
71 \x20 if [ -n \"$present\" ]; then eval \"value=\\${{$key}}\"; printf '%s\\0%s\\0' \"$key\" \"$value\"; fi\n\
72 done > \"$dir/$1.env\"\n"
73 ),
74 )
75 .expect("write capture helper");
76 format!("sh {}", shell_quote(&script))
77 }
78
79 fn hooks_config(case: &Value, capture_command: &str) -> HooksConfig {
80 let raw = serde_json::to_string(&case["hooks"]).expect("case.hooks");
81 // Substitute inside the JSON text, escaping the command for JSON.
82 let quoted = serde_json::to_string(capture_command).expect("quote");
83 let substituted = raw.replace("{{capture}}", &quoted[1..quoted.len() - 1]);
84 let hooks: Value = serde_json::from_str(&substituted).expect("substituted hooks");
85 serde_json::from_value(json!({ "hooks": hooks })).expect("case.hooks is HooksConfig")
86 }
87
88 fn read_capture(capture: &Path, hook: &str) -> Value {
89 let stdin = std::fs::read_to_string(capture.join(format!("{hook}.stdin")));
90 let env = std::fs::read(capture.join(format!("{hook}.env")));
91 let (Ok(stdin), Ok(env)) = (stdin, env) else {
92 return json!({ "name": hook, "ran": false });
93 };
94 let stdin = if stdin.trim().is_empty() {
95 Value::String(String::new())
96 } else {
97 serde_json::from_str::<Value>(&stdin).unwrap_or(Value::String(stdin))
98 };
99 let fields: Vec<String> = env
100 .split(|byte| *byte == 0)
101 .map(|field| String::from_utf8_lossy(field).into_owned())
102 .collect();
103 let env: serde_json::Map<String, Value> = fields
104 .as_chunks::<2>()
105 .0
106 .iter()
107 .filter(|pair| HOOK_ENV_CONTRACT.contains(&pair[0].as_str()))
108 .map(|pair| (pair[0].clone(), Value::String(pair[1].clone())))
109 .collect();
110 json!({ "name": hook, "ran": true, "stdin": stdin, "env": env })
111 }
112
113 fn tool_outcome(case: &Value) -> Result<ToolResult, ToolError> {
114 let outcome = &case["tool"]["outcome"];
115 if let Some(ok) = outcome.get("ok") {
116 return Ok(serde_json::from_value(ok.clone()).expect("tool.outcome.ok is a ToolResult"));
117 }
118 let failed = &outcome["execution_failed"];
119 let message = failed["message"]
120 .as_str()
121 .expect("execution_failed.message");
122 Err(match failed.get("metadata") {
123 Some(metadata) => ToolError::execution_failed_with_metadata(message, metadata.clone()),
124 None => ToolError::execution_failed(message),
125 })
126 }
127
128 fn run_case(name: &str, case: &Value, failures: &mut Failures) {
129 assert!(
130 case["hooks"]
131 .as_array()
132 .is_some_and(|hooks| !hooks.is_empty()),
133 "hook case must contain a hook"
134 );
135 let sandbox = Sandbox::new(case);
136 let capture = sandbox.workspace.join(".conformance-capture");
137 std::fs::create_dir_all(&capture).expect("capture dir");
138 let capture_command = install_capture_helper(&capture);
139 let executor = Arc::new(HookExecutor::new(
140 hooks_config(case, &capture_command),
141 sandbox.workspace.clone(),
142 ));
143 let tool = &case["tool"];
144 let tool_name = tool["name"].as_str().expect("tool.name");
145 let call_id = tool["call_id"].as_str().expect("tool.call_id");
146
147 let outcome = match case["event"].as_str().expect("case.event") {
148 "tool_call_before" => {
149 let runtime = tokio::runtime::Builder::new_current_thread()
150 .enable_all()
151 .build()
152 .expect("runtime");
153 let verdict =
154 runtime.block_on(crate::core::engine::turn_loop::run_tool_call_before_hooks(
155 Some(&executor),
156 None, // This surface has no TypeScript host attachment.
157 tool_name,
158 call_id,
159 &tool["input"],
160 AppMode::Agent,
161 &sandbox.workspace,
162 case["model"].as_str().unwrap_or("deepseek-v4-pro"),
163 ));
164 drop(runtime);
165 match verdict {
166 Ok(admitted) => json!({ "admit": {
167 "requires_approval": admitted.requires_approval,
168 "updated_input": admitted.updated_input,
169 "additional_context": admitted.additional_context,
170 } }),
171 Err(error) => json!({ "refuse": {
172 "kind": golden::tool_error_kind(&error),
173 "detail": error.to_string(),
174 } }),
175 }
176 }
177 "tool_call_after" => {
178 let context = HookContext::new()
179 .with_workspace(sandbox.workspace.clone())
180 .with_session_id(executor.session_id())
181 .with_tool_name(tool_name)
182 .with_tool_call_id(call_id)
183 .with_tool_outcome(&tool_outcome(case));
184 let results = executor.execute(HookEvent::ToolCallAfter, &context);
185 json!({ "observers": results.iter().map(|result| json!({
186 "name": result.name,
187 "success": result.success,
188 "exit_code": result.exit_code,
189 "background": result.background,
190 "stdout": result.stdout,
191 "stderr": result.stderr,
192 "error": result.error,
193 })).collect::<Vec<_>>() })
194 }
195 other => panic!("unknown hook event `{other}`"),
196 };
197
198 let hooks: Vec<Value> = case["hooks"]
199 .as_array()
200 .expect("case.hooks")
201 .iter()
202 .map(|hook| {
203 read_capture(
204 &capture,
205 hook["name"].as_str().expect("every hook is named"),
206 )
207 })
208 .collect();
209 let mut golden_value = json!({ "outcome": outcome, "hooks": hooks });
210 if !hooks.iter().any(|hook| hook["ran"] == true) {
211 failures.push(name, "no configured hook produced a capture");
212 return;
213 }
214 let mut masker = sandbox
215 .masker(&[])
216 .literal(executor.session_id(), "<HOOK_SESSION>");
217 masker.value(&mut golden_value);
218 failures.record(
219 name,
220 golden::check_golden(
221 &golden::family_dir(FAMILY).join(format!("{name}.golden.json")),
222 &golden::pretty(&golden::canonical(&golden_value)),
223 ),
224 );
225 }
226
227 #[test]
228 fn hook_receipts_match_goldens() {
229 let names = golden::case_names(FAMILY);
230 let mut failures = Failures::default();
231 for name in &names {
232 let case = golden::read_case(FAMILY, name);
233 run_case(name, &case, &mut failures);
234 }
235 failures.finish(FAMILY, names.len());
236 }
237
237 lines RUST