Synced from monorepo
Synced from monorepo Changes: - Cache growing transcripts on the messages backend - Tell the model when a wait was clamped instead of re-inviting it - Stop the stationarity nudge from claiming results are identical - Deliver the stationarity nudge after the tool result - Run auth provider commands through the platform shell (fixes Windows) - Keep monitor tool stdout short and prescriptive - Use UUIDs for analytics event insert IDs - Stop crashing at startup when the host runs out of threads - Delete the current session from within the session - Add project forking-settings toggle (backend and deploy-time control) - Reap a session’s bash and background commands when it closes - Reap a session’s hook child processes when it closes - Track coding-data consent decisions - Fail open the access gate to stop false CLI paywalls - Ship Agent Dashboard user guide - Enable doom-loop recovery by default - Kill agent children and the idle inhibitor when the parent process dies - Fix multi-process credential wipe and orphaned session log writers Source-Revision: 6372e41d828b8a6ee82c29e01a69e27ec895cca9
This commit is contained in:
parent
5da6962e4a
commit
500129c714
89 changed files with 3841 additions and 771 deletions
|
|
@ -526,6 +526,7 @@ mod tests {
|
|||
RunContext {
|
||||
session_id: "test-session",
|
||||
workspace_root: "/tmp",
|
||||
process_scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use xai_grok_tools::util::ProcessGroup;
|
||||
|
||||
use crate::config::HookSpec;
|
||||
use crate::event::HookEventEnvelope;
|
||||
|
|
@ -18,6 +20,23 @@ const MAX_OUTPUT_BYTES: usize = 64 * 1024;
|
|||
/// or block (Stop/SubagentStop, with stderr as the feedback).
|
||||
const GATE_EXIT_CODE: i32 = 2;
|
||||
|
||||
/// `None` when the group cannot be built, which only costs session reaping, so
|
||||
/// the hook still runs.
|
||||
fn hook_process_group(child: &tokio::process::Child) -> Option<Arc<ProcessGroup>> {
|
||||
let mut group = ProcessGroup::new()
|
||||
.inspect_err(
|
||||
|e| tracing::warn!(pid = child.id(), error = %e, "hook: no process group; not reaped on session close"),
|
||||
)
|
||||
.ok()?;
|
||||
group
|
||||
.attach(child)
|
||||
.inspect_err(
|
||||
|e| tracing::warn!(pid = child.id(), error = %e, "hook: process group attach failed; not reaped on session close"),
|
||||
)
|
||||
.ok()?;
|
||||
Some(Arc::new(group))
|
||||
}
|
||||
|
||||
/// Run a single hook command.
|
||||
///
|
||||
/// Spawns the command as a child process, writes the envelope JSON on stdin,
|
||||
|
|
@ -166,6 +185,21 @@ pub async fn run_command_hook(
|
|||
}
|
||||
};
|
||||
|
||||
let mut hook_group = None;
|
||||
if let Some(scope) = ctx.process_scope.as_ref()
|
||||
&& let Some(group) = hook_process_group(&child)
|
||||
{
|
||||
// A closed scope means the session is gone and `register` already killed
|
||||
// the child, so stop rather than write stdin to a corpse.
|
||||
if !scope.register(&group) {
|
||||
return (
|
||||
HookRunnerResult::Failed("session closed before the hook ran".to_string()),
|
||||
start.elapsed(),
|
||||
);
|
||||
}
|
||||
hook_group = Some(group);
|
||||
}
|
||||
|
||||
// Write stdin concurrently with draining output, under the timeout: a hook
|
||||
// that never reads stdin would otherwise block `write_all` on a full pipe
|
||||
// buffer, outside the deadline.
|
||||
|
|
@ -184,14 +218,18 @@ pub async fn run_command_hook(
|
|||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// killpg takes grandchildren that kill_on_drop would miss.
|
||||
if !matches!(result, Ok(Ok(_)))
|
||||
&& let Some(group) = &hook_group
|
||||
{
|
||||
let _ = group.kill();
|
||||
}
|
||||
|
||||
match result {
|
||||
Err(_) => {
|
||||
// Timeout: kill_on_drop handles cleanup.
|
||||
(
|
||||
HookRunnerResult::Failed(format!("timed out after {}ms", spec.timeout_ms)),
|
||||
elapsed,
|
||||
)
|
||||
}
|
||||
Err(_) => (
|
||||
HookRunnerResult::Failed(format!("timed out after {}ms", spec.timeout_ms)),
|
||||
elapsed,
|
||||
),
|
||||
Ok(Err(e)) => (
|
||||
HookRunnerResult::Failed(format!("command execution failed: {e}")),
|
||||
elapsed,
|
||||
|
|
@ -907,6 +945,14 @@ mod tests {
|
|||
RunContext {
|
||||
session_id: "test-session",
|
||||
workspace_root: "/tmp",
|
||||
process_scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_scoped_ctx(scope: xai_grok_tools::util::ProcessScope) -> RunContext<'static> {
|
||||
RunContext {
|
||||
process_scope: Some(scope),
|
||||
..make_ctx()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1106,6 +1152,7 @@ mod tests {
|
|||
let ctx = RunContext {
|
||||
session_id: "test-session",
|
||||
workspace_root: &workspace,
|
||||
process_scope: None,
|
||||
};
|
||||
let (result, _) = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe).await;
|
||||
|
||||
|
|
@ -1382,4 +1429,70 @@ mod tests {
|
|||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn command_hook_session_close_reaps_whole_group() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let marker = tmp.path().join("grandchild_alive");
|
||||
// `& wait` keeps the leader alive while the grandchild outlives it, so
|
||||
// only a group kill stops the marker being written.
|
||||
let mut spec = make_shell_spec(&format!(
|
||||
"sh -c 'sleep 2 && echo alive > {}' & wait",
|
||||
marker.display()
|
||||
));
|
||||
spec.timeout_ms = 60_000;
|
||||
let envelope = make_envelope();
|
||||
let scope = xai_grok_tools::util::ProcessScope::new();
|
||||
let hook_scope = scope.clone();
|
||||
let hook = tokio::spawn(async move {
|
||||
run_command_hook(
|
||||
&spec,
|
||||
&envelope,
|
||||
&make_scoped_ctx(hook_scope),
|
||||
GateKind::Observe,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
scope.kill_all();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(15), hook)
|
||||
.await
|
||||
.expect("kill_all must reap the enrolled hook, not leave it on its 60s timeout")
|
||||
.expect("hook task join");
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"grandchild outlived session close, so the group was not killpg'd"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn command_hook_fails_fast_when_scope_already_closed() {
|
||||
let scope = xai_grok_tools::util::ProcessScope::new();
|
||||
scope.kill_all();
|
||||
let mut spec = make_shell_spec("sleep 600");
|
||||
spec.timeout_ms = 60_000;
|
||||
|
||||
let (result, _) = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
run_command_hook(
|
||||
&spec,
|
||||
&make_envelope(),
|
||||
&make_scoped_ctx(scope),
|
||||
GateKind::Observe,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("a closed scope must fail the hook immediately, not run to its 60s timeout");
|
||||
|
||||
assert!(
|
||||
matches!(result, HookRunnerResult::Failed(_)),
|
||||
"got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -673,6 +673,7 @@ mod tests {
|
|||
let ctx = crate::runner::RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: "/tmp",
|
||||
process_scope: None,
|
||||
};
|
||||
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, GateKind::Tool).await;
|
||||
|
||||
|
|
@ -751,6 +752,7 @@ mod tests {
|
|||
let ctx = crate::runner::RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: "/tmp",
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, GateKind::Tool).await;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub use crate::event::GateKind;
|
|||
pub struct RunContext<'a> {
|
||||
pub session_id: &'a str,
|
||||
pub workspace_root: &'a str,
|
||||
pub process_scope: Option<xai_grok_tools::util::ProcessScope>,
|
||||
}
|
||||
|
||||
/// Result of running a single hook (any handler type).
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ async fn hook_deny_via_exit_code_only() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let pre_result =
|
||||
|
|
@ -101,6 +102,7 @@ async fn hook_fail_open_on_crash() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let pre_result =
|
||||
|
|
@ -134,6 +136,7 @@ async fn hook_fail_open_on_timeout() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let pre_result =
|
||||
|
|
@ -162,6 +165,7 @@ async fn matcher_filters_tool_name() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let pre_result = dispatcher::dispatch_pre_tool_use(
|
||||
|
|
@ -194,6 +198,7 @@ async fn non_blocking_dispatch() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let results = dispatcher::dispatch_non_blocking(
|
||||
|
|
@ -232,6 +237,7 @@ async fn first_deny_stops_chain() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let pre_result = dispatcher::dispatch_pre_tool_use(
|
||||
|
|
@ -264,6 +270,7 @@ async fn hook_receives_stdin_envelope() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test-sess-123",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let pre_result =
|
||||
|
|
@ -288,6 +295,7 @@ async fn shell_pipe_command_works() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let pre_result =
|
||||
|
|
@ -419,6 +427,7 @@ async fn new_event_types_fire_and_receive_correct_envelope() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let results =
|
||||
|
|
@ -508,6 +517,7 @@ async fn runner_injected_vars_override_extra_env_at_spawn() {
|
|||
let ctx = RunContext {
|
||||
session_id: real_session,
|
||||
workspace_root: real_workspace,
|
||||
process_scope: None,
|
||||
};
|
||||
|
||||
let result =
|
||||
|
|
@ -628,6 +638,7 @@ async fn direct_exec_command_with_env_var_resolves_at_load_time() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
let result =
|
||||
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
||||
|
|
@ -697,6 +708,7 @@ async fn http_hook_url_env_expansion_end_to_end() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
let pre_result =
|
||||
dispatcher::dispatch_pre_tool_use(®istry, &pre_tool_use_envelope("read_file"), &ctx)
|
||||
|
|
@ -779,6 +791,7 @@ async fn lenient_parsing_with_mixed_claude_events() {
|
|||
let ctx = RunContext {
|
||||
session_id: "test",
|
||||
workspace_root: dir.path().to_str().unwrap(),
|
||||
process_scope: None,
|
||||
};
|
||||
let result = dispatcher::dispatch_pre_tool_use(
|
||||
®istry,
|
||||
|
|
|
|||
Loading…
Reference in a new issue