diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c43ba358..d42aece1a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,10 +53,12 @@ quieter, and Fleet runs can be checked before they spend anything. - **[@cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)). - **[@BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)). - **[@Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance with its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)). +- **[@wuisabel-gif](https://github.com/wuisabel-gif)** — designed the `tool_call_after` execution-receipt contract and its tests on a reference branch, which landed re-implemented on the current hook seam ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), [#6713](https://github.com/Hmbown/Codewhale/pull/6713)). - **[@SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)). ### Added +- `tool_call_after` hooks for shell tools receive `DEEPSEEK_TOOL_EXECUTION_RECEIPT`: the command that actually ran after admission, its working directory, how it ended, and bounded stdout/stderr previews, so a hook can record exactly what executed ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), requested by [@wuisabel-gif](https://github.com/wuisabel-gif)). - Runtime API: turns now record what they produced. Each item and turn carries typed artifact references (path, kind, size, revision, and a restore point when file-revert would accept one) for files a tool wrote, diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 4467a60770..e69ddaa5d8 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -53,10 +53,12 @@ quieter, and Fleet runs can be checked before they spend anything. - **[@cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)). - **[@BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)). - **[@Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance with its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)). +- **[@wuisabel-gif](https://github.com/wuisabel-gif)** — designed the `tool_call_after` execution-receipt contract and its tests on a reference branch, which landed re-implemented on the current hook seam ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), [#6713](https://github.com/Hmbown/Codewhale/pull/6713)). - **[@SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)). ### Added +- `tool_call_after` hooks for shell tools receive `DEEPSEEK_TOOL_EXECUTION_RECEIPT`: the command that actually ran after admission, its working directory, how it ended, and bounded stdout/stderr previews, so a hook can record exactly what executed ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), requested by [@wuisabel-gif](https://github.com/wuisabel-gif)). - Runtime API: turns now record what they produced. Each item and turn carries typed artifact references (path, kind, size, revision, and a restore point when file-revert would accept one) for files a tool wrote, diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index c3a6c11eb4..d12d3af22e 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -31,9 +31,9 @@ pub use config::{ PROJECT_HOOKS_TEMPLATE, workspace_allows_project_hooks, }; pub(crate) use executor::{ - HOOK_CONTEXT_AGGREGATE_MAX_CHARS, HOOK_LABEL_MAX_CHARS, generic_unavailable_detail, - parse_tool_call_before_stdout, sanitize_hook_denial_reason, sanitize_hook_label, - sanitize_hook_line, sanitize_hook_text, + HOOK_CONTEXT_AGGREGATE_MAX_CHARS, HOOK_EXECUTION_RECEIPT_MAX_BYTES, HOOK_LABEL_MAX_CHARS, + generic_unavailable_detail, parse_tool_call_before_stdout, sanitize_hook_denial_reason, + sanitize_hook_label, sanitize_hook_line, sanitize_hook_text, }; #[cfg(test)] pub(crate) use executor::{ diff --git a/crates/tui/src/hooks/executor.rs b/crates/tui/src/hooks/executor.rs index 7930311fe6..f2de3a0ab8 100644 --- a/crates/tui/src/hooks/executor.rs +++ b/crates/tui/src/hooks/executor.rs @@ -47,6 +47,10 @@ pub struct HookContext { /// command usually has no exit code, so this is how a hook tells it apart /// from a tool that reported nothing. pub tool_status: Option, + /// Serialized post-admission shell execution receipt (#6689), exported as + /// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`. Complete JSON or absent — never a + /// truncated document — and at most [`HOOK_EXECUTION_RECEIPT_MAX_BYTES`]. + pub tool_execution_receipt: Option, /// Whether tool succeeded pub tool_success: Option, /// Current mode @@ -115,6 +119,7 @@ impl HookContext { }; let mut context = self.with_tool_result(&text, success, reported_tool_exit_code(result)); context.tool_status = reported_tool_status(result).map(str::to_string); + context.tool_execution_receipt = reported_tool_execution_receipt(result); context } @@ -179,6 +184,15 @@ impl HookContext { bound(&mut self.message, HOOK_MESSAGE_CONTEXT_MAX_BYTES); bound(&mut self.error_message, HOOK_ERROR_CONTEXT_MAX_BYTES); bound(&mut self.model, HOOK_OBSERVER_METADATA_MAX_BYTES); + // A receipt is complete JSON or nothing: truncating it would export a + // broken document, so an oversized one is dropped instead. + if self + .tool_execution_receipt + .as_ref() + .is_some_and(|receipt| receipt.len() > HOOK_EXECUTION_RECEIPT_MAX_BYTES) + { + self.tool_execution_receipt = None; + } if let Some(workspace) = self.workspace.take() { self.workspace = Some(PathBuf::from(truncate_env_value( &workspace.to_string_lossy(), @@ -225,6 +239,14 @@ impl HookContext { if let Some(ref status) = self.tool_status { env.insert("DEEPSEEK_TOOL_STATUS".to_string(), status.clone()); } + if let Some(ref receipt) = self.tool_execution_receipt + && receipt.len() <= HOOK_EXECUTION_RECEIPT_MAX_BYTES + { + env.insert( + "DEEPSEEK_TOOL_EXECUTION_RECEIPT".to_string(), + receipt.clone(), + ); + } if let Some(ref mode) = self.mode { env.insert("DEEPSEEK_MODE".to_string(), mode.clone()); } @@ -405,6 +427,12 @@ const HOOK_TOOL_ARGS_ENV_MAX_BYTES: usize = 10_000; /// Largest raw tool result retained in an observer job before enqueue. const HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES: usize = 10_000; +/// Largest serialized shell execution receipt exported through +/// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`. The shell tool fits its output previews +/// beneath this bound; the hook boundary drops anything larger rather than +/// truncate a JSON document. +pub(crate) const HOOK_EXECUTION_RECEIPT_MAX_BYTES: usize = 32 * 1024; + /// Largest error retained in an observer job before enqueue. const HOOK_ERROR_CONTEXT_MAX_BYTES: usize = 5_000; @@ -1435,6 +1463,9 @@ impl HookExecutor { // raw_arg: cmd.exe does not parse the CRT-style \" escapes that // Command::arg would insert, so pass the command line verbatim. cmd.arg("/C").raw_arg(command); + // Only this call's context may supply a receipt. In particular, + // a Codewhale launched from another hook must not inherit one. + cmd.env_remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT"); cmd } #[cfg(not(windows))] @@ -1446,6 +1477,9 @@ impl HookExecutor { use std::os::unix::process::CommandExt as _; cmd.process_group(0); } + // Only this call's context may supply a receipt. In particular, + // a Codewhale launched from another hook must not inherit one. + cmd.env_remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT"); cmd } } @@ -3011,6 +3045,24 @@ fn reported_tool_status( } } +/// Read the post-admission execution receipt a shell tool recorded (#6689), +/// serialized for `DEEPSEEK_TOOL_EXECUTION_RECEIPT`. +/// +/// Only a schema-1 object within the size bound counts. The receipt is built +/// by the shell tool from what its process manager recorded at spawn; it is +/// never reconstructed here from the before-hook input, which can differ from +/// what actually ran. +fn reported_tool_execution_receipt( + result: &Result, +) -> Option { + let receipt = reported_tool_metadata(result)?.get("execution_receipt")?; + if receipt.get("schema_version")?.as_u64()? != 1 { + return None; + } + let encoded = serde_json::to_string(receipt).ok()?; + (encoded.len() <= HOOK_EXECUTION_RECEIPT_MAX_BYTES).then_some(encoded) +} + /// Read the process exit code a tool reported, when it reported one. /// /// The one source for `DEEPSEEK_TOOL_EXIT_CODE`: the TUI and the Runtime API @@ -5617,6 +5669,130 @@ command = "echo project" ); } + /// #6689: `DEEPSEEK_TOOL_EXECUTION_RECEIPT` is read from the metadata a + /// shell tool recorded — on a failed call as well as a successful one — + /// and is complete JSON or absent. The existing variables do not change. + #[test] + fn execution_receipt_env_is_complete_json_or_absent() { + use crate::tools::spec::{ToolError, ToolResult}; + + let receipt = json!({"schema_version": 1, "command": "printf effective", + "cwd": "/tmp", "state": "completed", "scope": "local", "exit_code": 7, + "stdout": "\u{1f40b}", "stderr": "", "stdout_truncated": false, + "stderr_truncated": false, "output_kind": "separate"}); + let plain = HookContext::new() + .with_tool_name("Bash") + .with_tool_outcome(&Ok(ToolResult::success("out"))); + let legacy = plain.to_env_vars(); + assert!(!legacy.contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT")); + + let with_receipt = HookContext::new() + .with_tool_name("Bash") + .with_tool_outcome(&Ok( + ToolResult::success("out").with_metadata(json!({"execution_receipt": receipt})) + )); + let mut env = with_receipt.to_env_vars(); + let encoded = env + .remove("DEEPSEEK_TOOL_EXECUTION_RECEIPT") + .expect("receipt exported"); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + receipt + ); + assert_eq!(env, legacy, "existing variables are unchanged"); + + let failed = + HookContext::new().with_tool_outcome(&Err(ToolError::execution_failed_with_metadata( + "boom", + json!({"exit_code": 7, "execution_receipt": receipt}), + ))); + assert!( + failed + .to_env_vars() + .contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT") + ); + + // An unknown schema or an oversized document is dropped, not cut. + for bad in [ + json!({"schema_version": 2, "command": "x"}), + json!({"command": "x"}), + json!({"schema_version": 1, + "stdout": "x".repeat(super::HOOK_EXECUTION_RECEIPT_MAX_BYTES)}), + ] { + let context = HookContext::new().with_tool_outcome(&Ok( + ToolResult::success("out").with_metadata(json!({"execution_receipt": bad})) + )); + assert!(context.tool_execution_receipt.is_none()); + } + let oversized = HookContext { + tool_execution_receipt: Some("x".repeat(super::HOOK_EXECUTION_RECEIPT_MAX_BYTES + 1)), + ..HookContext::new() + }; + assert!( + !oversized + .to_env_vars() + .contains_key("DEEPSEEK_TOOL_EXECUTION_RECEIPT") + ); + assert!( + oversized + .bounded_for_observer() + .tool_execution_receipt + .is_none() + ); + } + + /// An absent receipt must be absent in the actual child environment, + /// even when a nested Codewhale inherited an outer hook's receipt. + #[cfg(unix)] + #[test] + fn execution_receipt_never_inherits_another_calls_environment() { + let _env = lock_test_env(); + let _stale = EnvVarGuard::set("DEEPSEEK_TOOL_EXECUTION_RECEIPT", "stale-outer-receipt"); + let current = r#"{"schema_version":1,"command":"current call"}"#; + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("receipt-env.txt"); + let command = write_hook_script( + &dir, + "capture_receipt_env.sh", + &format!( + "#!/bin/sh\nprintf '%s' \"${{DEEPSEEK_TOOL_EXECUTION_RECEIPT-unset}}\" > {}\n", + out.display() + ), + ); + for background in [false, true] { + let mut hook = Hook::new(HookEvent::ToolCallAfter, &command); + hook.background = background; + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![hook], + ..HooksConfig::default() + }, + dir.path().to_path_buf(), + ); + for (receipt, expected) in [ + (None, "unset"), + ( + Some("x".repeat(HOOK_EXECUTION_RECEIPT_MAX_BYTES + 1)), + "unset", + ), + (Some(current.to_string()), current), + ] { + if out.exists() { + std::fs::remove_file(&out).unwrap(); + } + let context = HookContext { + tool_execution_receipt: receipt, + ..HookContext::new() + }; + let results = executor.execute(HookEvent::ToolCallAfter, &context); + assert_eq!(results.len(), 1); + assert!(results[0].success); + assert_eq!(wait_for_captured_output(&out), expected); + } + } + } + #[test] fn observer_context_is_bounded_before_enqueue() { let huge = "用户".repeat(20_000); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index efc0564592..7cd7cad2c4 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -14698,6 +14698,12 @@ impl RuntimeThreadManager { } obj.insert("tool_result_for".to_string(), json!(id)); obj.insert("is_error".to_string(), json!(!output.success)); + // The shell execution receipt (#6689) is + // for completion hooks, which already + // read it from the live result; it + // repeats output previews `detail` + // holds, so it is never persisted. + obj.remove("execution_receipt"); } // Failed calls count too: a large error // output spills like any other. diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index cbcec70cbd..2ce5bddb7c 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -1127,6 +1127,9 @@ pub struct BackgroundShell { lifecycle_seq: u64, last_lifecycle_status: Option, last_lifecycle_bytes: usize, + /// The terminal status came from a failed `try_wait`, not an observed + /// exit or signal. Such a run gets no execution receipt (#6689). + wait_failed: bool, } #[derive(Clone)] @@ -1297,6 +1300,7 @@ impl BackgroundShell { Ok(None) => false, // Still running Err(_) => { self.status = ShellStatus::Failed; + self.wait_failed = true; self.heavy_permit.take(); self.collect_output(); true @@ -2027,6 +2031,7 @@ impl ShellManager { lifecycle_seq: 0, last_lifecycle_status: None, last_lifecycle_bytes: 0, + wait_failed: false, }, ); } @@ -2883,6 +2888,7 @@ impl ShellManager { lifecycle_seq: 0, last_lifecycle_status: None, last_lifecycle_bytes: 0, + wait_failed: false, }; #[cfg(unix)] @@ -4767,10 +4773,29 @@ async fn execute_foreground_via_background( extra_env: HashMap, direct_argv: bool, timeout_bounds_ms: (u64, u64), + wants_receipt: bool, + receipt_identity: &mut Option, ) -> Result { let timeout_ms = timeout_ms.map(|timeout| timeout.clamp(timeout_bounds_ms.0, timeout_bounds_ms.1)); let spawn_timeout_ms = timeout_ms.unwrap_or(timeout_bounds_ms.1); + // Freeze the receipt's directory before execution and hand that same + // resolved spelling to the OS. Looking up the original symlink after + // the command runs can name a different directory than the one it used. + // Resolution is asynchronous; failure leaves the ordinary execution + // path intact but cannot produce an exact receipt. + let receipt_cwd = if wants_receipt { + match working_dir.as_deref() { + Some(cwd) => tokio::fs::canonicalize(cwd) + .await + .ok() + .and_then(|path| path.into_os_string().into_string().ok()), + None => None, + } + } else { + None + }; + let working_dir = receipt_cwd.clone().or(working_dir); let task_id = { let mut manager = context .shell_manager @@ -4802,11 +4827,34 @@ async fn execute_foreground_via_background( .ok_or_else(|| anyhow!("foreground shell did not return a process id"))?; // Classify before releasing the manager lock: even an immediately // completed foreground command must never look like background work. - manager + let process = manager .processes .get_mut(&task_id) - .ok_or_else(|| anyhow!("foreground shell {task_id} is not tracked"))? - .background = false; + .ok_or_else(|| anyhow!("foreground shell {task_id} is not tracked"))?; + process.background = false; + // #6689: the receipt identity is what the manager recorded for this + // spawn — the admitted command and the directory handed to the OS — + // never the before-hook request. Only pipe-backed, unsandboxed local + // runs through a POSIX-style ` ` dispatcher + // qualify: a PTY, the hardened read-only argv rewrite, an OS sandbox + // wrapper, Windows shell prefixes, and a PowerShell dispatcher (even + // `$SHELL=pwsh` on Unix, which wraps the source or runs it from a temp + // `-File`) all change what the process executes relative to this string. + if receipt_cwd.is_some() + && !cfg!(windows) + && !tty + && !direct_argv + && !spawned.sandboxed + && !spawned.sandbox_denied + && !crate::shell_dispatcher::global_dispatcher() + .kind() + .is_powershell() + { + *receipt_identity = Some(ShellExecutionIdentity { + command: process.command.clone(), + cwd: process.working_dir.clone(), + }); + } task_id }; let mut foreground = ForegroundShellGuard { @@ -4871,6 +4919,16 @@ async fn execute_foreground_via_background( if manager.poll_status(&task_id)? == ShellStatus::Running { None } else { + // #6689: a status from a failed wait is neither an observed + // exit nor an interruption, so the receipt is left out rather + // than reporting a guessed state. + if manager + .processes + .get(&task_id) + .is_none_or(|shell| shell.wait_failed) + { + *receipt_identity = None; + } let snapshot = manager.get_output(&task_id, false, 0)?; // Ordering matters: the snapshot is taken before the // acknowledgement releases the retained bytes. @@ -4937,6 +4995,110 @@ impl Drop for ForegroundShellGuard { } } +/// The admitted command and working directory a foreground spawn recorded, +/// for the `tool_call_after` execution receipt (#6689). +struct ShellExecutionIdentity { + command: String, + cwd: PathBuf, +} + +/// Largest command or working directory a receipt carries. Identities are +/// exact or absent, never truncated. +const EXECUTION_RECEIPT_IDENTITY_MAX_BYTES: usize = 8 * 1024; + +/// Starting per-stream output preview in an execution receipt. Halved until +/// the serialized receipt fits `HOOK_EXECUTION_RECEIPT_MAX_BYTES`. +const EXECUTION_RECEIPT_PREVIEW_MAX_BYTES: usize = 8 * 1024; + +/// Keep both ends of an output stream — the first lines and the final +/// diagnostic — cut on UTF-8 boundaries with a visible marker. +fn execution_receipt_preview(text: &str, budget: usize) -> (String, bool) { + if text.len() <= budget { + return (text.to_owned(), false); + } + let mut head = budget / 2; + while !text.is_char_boundary(head) { + head -= 1; + } + let mut tail = text.len() - budget / 2; + while !text.is_char_boundary(tail) { + tail += 1; + } + ( + format!( + "{}\n[receipt preview truncated]\n{}", + &text[..head], + &text[tail..] + ), + true, + ) +} + +/// Build the schema-1 execution receipt for a settled foreground shell run +/// (#6689), exported to `tool_call_after` hooks as +/// `DEEPSEEK_TOOL_EXECUTION_RECEIPT`. +/// +/// Returns `None` — absence, which implies neither success nor failure — for +/// a run still in flight, a sandboxed run, or an identity that is not exact: +/// empty, over the identity bound, containing NUL, or a relative or non-UTF-8 +/// directory. `output_kind` is `"combined"` when stdout and stderr shared one +/// pipe, so `stdout` holds the combined preview. +fn shell_execution_receipt( + identity: &ShellExecutionIdentity, + result: &ShellResult, + output_kind: &'static str, +) -> Option { + let command = identity.command.as_str(); + let cwd = identity.cwd.to_str()?; + if command.is_empty() + || command.len() > EXECUTION_RECEIPT_IDENTITY_MAX_BYTES + || command.contains('\0') + || cwd.len() > EXECUTION_RECEIPT_IDENTITY_MAX_BYTES + || cwd.contains('\0') + || !identity.cwd.is_absolute() + || result.sandboxed + || result.sandbox_denied + { + return None; + } + // A nonzero exit is still a completed run; `interrupted` is a signal, + // kill, cancel, or timeout. The exit code is only ever the observed one. + let state = match result.status { + ShellStatus::Completed => "completed", + ShellStatus::Failed if result.exit_code.is_some() => "completed", + ShellStatus::Failed | ShellStatus::Killed | ShellStatus::TimedOut => "interrupted", + ShellStatus::Running => return None, + }; + let mut budget = EXECUTION_RECEIPT_PREVIEW_MAX_BYTES; + loop { + let (stdout, stdout_clipped) = execution_receipt_preview(&result.stdout, budget); + let (stderr, stderr_clipped) = execution_receipt_preview(&result.stderr, budget); + let receipt = json!({ + "schema_version": 1, + "command": command, + "cwd": cwd, + "state": state, + "scope": "local", + "exit_code": result.exit_code, + "stdout": stdout, + "stderr": stderr, + "stdout_truncated": result.stdout_truncated || stdout_clipped, + "stderr_truncated": result.stderr_truncated || stderr_clipped, + "output_kind": output_kind, + }); + // The bound is on the serialized form, so JSON escaping counts. + if serde_json::to_vec(&receipt).ok()?.len() + <= crate::hooks::HOOK_EXECUTION_RECEIPT_MAX_BYTES + { + return Some(receipt); + } + if budget == 0 { + return None; + } + budget /= 2; + } +} + const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64; /// Initial cadence for foreground-via-background completion polling. Fast @@ -5005,6 +5167,7 @@ fn finish_contract_bash_result( result: ShellResult, timeout_ms: Option, context: &ToolContext, + execution_receipt: Option, ) -> Result { let sandbox_denied_hint = shell_sandbox_denied_hint(context, &result); let mut output = result.stdout.clone(); @@ -5016,12 +5179,15 @@ fn finish_contract_bash_result( format!("{hint}\n\n{output}") }; } - let metadata = json!({ + let mut metadata = json!({ "evidence_routing": "inline", "exit_code": result.exit_code, "status": format!("{:?}", result.status), "duration_ms": result.duration_ms, "sandboxed": result.sandboxed, "sandbox_type": result.sandbox_type, "task_id": result.task_id, "backgrounded": result.status == ShellStatus::Running, }); + if let Some(receipt) = execution_receipt { + metadata["execution_receipt"] = receipt; + } if result.status == ShellStatus::Running { let task_id = result.task_id.as_deref().unwrap_or("unknown"); let partial = (!output.is_empty()).then(|| format!("\n\nOutput so far:\n{output}")); @@ -5932,6 +6098,14 @@ impl ToolSpec for BashTool { } let mut lifecycle_warning = None; + let mut receipt_identity = None; + // #6689: the receipt exists for completion hooks to read. Without one + // registered it would only ride along in tool metadata, which the + // Runtime API persists and emits for every call. + let wants_receipt = context.runtime.hook_executor.as_ref().is_some_and(|hooks| { + hooks.has_hooks_for_event(crate::hooks::HookEvent::ToolCallAfter) + || hooks.has_hooks_for_event(crate::hooks::HookEvent::OnError) + }); let result = if interactive { let mut manager = context .shell_manager @@ -6015,6 +6189,8 @@ impl ToolSpec for BashTool { } else { (1_000, 600_000) }, + wants_receipt, + &mut receipt_identity, ) .await }; @@ -6037,8 +6213,26 @@ impl ToolSpec for BashTool { .cancel_token .as_ref() .is_some_and(|token| token.is_cancelled()); + // Lowercase `bash` joins stdout and stderr on one pipe, so + // its preview is combined output, not stdout. + let execution_receipt = receipt_identity.as_ref().and_then(|identity| { + shell_execution_receipt( + identity, + &result, + if self.optional_timeout { + "combined" + } else { + "separate" + }, + ) + }); if self.optional_timeout { - return finish_contract_bash_result(result, timeout_ms, context); + return finish_contract_bash_result( + result, + timeout_ms, + context, + execution_receipt, + ); } let task_id_str = result.task_id.clone().unwrap_or_default(); let stdout_summary = summarize_output(&result.stdout); @@ -6169,6 +6363,9 @@ impl ToolSpec for BashTool { }), }), }); + if let Some(receipt) = execution_receipt { + metadata["execution_receipt"] = receipt; + } metadata["backgrounded"] = json!(background || backgrounded_foreground); if persist { metadata["persist_requested"] = json!(true); diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 78e2d13700..c413b621be 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -263,6 +263,7 @@ fn contract_bash_nonzero_is_an_error_with_status_after_output() { }, None, &ToolContext::new("."), + None, ) .expect_err("nonzero must be a failed tool call"); assert!( @@ -292,6 +293,250 @@ async fn lowercase_bash_returns_one_ordered_stream() { assert_eq!(result.content, "out-1err-2out-3"); } +/// #6689: the receipt is exact-or-absent, fits the hook bound after JSON +/// escaping, and reports how the run ended without inventing an exit code. +#[test] +fn execution_receipt_is_bounded_and_reports_truthful_state() { + let tmp = tempdir().unwrap(); + let identity = |command: &str, cwd: &Path| ShellExecutionIdentity { + command: command.to_string(), + cwd: cwd.to_path_buf(), + }; + let cwd = tmp.path().canonicalize().unwrap(); + let mut result = failed_network_shell_result( + &"\u{1f40b}\n\"\u{1}".repeat(12_000), + &"err\n".repeat(12_000), + ); + result.sandboxed = false; + result.sandbox_type = None; + result.stdout_truncated = true; + result.exit_code = None; + result.status = ShellStatus::Killed; + + let receipt = shell_execution_receipt(&identity("printf hi", &cwd), &result, "separate") + .expect("receipt"); + let encoded = serde_json::to_string(&receipt).unwrap(); + assert!(encoded.len() <= crate::hooks::HOOK_EXECUTION_RECEIPT_MAX_BYTES); + assert_eq!(serde_json::from_str::(&encoded).unwrap(), receipt); + assert_eq!(receipt["schema_version"], 1); + assert_eq!(receipt["command"], "printf hi"); + assert_eq!(receipt["cwd"], cwd.to_str().unwrap()); + assert_eq!(receipt["state"], "interrupted"); + assert!(receipt["exit_code"].is_null()); + assert_eq!(receipt["stdout_truncated"], true); + assert_eq!(receipt["stderr_truncated"], true); + assert!( + receipt["stderr"] + .as_str() + .unwrap() + .contains("[receipt preview truncated]") + ); + + // A nonzero exit is a completed run, and a 64-bit code survives. + result.status = ShellStatus::Failed; + result.exit_code = Some(3_221_225_477); + let receipt = shell_execution_receipt(&identity("x", &cwd), &result, "combined").unwrap(); + assert_eq!(receipt["state"], "completed"); + assert_eq!(receipt["exit_code"], 3_221_225_477_i64); + assert_eq!(receipt["output_kind"], "combined"); + + result.status = ShellStatus::TimedOut; + result.exit_code = None; + let receipt = shell_execution_receipt(&identity("x", &cwd), &result, "separate").unwrap(); + assert_eq!(receipt["state"], "interrupted"); + + // Absent, never truncated or guessed. + result.status = ShellStatus::Running; + assert!(shell_execution_receipt(&identity("x", &cwd), &result, "separate").is_none()); + result.status = ShellStatus::Completed; + for command in ["", "bad\0command"] { + assert!(shell_execution_receipt(&identity(command, &cwd), &result, "separate").is_none()); + } + let long = "x".repeat(EXECUTION_RECEIPT_IDENTITY_MAX_BYTES + 1); + assert!(shell_execution_receipt(&identity(&long, &cwd), &result, "separate").is_none()); + let long_cwd = PathBuf::from(format!("/{long}")); + assert!(shell_execution_receipt(&identity("x", &long_cwd), &result, "separate").is_none()); + assert!( + shell_execution_receipt(&identity("x", Path::new("relative")), &result, "separate") + .is_none() + ); + result.sandboxed = true; + assert!(shell_execution_receipt(&identity("x", &cwd), &result, "separate").is_none()); +} + +/// #6689: a settled foreground run records the command and directory the +/// process manager spawned, on success and on failure, and background runs +/// carry no receipt. The workspace is opened through a symlink so the default +/// directory and an explicit `cwd` would otherwise be spelled differently. +#[cfg(unix)] +#[tokio::test] +async fn foreground_shell_results_carry_the_spawned_execution_receipt() { + let tmp = tempdir().unwrap(); + let real = tmp.path().join("real"); + std::fs::create_dir_all(real.join("child")).unwrap(); + let workspace = tmp.path().join("link"); + std::os::unix::fs::symlink(&real, &workspace).unwrap(); + let mut context = ToolContext::new(workspace.clone()) + .with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess); + context.auto_approve = true; + let tool = BashTool::new("Bash"); + + // No completion hook registered: nothing reads a receipt, so none rides + // along in the metadata the Runtime API persists. + let unobserved = tool + .execute(json!({"command": "pwd"}), &context) + .await + .unwrap(); + assert!( + unobserved + .metadata + .as_ref() + .unwrap() + .get("execution_receipt") + .is_none() + ); + + let hooks = crate::hooks::HookExecutor::new( + crate::hooks::HooksConfig { + enabled: true, + hooks: vec![crate::hooks::Hook::new( + crate::hooks::HookEvent::ToolCallAfter, + "true", + )], + ..crate::hooks::HooksConfig::default() + }, + workspace.clone(), + ); + context.runtime.hook_executor = Some(std::sync::Arc::new(hooks)); + // Exact strings, not re-canonicalized: both spellings must already agree. + let real = real.canonicalize().unwrap(); + let real_str = real.to_str().unwrap(); + let child_str = real.join("child"); + let child_str = child_str.to_str().unwrap(); + + let default_dir = tool + .execute(json!({"command": "pwd"}), &context) + .await + .unwrap(); + let explicit_dir = tool + .execute(json!({"command": "pwd", "cwd": "."}), &context) + .await + .unwrap(); + for result in [&default_dir, &explicit_dir] { + assert_eq!( + result.metadata.as_ref().unwrap()["execution_receipt"]["cwd"], + real_str + ); + } + + let command = "printf effective; printf diagnostic >&2; exit 7"; + let result = tool + .execute(json!({"command": command, "cwd": "child"}), &context) + .await + .unwrap(); + let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"]; + assert_eq!(receipt["command"], command); + assert_eq!(receipt["cwd"], child_str); + assert_eq!(receipt["stdout"], "effective"); + assert_eq!(receipt["stderr"], "diagnostic"); + assert_eq!(receipt["exit_code"], 7); + assert_eq!(receipt["state"], "completed"); + assert_eq!(receipt["output_kind"], "separate"); + + let interrupted = tool + .execute(json!({"command": "kill -TERM $$"}), &context) + .await + .unwrap(); + let receipt = &interrupted.metadata.as_ref().unwrap()["execution_receipt"]; + assert_eq!(receipt["state"], "interrupted"); + assert!(receipt["exit_code"].is_null()); + + let background = tool + .execute( + json!({"command": "printf background", "background": true}), + &context, + ) + .await + .unwrap(); + assert!( + background + .metadata + .as_ref() + .unwrap() + .get("execution_receipt") + .is_none() + ); + + // Lowercase `bash` shares one pipe: the preview is combined output, and a + // failing command's error still carries the receipt for hooks. + let result = LowercaseBashTool + .execute( + json!({"command": "printf merged; printf diagnostic >&2"}), + &context, + ) + .await + .unwrap(); + let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"]; + assert_eq!(receipt["output_kind"], "combined"); + assert_eq!(receipt["stdout"], "mergeddiagnostic"); + assert_eq!(receipt["stderr"], ""); + assert_eq!(receipt["state"], "completed"); + let error = LowercaseBashTool + .execute(json!({"command": "printf partial; exit 3"}), &context) + .await + .expect_err("nonzero exit is a failed bash call"); + let receipt = &error.metadata().expect("failure metadata")["execution_receipt"]; + assert_eq!(receipt["command"], "printf partial; exit 3"); + assert_eq!(receipt["exit_code"], 3); + assert_eq!(receipt["state"], "completed"); + assert_eq!(receipt["cwd"], real_str); +} + +/// A post-run lookup of a retargeted workspace symlink would falsely name +/// the replacement directory. The receipt must keep the actual spawn path. +#[cfg(unix)] +#[tokio::test] +async fn execution_receipt_keeps_spawn_cwd_when_the_command_retargets_the_workspace() { + let tmp = tempdir().unwrap(); + let real = tmp.path().join("real"); + let other = tmp.path().join("other"); + std::fs::create_dir(&real).unwrap(); + std::fs::create_dir(&other).unwrap(); + let workspace = tmp.path().join("link"); + std::os::unix::fs::symlink(&real, &workspace).unwrap(); + let mut context = ToolContext::new(workspace.clone()) + .with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess); + context.auto_approve = true; + context.runtime.hook_executor = Some(std::sync::Arc::new(crate::hooks::HookExecutor::new( + crate::hooks::HooksConfig { + enabled: true, + hooks: vec![crate::hooks::Hook::new( + crate::hooks::HookEvent::ToolCallAfter, + "true", + )], + ..crate::hooks::HooksConfig::default() + }, + workspace.clone(), + ))); + let command = "pwd -P; rm ../link; ln -s other ../link; pwd -P"; + let result = BashTool::new("Bash") + .execute(json!({"command": command}), &context) + .await + .unwrap(); + assert!(result.success); + let receipt = &result.metadata.as_ref().unwrap()["execution_receipt"]; + let actual = real.canonicalize().unwrap(); + let actual = actual.to_str().unwrap(); + assert_eq!(receipt["command"], command); + assert_eq!(receipt["cwd"], actual); + assert_eq!(receipt["stdout"], format!("{actual}\n{actual}\n")); + assert_eq!(receipt["exit_code"], 0); + assert_eq!( + workspace.canonicalize().unwrap(), + other.canonicalize().unwrap() + ); +} + #[cfg(unix)] #[tokio::test] async fn lowercase_bash_keeps_raw_command_under_readonly_policy() { @@ -2538,7 +2783,7 @@ fn contract_bash_denial_surfaces_the_escalation_shape() { let mut result = failed_network_shell_result("", "Operation not permitted"); result.sandbox_denied = true; - let error = finish_contract_bash_result(result, None, &ctx) + let error = finish_contract_bash_result(result, None, &ctx, None) .expect_err("sandbox denial is a failed call"); assert!( @@ -3774,6 +4019,7 @@ fn killed_shell_does_not_wait_for_blocked_reader_threads() { lifecycle_seq: 0, last_lifecycle_status: None, last_lifecycle_bytes: 0, + wait_failed: false, }; let started = std::time::Instant::now(); diff --git a/docs/CONTRIBUTORS.md b/docs/CONTRIBUTORS.md index 8149f3f0c5..fcaa3b091f 100644 --- a/docs/CONTRIBUTORS.md +++ b/docs/CONTRIBUTORS.md @@ -37,6 +37,7 @@ notes, and relevant issue/PR comments. - **[aboimpinto](https://github.com/aboimpinto)** — restored a green Linux full-workspace test gate without loosening any test, twice ([#6581](https://github.com/Hmbown/Codewhale/pull/6581), [#6666](https://github.com/Hmbown/Codewhale/pull/6666)). - **[dajiaohuang](https://github.com/dajiaohuang)** — validated `config set` values against the settings schema ([#6568](https://github.com/Hmbown/Codewhale/pull/6568)). - **[Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance and its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)). +- **[wuisabel-gif](https://github.com/wuisabel-gif)** — designed the `tool_call_after` execution-receipt contract and its tests on a reference branch, which landed re-implemented on the current hook seam ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), [#6713](https://github.com/Hmbown/Codewhale/pull/6713)). - **[Sh1Zuku / SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)). **Reports and reproductions** diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 023bd404a7..13eb8b92f2 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -311,8 +311,54 @@ rebrand. | `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`, `on_error` (tool failures) | `true` / `false` | | `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` and `on_error` **when the tool reported one** | absent otherwise — never synthesized; set for a failing command as well as a passing one; 64-bit, so Windows crash codes such as `3221225477` survive | | `DEEPSEEK_TOOL_STATUS` | `tool_call_after` and `on_error` **when a shell tool reported one** | `completed`, `failed`, `timed_out`, `killed`, or `running` (moved to the background); absent for other tools | +| `DEEPSEEK_TOOL_EXECUTION_RECEIPT` | `tool_call_after` and `on_error` **for a settled, local, foreground shell run** | complete JSON, at most 32 KiB, or absent; see [Execution receipt](#execution-receipt) | | `DEEPSEEK_SESSION_COST` | when cost is supplied | USD, six decimal places | +### Execution receipt + +`DEEPSEEK_TOOL_EXECUTION_RECEIPT` says what a shell tool (`bash`, `Bash`, +`exec_shell`) actually ran. The before-hook input is not the same thing: a +`tool_call_before` hook can rewrite it. The receipt is built from what the +process manager recorded when it spawned the process, after admission and +any rewrite. + +```json +{"schema_version":1,"command":"printf hello","cwd":"/absolute/workspace","state":"completed","scope":"local","exit_code":0,"stdout":"hello","stderr":"","stdout_truncated":false,"stderr_truncated":false,"output_kind":"separate"} +``` + +| Field | Meaning | +| --- | --- | +| `command` | the admitted shell source handed to the shell, not the shell executable or its argv wrapper | +| `cwd` | the canonical absolute path of the directory the process started in: symlinks are resolved, so a directory has one spelling whether or not the call passed `cwd`; it is resolved before spawn and that same path is handed to the OS | +| `state` | `completed` for an observed exit, including a nonzero one; `interrupted` for a signal, kill, cancel, or timeout | +| `scope` | always `local` in schema 1 | +| `exit_code` | the observed integer, or `null`; never synthesized from `state` | +| `stdout`, `stderr` | previews of the tool's retained output, which may already omit early process output; long previews keep their first and last bytes around a `[receipt preview truncated]` marker | +| `stdout_truncated`, `stderr_truncated` | `true` when the tool's own output capture or the preview dropped bytes | +| `output_kind` | `separate` for `Bash` / `exec_shell`; `combined` for lowercase `bash`, whose stdout and stderr share one pipe — `stdout` then holds the combined preview and `stderr` is empty | + +The rules are conservative: + +- **Exact or absent.** `command` and `cwd` are never truncated. If either is + over 8 KiB, contains NUL, or the directory is relative, not UTF-8, or cannot + resolve before spawn, the receipt is left out. So is a run whose end the shell + tool could not observe (the OS wait itself failed): its state is unknown, + and the receipt does not guess it. Previews shrink until the serialized JSON fits 32 KiB; + if it still cannot fit, the receipt is left out rather than cut. +- **Absence means nothing.** It implies neither success nor failure. An inherited + `DEEPSEEK_TOOL_EXECUTION_RECEIPT` is cleared before applying the current call's context. +- **Scope.** A receipt is built only while a `tool_call_after` or `on_error` + hook is configured, and only for a settled, pipe-backed, unsandboxed, local + foreground run. Background launches, a foreground run moved to `/jobs`, + PTY (`tty` / `combined_output`) and interactive sessions, OS-sandboxed and + external-backend execution, the read-only shell's hardened argv, Windows, + a PowerShell shell on any platform (it wraps the source or runs it from a + temporary script), and calls refused before execution have none. +- **Hooks only.** The receipt is not kept in the durable Runtime API item + record; that record already carries the tool output. +- It is set for a failed run as well as a passing one, so `on_error` for a + failed shell call carries it too. Every other variable is unchanged. + **Mode-spelling note.** UI-fired events (`session_start`, `session_end`, `message_submit`, `tool_call_after`, `mode_change`, `on_error`, `turn_end`, `subagent_*`, `session_busy`, `session_idle`, `session_error`, `waiting_for_user`) diff --git a/docs/public-surface-facts.json b/docs/public-surface-facts.json index 87eae1d470..77238964ea 100644 --- a/docs/public-surface-facts.json +++ b/docs/public-surface-facts.json @@ -232,6 +232,7 @@ "@aboimpinto", "@dajiaohuang", "@Water-Run", + "@wuisabel-gif", "@BX166", "@SparkofSpike", "@cenab" diff --git a/docs/zh_hans/HOOKS.md b/docs/zh_hans/HOOKS.md index fac5536695..4406931b1c 100644 --- a/docs/zh_hans/HOOKS.md +++ b/docs/zh_hans/HOOKS.md @@ -163,8 +163,36 @@ Observer 并**不**意味着无副作用。observer hook 是以你的凭据运 | `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`、`on_error`(工具失败) | `true` / `false` | | `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` 和 `on_error` **当工具报告了退出码时** | 否则不存在——绝不合成;命令失败时同样设置;64 位,因此 `3221225477` 这样的 Windows 崩溃码能完好保留 | | `DEEPSEEK_TOOL_STATUS` | `tool_call_after` 和 `on_error` **当 shell 工具报告了状态时** | `completed`、`failed`、`timed_out`、`killed` 或 `running`(已转入后台);其他工具不存在 | +| `DEEPSEEK_TOOL_EXECUTION_RECEIPT` | `tool_call_after` 和 `on_error` **仅限已结束的本地前台 shell 运行** | 完整 JSON,最多 32 KiB,否则不存在;见下方“执行回执” | | `DEEPSEEK_SESSION_COST` | 提供成本时 | USD,六位小数 | +### 执行回执 + +`DEEPSEEK_TOOL_EXECUTION_RECEIPT` 说明 shell 工具(`bash`、`Bash`、`exec_shell`)实际运行了什么。before-hook 的输入不等于实际执行的内容:`tool_call_before` hook 可以改写它。回执取自进程管理器在准入与改写之后启动进程时记录的内容。 + +```json +{"schema_version":1,"command":"printf hello","cwd":"/absolute/workspace","state":"completed","scope":"local","exit_code":0,"stdout":"hello","stderr":"","stdout_truncated":false,"stderr_truncated":false,"output_kind":"separate"} +``` + +| 字段 | 含义 | +| --- | --- | +| `command` | 交给 shell 的已准入命令源码,而不是 shell 可执行文件或其 argv 包装 | +| `cwd` | 进程启动时所在目录的规范绝对路径:解析符号链接,因此无论调用是否传入 `cwd`,同一目录只有一种写法;在启动前解析,并将同一路径交给操作系统 | +| `state` | 观察到退出(包括非零退出)为 `completed`;信号、kill、取消或超时为 `interrupted` | +| `scope` | schema 1 中始终为 `local` | +| `exit_code` | 观察到的整数,或 `null`;绝不根据 `state` 合成 | +| `stdout`、`stderr` | 工具已保留输出的预览,其中可能已经缺少进程输出的开头;过长的预览保留自身的首尾字节,中间以 `[receipt preview truncated]` 标记 | +| `stdout_truncated`、`stderr_truncated` | 工具自身的输出捕获或预览丢弃了字节时为 `true` | +| `output_kind` | `Bash` / `exec_shell` 为 `separate`;小写 `bash` 的 stdout 与 stderr 共用一个管道,为 `combined`——此时 `stdout` 是合并后的预览,`stderr` 为空 | + +规则是保守的: + +- **要么精确,要么不存在。** `command` 和 `cwd` 绝不截断。任一超过 8 KiB、包含 NUL,或目录是相对路径、非 UTF-8 或在启动前无法解析时,不导出回执。shell 工具无法观察到运行如何结束(操作系统的 wait 调用本身失败)时同样不导出:其状态未知,回执不做猜测。预览会缩短直到序列化后的 JSON 不超过 32 KiB;仍然放不下时不导出回执,而不是截断。 +- **不存在不代表任何结果。** 既不意味着成功,也不意味着失败。应用当前调用的上下文前,会清除继承的 `DEEPSEEK_TOOL_EXECUTION_RECEIPT`。 +- **范围。** 只有配置了 `tool_call_after` 或 `on_error` hook 时才会生成回执,且仅限已结束、基于管道、未沙箱化的本地前台运行。后台启动、转入 `/jobs` 的前台运行、PTY(`tty` / `combined_output`)与交互会话、OS 沙箱与外部后端执行、只读 shell 的加固 argv、Windows、任何平台上的 PowerShell(它会包装源码或通过临时脚本运行),以及执行前就被拒绝的调用都没有回执。 +- **仅供 hook 使用。** 回执不写入持久化的 Runtime API 条目记录;该记录已包含工具输出。 +- 失败的运行与成功的运行同样设置,因此 shell 调用失败时的 `on_error` 也带有它。其他变量均不变。 + **模式拼写说明。** UI 触发的事件(`session_start`、`session_end`、`message_submit`、`tool_call_after`、`mode_change`、`on_error`、`turn_end`、`subagent_*`)会将 `DEEPSEEK_MODE` 设为 UI 标签——`ACT`、`PLAN`、`OPERATE`。`tool_call_before` 在引擎内部触发,并使用引擎自己的模式拼写(`Agent`、`Plan`、`Operate`)。`mode` 条件不区分大小写比较,因此 `{ type = "mode", mode = "plan" }` 两者都能匹配,但精确字符串匹配 `$DEEPSEEK_MODE` 的 hook 应同时接受两种拼写。 **`shell_env` 是受限的那个。** 它只接收 `DEEPSEEK_TOOL_NAME` 和 `DEEPSEEK_TOOL_ARGS`——没有会话 id、工作区、模型或模式。因此,`shell_env` hook 上的 `{ type = "mode", … }` 条件会在加载时被拒绝;请改用 `tool_name` 或 `tool_category` 来限定作用域。 diff --git a/web/lib/release-credits.ts b/web/lib/release-credits.ts index 7a911cfe8b..11e8aa414a 100644 --- a/web/lib/release-credits.ts +++ b/web/lib/release-credits.ts @@ -25,6 +25,7 @@ export const RELEASE_CONTRIBUTORS: string[] = [ "@aboimpinto", "@dajiaohuang", "@Water-Run", + "@wuisabel-gif", "@SparkofSpike", ];