Synced from monorepo
Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
|
|
@ -594,8 +594,12 @@ impl ToolBridge {
|
|||
|
||||
/// Drain newly-completed bash background tasks not yet reported.
|
||||
/// Marks returned tasks in [`ReportedTaskCompletions`] to prevent
|
||||
/// duplicate reminders from [`TaskCompletionReminder`].
|
||||
pub async fn drain_between_turn_bash_completions(&self) -> Vec<TaskSnapshot> {
|
||||
/// duplicate reminders from [`TaskCompletionReminder`]. Reserved IDs stay
|
||||
/// unreported for a later genuine user turn.
|
||||
pub async fn drain_between_turn_bash_completions(
|
||||
&self,
|
||||
reserved_ids: &[String],
|
||||
) -> Vec<TaskSnapshot> {
|
||||
let tasks = match self.list_tasks().await {
|
||||
Some(t) => t,
|
||||
None => return Vec::new(),
|
||||
|
|
@ -625,6 +629,7 @@ impl ToolBridge {
|
|||
completed
|
||||
.into_iter()
|
||||
.filter(|t| task_owned_by_session(t, my_owner.as_deref()))
|
||||
.filter(|t| !reserved_ids.contains(&t.task_id))
|
||||
.filter(|t| state.mark_reported(&t.task_id))
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -817,7 +822,7 @@ mod tests {
|
|||
terminal: Some(backend),
|
||||
};
|
||||
|
||||
let drained = bridge.drain_between_turn_bash_completions().await;
|
||||
let drained = bridge.drain_between_turn_bash_completions(&[]).await;
|
||||
let ids: Vec<&str> = drained.iter().map(|t| t.task_id.as_str()).collect();
|
||||
|
||||
assert!(ids.contains(&"mine-task"), "own task must drain: {ids:?}");
|
||||
|
|
@ -830,4 +835,36 @@ mod tests {
|
|||
"another session's task must NOT leak into this session: {ids:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn between_turn_bash_completions_skip_reserved_ids_without_reporting_them() {
|
||||
let toolset = FinalizedToolset::empty_for_test();
|
||||
{
|
||||
let mut res = toolset.resources.lock().await;
|
||||
res.register_state::<ReportedTaskCompletions>();
|
||||
}
|
||||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal {
|
||||
tasks: vec![completed_task("reserved", None)],
|
||||
});
|
||||
let bridge = ToolBridge {
|
||||
registry: Arc::new(toolset),
|
||||
terminal: Some(backend),
|
||||
};
|
||||
|
||||
assert!(
|
||||
bridge
|
||||
.drain_between_turn_bash_completions(&["reserved".to_string()])
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
bridge
|
||||
.drain_between_turn_bash_completions(&[])
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|task| task.task_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["reserved".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ pub mod file_system;
|
|||
pub mod mock_fs;
|
||||
#[cfg(unix)]
|
||||
pub mod shell_state;
|
||||
#[cfg(unix)]
|
||||
pub mod static_shell;
|
||||
pub mod terminal;
|
||||
|
||||
pub use cgroup::{CgroupMemoryConfig, PROCESS_OOM_EXIT_CODE};
|
||||
|
|
|
|||
|
|
@ -104,8 +104,10 @@ dump_bash_state() {
|
|||
env_vars=$(builtin export -p 2>/dev/null | command grep -viE '_proxy=|GROK_SANDBOX|GROK_AGENT=|SUDO_ASKPASS|GROK_ASKPASS|ELECTRON_RUN_AS_NODE|SSH_AUTH_SOCK|DBUS_SESSION_BUS_ADDRESS|XDG_RUNTIME_DIR|WAYLAND_DISPLAY|GPG_TTY' || true)
|
||||
_emit_encoded "$env_vars" "ENV_VARS_B64"
|
||||
|
||||
# errexit/pipefail here are this function's own `set -euo pipefail` (set is
|
||||
# shell-global in bash); replaying them would abort later user commands.
|
||||
local posix_opts
|
||||
posix_opts=$(builtin shopt -po 2>/dev/null | command grep -v '^set -o nounset$' | command grep -v '^set +o nounset$' || true)
|
||||
posix_opts=$(builtin shopt -po 2>/dev/null | command grep -vE '^set [-+]o (nounset|errexit|pipefail)$' || true)
|
||||
_emit_encoded "$posix_opts" "POSIX_OPTS_B64"
|
||||
|
||||
local bash_opts
|
||||
|
|
@ -158,8 +160,11 @@ function dump_zsh_state() {
|
|||
env_vars=$(builtin typeset -xp 2>/dev/null | command grep -viE '_proxy=|GROK_SANDBOX|GROK_AGENT=|SUDO_ASKPASS|GROK_ASKPASS|ELECTRON_RUN_AS_NODE|SSH_AUTH_SOCK|DBUS_SESSION_BUS_ADDRESS|XDG_RUNTIME_DIR|WAYLAND_DISPLAY|GPG_TTY' || true)
|
||||
_emit_encoded "$env_vars" "ENV_VARS_B64"
|
||||
|
||||
# errreturn/pipefail here are this function's own `emulate -L` options
|
||||
# (setopt lists them while inside); replaying them would abort later user
|
||||
# commands.
|
||||
local zsh_opts
|
||||
zsh_opts=$(setopt 2>/dev/null | command grep -v '^nounset$' | command awk '{printf "builtin setopt %s 2>/dev/null || true\n", $0}' || true)
|
||||
zsh_opts=$(setopt 2>/dev/null | command grep -vE '^(nounset|errexit|errreturn|pipefail)$' | command awk '{printf "builtin setopt %s 2>/dev/null || true\n", $0}' || true)
|
||||
_emit_encoded "$zsh_opts" "ZSH_OPTS_B64"
|
||||
|
||||
local all_functions
|
||||
|
|
@ -361,6 +366,7 @@ impl ShellState {
|
|||
user_command: &str,
|
||||
cwd_override: Option<&Path>,
|
||||
search_shadows: super::SearchShadowConfig,
|
||||
spawn_notice: Option<&str>,
|
||||
) -> std::io::Result<PreparedCommand> {
|
||||
let dump_script = self.shell.dump_script();
|
||||
let dump_fn = self.shell.dump_function_name();
|
||||
|
|
@ -408,6 +414,7 @@ impl ShellState {
|
|||
builtin export GROK_AGENT=1; \
|
||||
builtin export PWD=\"$(builtin pwd)\"; \
|
||||
builtin shopt -s expand_aliases 2>/dev/null; {sudo_inject}{search_inject}\
|
||||
builtin printf '%s' \"${{2:-}}\"; \
|
||||
builtin eval \"$1\" 2>&1; }}; \
|
||||
COMMAND_EXIT_CODE=$?; {dump_fn} >&4; builtin exit $COMMAND_EXIT_CODE"
|
||||
),
|
||||
|
|
@ -423,6 +430,7 @@ impl ShellState {
|
|||
builtin export GROK_AGENT=1; \
|
||||
builtin export PWD=\"$(builtin pwd)\"; \
|
||||
builtin setopt aliases 2>/dev/null; {sudo_inject}{search_inject}\
|
||||
builtin printf '%s' \"${{2:-}}\"; \
|
||||
builtin eval \"$1\" 2>&1; }}; \
|
||||
COMMAND_EXIT_CODE=$?; {dump_fn} >&4; builtin exit $COMMAND_EXIT_CODE"
|
||||
),
|
||||
|
|
@ -430,7 +438,7 @@ impl ShellState {
|
|||
|
||||
let effective_cwd = cwd_override.unwrap_or(&self.cwd);
|
||||
|
||||
let args: Vec<String> = match self.shell {
|
||||
let mut args: Vec<String> = match self.shell {
|
||||
ShellKind::Bash => vec![
|
||||
"-O".into(),
|
||||
"extglob".into(),
|
||||
|
|
@ -441,6 +449,9 @@ impl ShellState {
|
|||
],
|
||||
ShellKind::Zsh => vec!["-c".into(), wrapper, "--".into(), user_command.into()],
|
||||
};
|
||||
if let Some(notice) = spawn_notice {
|
||||
args.push(notice.into());
|
||||
}
|
||||
|
||||
let fd_mappings = vec![
|
||||
FdMapping {
|
||||
|
|
@ -855,6 +866,7 @@ mod tests {
|
|||
"export GROK_TEST_VAR=hello",
|
||||
None,
|
||||
crate::computer::local::SearchShadowConfig::default(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -914,6 +926,7 @@ mod tests {
|
|||
command,
|
||||
None,
|
||||
crate::computer::local::SearchShadowConfig::default(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut cmd = tokio::process::Command::new(&prep.binary);
|
||||
|
|
@ -1040,6 +1053,7 @@ mod tests {
|
|||
"true",
|
||||
None,
|
||||
crate::computer::local::SearchShadowConfig::default(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let wrapper = prep
|
||||
|
|
@ -1124,7 +1138,7 @@ mod tests {
|
|||
let cwd = std::env::current_dir().unwrap();
|
||||
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
|
||||
|
||||
let prep = state.prepare_command("true", None, shadows).unwrap();
|
||||
let prep = state.prepare_command("true", None, shadows, None).unwrap();
|
||||
// Shadows enabled → the self-resolving find/grep functions are always
|
||||
// installed (they fall back to the OS binary if bfs/ugrep aren't found).
|
||||
assert!(
|
||||
|
|
|
|||
311
crates/codegen/xai-grok-tools/src/computer/local/static_shell.rs
Normal file
311
crates/codegen/xai-grok-tools/src/computer/local/static_shell.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
//! Static (replay-only) login-shell capture for the non-persistent bash path.
|
||||
//!
|
||||
//! Sources the user's rc once at init and captures function and alias
|
||||
//! definitions; every command replays that fixed snapshot in a fresh shell.
|
||||
//! Nothing is ever written back: no state dump, no tracked cwd, no
|
||||
//! persistence across calls. Env vars are deliberately not captured here —
|
||||
//! the host-side login env capture applies them with fill-gaps precedence.
|
||||
//!
|
||||
//! Self-contained by design: independent of the cursor persistent shell's
|
||||
//! `shell_state` machinery so changes to either path cannot affect the other.
|
||||
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use command_fds::FdMapping;
|
||||
use nix::libc;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
pub use xai_grok_config::shell::UnixShellKind;
|
||||
|
||||
const INIT_MARKER: &str = "__GROK_STATIC_SHELL_MARKER__";
|
||||
const INIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// A fixed snapshot of rc-defined functions and aliases, captured once.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StaticShellSnapshot {
|
||||
pub snapshot: String,
|
||||
pub shell: UnixShellKind,
|
||||
}
|
||||
|
||||
fn shell_binary(shell: UnixShellKind) -> &'static str {
|
||||
xai_grok_config::shell::unix_shell_path(shell)
|
||||
}
|
||||
|
||||
fn rc_file_name(shell: UnixShellKind) -> &'static str {
|
||||
match shell {
|
||||
UnixShellKind::Bash => ".bashrc",
|
||||
UnixShellKind::Zsh => ".zshrc",
|
||||
}
|
||||
}
|
||||
|
||||
fn sudo_alias_injection() -> String {
|
||||
match std::env::var("SUDO_ASKPASS") {
|
||||
Ok(val) if !val.is_empty() => "alias sudo='sudo -A'; ".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl StaticShellSnapshot {
|
||||
/// Source the rc once in a login shell and capture alias and function
|
||||
/// definitions between SOH markers. Returns an empty snapshot on any
|
||||
/// failure or timeout, degrading to a plain shell.
|
||||
pub async fn init(cwd: &Path) -> Self {
|
||||
let shell = xai_grok_config::shell::detect_unix_shell_kind();
|
||||
|
||||
let capture = match shell {
|
||||
UnixShellKind::Bash => "builtin alias -p 2>/dev/null; builtin declare -f 2>/dev/null",
|
||||
UnixShellKind::Zsh => {
|
||||
"{ builtin alias -L; builtin alias -gL; builtin alias -sL } 2>/dev/null; \
|
||||
builtin typeset -f 2>/dev/null"
|
||||
}
|
||||
};
|
||||
let script = format!(
|
||||
"source \"$HOME/{rc}\" 2>/dev/null; \
|
||||
printf '\\x01'; {capture}; printf '\\x01'",
|
||||
rc = rc_file_name(shell)
|
||||
);
|
||||
|
||||
let result = tokio::time::timeout(INIT_TIMEOUT, async {
|
||||
let mut cmd = tokio::process::Command::new(shell_binary(shell));
|
||||
cmd.args(["-lc", &script])
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
crate::util::detach_command(&mut cmd);
|
||||
cmd.envs(crate::util::pager_env());
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
|
||||
let mut stdout_buf = Vec::new();
|
||||
if let Some(ref mut stdout) = child.stdout {
|
||||
stdout.read_to_end(&mut stdout_buf).await.ok();
|
||||
}
|
||||
let status = child.wait().await.ok()?;
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&stdout_buf);
|
||||
let parts: Vec<&str> = stdout.split('\x01').collect();
|
||||
parts.get(1).map(|s| s.to_string())
|
||||
})
|
||||
.await;
|
||||
|
||||
let snapshot = match result {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
tracing::warn!("static shell capture failed; using empty snapshot");
|
||||
String::new()
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"static shell capture timed out after {}s; using empty snapshot",
|
||||
INIT_TIMEOUT.as_secs()
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
let _ = INIT_MARKER;
|
||||
Self { snapshot, shell }
|
||||
}
|
||||
|
||||
/// Build the replay wrapper: read the snapshot from fd 3, eval it (alias
|
||||
/// and function definitions), then eval the user command; the shell exits
|
||||
/// with the user command's status. A failing snapshot replay does not
|
||||
/// abort the command.
|
||||
pub fn prepare_command(
|
||||
&self,
|
||||
user_command: &str,
|
||||
search_shadows: super::SearchShadowConfig,
|
||||
) -> std::io::Result<PreparedStaticCommand> {
|
||||
let sudo_inject = sudo_alias_injection();
|
||||
let search_inject = super::embedded_search_tools::search_injection(search_shadows);
|
||||
|
||||
let (state_in_read, state_in_write) = os_pipe()?;
|
||||
set_cloexec(&state_in_write)?;
|
||||
|
||||
let wrapper = match self.shell {
|
||||
UnixShellKind::Bash => format!(
|
||||
"snap=$(command cat <&3); builtin shopt -s extglob 2>/dev/null; \
|
||||
builtin shopt -s expand_aliases 2>/dev/null; \
|
||||
builtin eval -- \"$snap\"; \
|
||||
builtin export GROK_AGENT=1; \
|
||||
builtin export PWD=\"$(builtin pwd)\"; {sudo_inject}{search_inject}\
|
||||
builtin eval \"$1\" 2>&1"
|
||||
),
|
||||
UnixShellKind::Zsh => format!(
|
||||
"snap=$(command cat <&3); \
|
||||
builtin setopt nonomatch 2>/dev/null; \
|
||||
builtin eval \"$snap\"; \
|
||||
builtin export GROK_AGENT=1; \
|
||||
builtin export PWD=\"$(builtin pwd)\"; \
|
||||
builtin setopt aliases 2>/dev/null; {sudo_inject}{search_inject}\
|
||||
builtin eval \"$1\" 2>&1"
|
||||
),
|
||||
};
|
||||
|
||||
let args: Vec<String> = match self.shell {
|
||||
UnixShellKind::Bash => vec![
|
||||
"-O".into(),
|
||||
"extglob".into(),
|
||||
"-c".into(),
|
||||
wrapper,
|
||||
"--".into(),
|
||||
user_command.into(),
|
||||
],
|
||||
UnixShellKind::Zsh => vec!["-c".into(), wrapper, "--".into(), user_command.into()],
|
||||
};
|
||||
|
||||
Ok(PreparedStaticCommand {
|
||||
binary: shell_binary(self.shell).to_string(),
|
||||
args,
|
||||
fd_mappings: vec![FdMapping {
|
||||
parent_fd: state_in_read,
|
||||
child_fd: 3,
|
||||
}],
|
||||
state_in_write,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PreparedStaticCommand {
|
||||
pub binary: String,
|
||||
pub args: Vec<String>,
|
||||
pub fd_mappings: Vec<FdMapping>,
|
||||
pub state_in_write: OwnedFd,
|
||||
}
|
||||
|
||||
/// Write the snapshot to the pipe, then close the fd so the child sees EOF.
|
||||
pub async fn write_snapshot_to_pipe(snapshot: &str, fd: OwnedFd) -> std::io::Result<()> {
|
||||
let data = snapshot.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use std::io::Write;
|
||||
// Safety: we own the fd.
|
||||
let mut file = unsafe { std::fs::File::from_raw_fd(fd.as_raw_fd()) };
|
||||
std::mem::forget(fd);
|
||||
file.write_all(data.as_bytes())?;
|
||||
file.flush()?;
|
||||
drop(file);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
fn os_pipe() -> std::io::Result<(OwnedFd, OwnedFd)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)
|
||||
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let (read_fd, write_fd) =
|
||||
nix::unistd::pipe().map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
|
||||
let _ = set_cloexec(&read_fd);
|
||||
let _ = set_cloexec(&write_fd);
|
||||
Ok((read_fd, write_fd))
|
||||
}
|
||||
}
|
||||
|
||||
fn set_cloexec(fd: &OwnedFd) -> std::io::Result<()> {
|
||||
let raw = fd.as_raw_fd();
|
||||
let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) };
|
||||
if flags < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let ret = unsafe { libc::fcntl(raw, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
|
||||
if ret < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use command_fds::CommandFdExt;
|
||||
|
||||
fn bash_available() -> bool {
|
||||
std::path::Path::new("/bin/bash").exists()
|
||||
}
|
||||
|
||||
async fn run_static(snapshot: &str, command: &str) -> std::process::Output {
|
||||
let state = StaticShellSnapshot {
|
||||
snapshot: snapshot.to_string(),
|
||||
shell: UnixShellKind::Bash,
|
||||
};
|
||||
let prep = state
|
||||
.prepare_command(
|
||||
command,
|
||||
crate::computer::local::SearchShadowConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut cmd = tokio::process::Command::new(&prep.binary);
|
||||
cmd.args(&prep.args)
|
||||
.current_dir(std::env::current_dir().unwrap())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
cmd.fd_mappings(prep.fd_mappings).unwrap();
|
||||
let child = cmd.spawn().unwrap();
|
||||
drop(cmd);
|
||||
|
||||
let snap = state.snapshot.clone();
|
||||
let write_handle =
|
||||
tokio::spawn(async move { write_snapshot_to_pipe(&snap, prep.state_in_write).await });
|
||||
let output = child.wait_with_output().await.unwrap();
|
||||
write_handle.await.unwrap().unwrap();
|
||||
output
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replays_aliases_and_functions() {
|
||||
if !bash_available() {
|
||||
return;
|
||||
}
|
||||
let output = run_static(
|
||||
"alias grok_alias_probe='echo ALIAS_OK'\ngrok_fn_probe() { echo FN_OK; }\n",
|
||||
"grok_alias_probe && grok_fn_probe",
|
||||
)
|
||||
.await;
|
||||
assert!(output.status.success(), "command failed: {output:?}");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("ALIAS_OK") && stdout.contains("FN_OK"),
|
||||
"alias and function must be replayed: {stdout:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_command_exit_code_propagates_past_bad_snapshot() {
|
||||
if !bash_available() {
|
||||
return;
|
||||
}
|
||||
let output = run_static("this-is-not-a-command 2>/dev/null\n", "exit 7").await;
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(7),
|
||||
"user command exit code must propagate: {output:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_snapshot_runs_plain() {
|
||||
if !bash_available() {
|
||||
return;
|
||||
}
|
||||
let output = run_static("", "echo PLAIN_OK").await;
|
||||
assert!(output.status.success());
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).contains("PLAIN_OK"),
|
||||
"empty snapshot must degrade to a plain shell"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -150,8 +150,14 @@ enum TerminalCommand {
|
|||
reply: oneshot::Sender<Option<PathBuf>>,
|
||||
},
|
||||
|
||||
WarmShell {
|
||||
cwd: PathBuf,
|
||||
},
|
||||
|
||||
/// Kill all running foreground processes owned by a specific session.
|
||||
KillForegroundCommandsByOwner { owner_session_id: String },
|
||||
KillForegroundCommandsByOwner {
|
||||
owner_session_id: String,
|
||||
},
|
||||
|
||||
/// Kill all running background tasks owned by a specific session.
|
||||
KillTasksByOwner {
|
||||
|
|
@ -502,6 +508,8 @@ struct LocalTerminalActor {
|
|||
/// Whether persistent shell state is enabled.
|
||||
persistent_shell: bool,
|
||||
|
||||
login_shell_capture: bool,
|
||||
|
||||
/// Per-backend `find`→`bfs` / `grep`→`ugrep` shadow enable state, resolved
|
||||
/// once by the host and baked in at construction. Passed to
|
||||
/// `search_injection` per command rather than read from a process-global, so
|
||||
|
|
@ -513,12 +521,12 @@ struct LocalTerminalActor {
|
|||
#[cfg(unix)]
|
||||
shell_state: Option<shell_state::ShellState>,
|
||||
|
||||
/// Captured login-shell PATH for the non-persistent path.
|
||||
/// Lazily initialized on first command when `persistent_shell` is false.
|
||||
/// Ensures CLI tools from rc files are discoverable even without a full
|
||||
/// shell snapshot.
|
||||
/// Static alias/function snapshot for the non-persistent path.
|
||||
#[cfg(unix)]
|
||||
login_path_env: Option<HashMap<String, String>>,
|
||||
static_shell: Option<super::static_shell::StaticShellSnapshot>,
|
||||
|
||||
#[cfg(unix)]
|
||||
login_env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl LocalTerminalActor {
|
||||
|
|
@ -528,6 +536,7 @@ impl LocalTerminalActor {
|
|||
cgroup_guard: CgroupGuard,
|
||||
memory_monitor: MemoryMonitor,
|
||||
persistent_shell: bool,
|
||||
login_shell_capture: bool,
|
||||
search_shadows: SearchShadowConfig,
|
||||
completed_task_ttl: Duration,
|
||||
foreground_block_budget: Duration,
|
||||
|
|
@ -547,11 +556,14 @@ impl LocalTerminalActor {
|
|||
_cgroup_guard: cgroup_guard,
|
||||
memory_monitor,
|
||||
persistent_shell,
|
||||
login_shell_capture,
|
||||
search_shadows,
|
||||
#[cfg(unix)]
|
||||
shell_state: None,
|
||||
#[cfg(unix)]
|
||||
login_path_env: None,
|
||||
static_shell: None,
|
||||
#[cfg(unix)]
|
||||
login_env: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -570,15 +582,19 @@ impl LocalTerminalActor {
|
|||
return self.spawn_persistent_command(command, cwd, env).await;
|
||||
}
|
||||
|
||||
// Lazy-init: capture the user's login-shell PATH on first command so
|
||||
// CLI tools from rc files (.bashrc, .zshrc, virtualenvs) are visible.
|
||||
#[cfg(unix)]
|
||||
if self.login_path_env.is_none() {
|
||||
self.login_path_env = Some(capture_login_path().await);
|
||||
if self.login_shell_capture && login_env_capture_enabled() {
|
||||
self.ensure_static_shell_initialized(cwd).await;
|
||||
return self.spawn_static_command(command, cwd, env).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let login_env = self.login_path_env.as_ref();
|
||||
if self.login_env.is_none() {
|
||||
self.login_env = Some(capture_login_env().await);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let login_env = self.login_env.as_ref();
|
||||
#[cfg(not(unix))]
|
||||
let login_env: Option<&HashMap<String, String>> = None;
|
||||
|
||||
|
|
@ -591,6 +607,134 @@ impl LocalTerminalActor {
|
|||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn ensure_static_shell_initialized(&mut self, cwd: &std::path::Path) {
|
||||
if self.static_shell.is_some() && self.login_env.is_some() {
|
||||
return;
|
||||
}
|
||||
let (snapshot, login_env) = tokio::join!(
|
||||
async {
|
||||
if self.static_shell.is_none() {
|
||||
Some(super::static_shell::StaticShellSnapshot::init(cwd).await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
async {
|
||||
if self.login_env.is_none() {
|
||||
Some(capture_login_env().await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
);
|
||||
if let Some(snapshot) = snapshot {
|
||||
self.static_shell = Some(snapshot);
|
||||
}
|
||||
if let Some(env) = login_env {
|
||||
self.login_env = Some(env);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn spawn_static_command(
|
||||
&mut self,
|
||||
command: &str,
|
||||
cwd: &std::path::Path,
|
||||
env: &HashMap<String, String>,
|
||||
) -> Result<SpawnResult, ComputerError> {
|
||||
use command_fds::CommandFdExt;
|
||||
|
||||
let static_shell = self.static_shell.as_ref().unwrap();
|
||||
let prep = static_shell
|
||||
.prepare_command(command, self.search_shadows)
|
||||
.map_err(|e| ComputerError::io(format!("prepare static command: {e}")))?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&prep.binary);
|
||||
cmd.args(&prep.args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
if let Some(login) = self.login_env.as_ref() {
|
||||
for (key, value) in login {
|
||||
if key != "PATH" && std::env::var_os(key).is_none() {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
cmd.envs(shell_state::shell_env_overrides());
|
||||
for (key, value) in env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
cmd.envs(crate::util::pager_env());
|
||||
if let Some(path) = self.login_env.as_ref().and_then(|l| l.get("PATH")) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
crate::util::apply_grok_agent_marker(&mut cmd);
|
||||
|
||||
cmd.fd_mappings(prep.fd_mappings)
|
||||
.map_err(|e| ComputerError::io(format!("fd mapping: {e}")))?;
|
||||
|
||||
unsafe {
|
||||
cmd.pre_exec(crate::util::detach_from_tty);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if xai_grok_sandbox::should_restrict_child_network() {
|
||||
unsafe {
|
||||
cmd.pre_exec(|| xai_grok_sandbox::child_net::install_child_network_filter());
|
||||
}
|
||||
}
|
||||
|
||||
let child = cmd.spawn().map_err(|e| {
|
||||
ComputerError::io_with_kind(format!("spawn shell in {}: {e}", cwd.display()), e.kind())
|
||||
})?;
|
||||
drop(cmd);
|
||||
|
||||
let mut process_group = crate::util::ProcessGroup::new()
|
||||
.map_err(|e| ComputerError::io(format!("ProcessGroup::new: {e}")))?;
|
||||
if let Err(e) = process_group.attach(&child) {
|
||||
tracing::debug!("Failed to attach static-shell child to ProcessGroup: {e}");
|
||||
}
|
||||
|
||||
let snapshot = static_shell.snapshot.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
super::static_shell::write_snapshot_to_pipe(&snapshot, prep.state_in_write).await
|
||||
{
|
||||
tracing::debug!("failed to write static shell snapshot to pipe: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(SpawnResult {
|
||||
child,
|
||||
process_group,
|
||||
state_dump_handle: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn ensure_persistent_shell_initialized(&mut self, cwd: &std::path::Path) {
|
||||
if self.shell_state.is_some() {
|
||||
return;
|
||||
}
|
||||
let shell = shell_state::ShellKind::detect();
|
||||
match shell_state::ShellState::init(shell, cwd).await {
|
||||
Ok(state) => self.shell_state = Some(state),
|
||||
Err(e) => {
|
||||
tracing::warn!("persistent shell init failed, using empty state: {e}");
|
||||
self.shell_state = Some(shell_state::ShellState {
|
||||
cwd: cwd.to_path_buf(),
|
||||
snapshot: String::new(),
|
||||
shell,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a command with persistent shell state: restore the prior snapshot
|
||||
/// via fd 3, run the user command, dump the new state to fd 4.
|
||||
#[cfg(unix)]
|
||||
|
|
@ -602,41 +746,41 @@ impl LocalTerminalActor {
|
|||
) -> Result<SpawnResult, ComputerError> {
|
||||
use command_fds::CommandFdExt;
|
||||
|
||||
if self.shell_state.is_none() {
|
||||
let shell = shell_state::ShellKind::detect();
|
||||
match shell_state::ShellState::init(shell, cwd).await {
|
||||
Ok(state) => self.shell_state = Some(state),
|
||||
Err(e) => {
|
||||
tracing::warn!("persistent shell init failed, using empty state: {e}");
|
||||
self.shell_state = Some(shell_state::ShellState {
|
||||
cwd: cwd.to_path_buf(),
|
||||
snapshot: String::new(),
|
||||
shell,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ensure_persistent_shell_initialized(cwd).await;
|
||||
|
||||
let shell_state = self.shell_state.as_ref().unwrap();
|
||||
// When the persistent shell already tracks a
|
||||
// model-set cwd (the model ran a `cd`), honor it unconditionally.
|
||||
// The bash tool always populates `request.working_directory` with
|
||||
// the workspace's resolved Cwd, even when no per-call override is
|
||||
// intended; treating that as "explicit override and reset" was the
|
||||
// bug that made `cd` not persist across consecutive Shell calls.
|
||||
//
|
||||
// Per-call working_directory overrides arrive through the
|
||||
// shell adapter, which prefixes a subshell `(cd <wd> &&
|
||||
// …)` to the command string — that mechanism is local to a single
|
||||
// call and does NOT mutate the parent shell's `$PWD`, so we never
|
||||
// need to surface it as a `cwd_override` here.
|
||||
let cwd_override: Option<&std::path::Path> = None;
|
||||
// Silence the unused-binding lint on the inbound `cwd` parameter:
|
||||
// it's still threaded into `spawn_command` (the non-persistent
|
||||
// fallback path) below.
|
||||
let _ = cwd;
|
||||
let tracked_cwd_alive = match tokio::fs::metadata(&shell_state.cwd).await {
|
||||
Ok(m) => m.is_dir(),
|
||||
Err(e) => !matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
|
||||
),
|
||||
};
|
||||
let (cwd_override, spawn_notice): (Option<&std::path::Path>, Option<String>) =
|
||||
if tracked_cwd_alive {
|
||||
(None, None)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tracked_cwd = %shell_state.cwd.display(),
|
||||
fallback = %cwd.display(),
|
||||
"persistent shell cwd no longer exists; falling back to request working directory"
|
||||
);
|
||||
(
|
||||
Some(cwd),
|
||||
Some(format!(
|
||||
"warning: shell working directory {} no longer exists; this command ran in {} instead\n",
|
||||
shell_state.cwd.display(),
|
||||
cwd.display()
|
||||
)),
|
||||
)
|
||||
};
|
||||
let prep = shell_state
|
||||
.prepare_command(command, cwd_override, self.search_shadows)
|
||||
.prepare_command(
|
||||
command,
|
||||
cwd_override,
|
||||
self.search_shadows,
|
||||
spawn_notice.as_deref(),
|
||||
)
|
||||
.map_err(|e| ComputerError::io(format!("prepare persistent command: {e}")))?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&prep.binary);
|
||||
|
|
@ -673,7 +817,12 @@ impl LocalTerminalActor {
|
|||
}
|
||||
}
|
||||
|
||||
let child = cmd.spawn().map_err(ComputerError::from)?;
|
||||
let child = cmd.spawn().map_err(|e| {
|
||||
ComputerError::io_with_kind(
|
||||
format!("spawn shell in {}: {e}", prep.cwd.display()),
|
||||
e.kind(),
|
||||
)
|
||||
})?;
|
||||
// Drop cmd to release the FdMapping OwnedFds held in its pre_exec closure.
|
||||
// Without this, the parent keeps the write-end of the state-out pipe open,
|
||||
// preventing the dump reader from seeing EOF.
|
||||
|
|
@ -792,11 +941,28 @@ impl LocalTerminalActor {
|
|||
}
|
||||
TerminalCommand::GetShellCwd { reply } => {
|
||||
#[cfg(unix)]
|
||||
let cwd = self.shell_state.as_ref().map(|s| s.cwd.clone());
|
||||
let cwd = if self.persistent_shell {
|
||||
self.shell_state.as_ref().map(|s| s.cwd.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let cwd = None;
|
||||
let _ = reply.send(cwd);
|
||||
}
|
||||
TerminalCommand::WarmShell { cwd } => {
|
||||
#[cfg(unix)]
|
||||
if self.persistent_shell {
|
||||
// Cursor's persistent shell initializes lazily on first
|
||||
// command; warming is only for the static capture path.
|
||||
} else if self.login_shell_capture && login_env_capture_enabled() {
|
||||
self.ensure_static_shell_initialized(&cwd).await;
|
||||
} else if self.login_env.is_none() {
|
||||
self.login_env = Some(capture_login_env().await);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = cwd;
|
||||
}
|
||||
TerminalCommand::KillForegroundCommands => {
|
||||
self.kill_foreground_commands().await;
|
||||
}
|
||||
|
|
@ -1401,7 +1567,7 @@ impl LocalTerminalActor {
|
|||
// branch — see the comment there). This pass must still fire
|
||||
// `send_task_complete` unconditionally for newly-completed
|
||||
// background tasks so the pager UI, persistence, and
|
||||
// `AutoWakeDeliveredIds` bookkeeping all still get the snapshot.
|
||||
// `TaskCompletionReservations` bookkeeping all still get the snapshot.
|
||||
for task_id in newly_completed {
|
||||
if let Some(process) = self.processes.get(&task_id) {
|
||||
let snapshot = process.to_task_snapshot(&task_id).await;
|
||||
|
|
@ -1968,7 +2134,7 @@ impl LocalTerminalBackend {
|
|||
/// If `memory_config` is provided, a cgroupv2 memory limit is enforced on
|
||||
/// all spawned commands (Linux only; silently degrades to no-op elsewhere).
|
||||
pub fn new() -> Self {
|
||||
Self::new_inner(None, false, false, SearchShadowConfig::default())
|
||||
Self::new_inner(None, false, false, true, SearchShadowConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend with persistent shell state.
|
||||
|
|
@ -1977,19 +2143,31 @@ impl LocalTerminalBackend {
|
|||
/// and shell options persist across command invocations. The user's login shell
|
||||
/// (bash or zsh) is detected and its rc files are loaded once on first command.
|
||||
pub fn with_persistent_shell() -> Self {
|
||||
Self::new_inner(None, false, true, SearchShadowConfig::default())
|
||||
Self::new_inner(None, false, true, true, SearchShadowConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend with cgroup memory limits.
|
||||
///
|
||||
/// See [`CgroupMemoryConfig`] for details on the soft/hard limit model.
|
||||
pub fn with_memory_limit(config: CgroupMemoryConfig) -> Self {
|
||||
Self::new_inner(Some(config), false, false, SearchShadowConfig::default())
|
||||
Self::new_inner(
|
||||
Some(config),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
SearchShadowConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend with both memory limits and persistent shell.
|
||||
pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self {
|
||||
Self::new_inner(Some(config), false, true, SearchShadowConfig::default())
|
||||
Self::new_inner(
|
||||
Some(config),
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
SearchShadowConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend using spawn_local (for single-threaded runtimes).
|
||||
|
|
@ -1997,7 +2175,14 @@ impl LocalTerminalBackend {
|
|||
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
|
||||
/// state, baked into this backend (see [`SearchShadowConfig`]).
|
||||
pub fn new_local(search_shadows: SearchShadowConfig) -> Self {
|
||||
Self::new_inner(None, true, false, search_shadows)
|
||||
Self::new_inner(None, true, false, true, search_shadows)
|
||||
}
|
||||
|
||||
pub fn new_local_with_login_shell_capture(
|
||||
search_shadows: SearchShadowConfig,
|
||||
login_shell_capture: bool,
|
||||
) -> Self {
|
||||
Self::new_inner(None, true, false, login_shell_capture, search_shadows)
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend using spawn_local with persistent shell.
|
||||
|
|
@ -2005,12 +2190,18 @@ impl LocalTerminalBackend {
|
|||
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
|
||||
/// state, baked into this backend (see [`SearchShadowConfig`]).
|
||||
pub fn new_local_with_persistent_shell(search_shadows: SearchShadowConfig) -> Self {
|
||||
Self::new_inner(None, true, true, search_shadows)
|
||||
Self::new_inner(None, true, true, true, search_shadows)
|
||||
}
|
||||
|
||||
/// Create a new LocalTerminalBackend using spawn_local with memory limits.
|
||||
pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self {
|
||||
Self::new_inner(Some(config), true, false, SearchShadowConfig::default())
|
||||
Self::new_inner(
|
||||
Some(config),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
SearchShadowConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Test-only: a spawn_local backend that enrolls spawned children into
|
||||
|
|
@ -2025,6 +2216,7 @@ impl LocalTerminalBackend {
|
|||
None,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
search_shadows,
|
||||
COMPLETED_TASK_TTL,
|
||||
FOREGROUND_BLOCK_BUDGET,
|
||||
|
|
@ -2040,6 +2232,7 @@ impl LocalTerminalBackend {
|
|||
None,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
SearchShadowConfig::default(),
|
||||
ttl,
|
||||
FOREGROUND_BLOCK_BUDGET,
|
||||
|
|
@ -2055,6 +2248,7 @@ impl LocalTerminalBackend {
|
|||
None,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
SearchShadowConfig::default(),
|
||||
COMPLETED_TASK_TTL,
|
||||
budget,
|
||||
|
|
@ -2070,6 +2264,7 @@ impl LocalTerminalBackend {
|
|||
None,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
SearchShadowConfig::default(),
|
||||
COMPLETED_TASK_TTL,
|
||||
FOREGROUND_BLOCK_BUDGET,
|
||||
|
|
@ -2082,12 +2277,14 @@ impl LocalTerminalBackend {
|
|||
memory_config: Option<CgroupMemoryConfig>,
|
||||
use_spawn_local: bool,
|
||||
persistent_shell: bool,
|
||||
login_shell_capture: bool,
|
||||
search_shadows: SearchShadowConfig,
|
||||
) -> Self {
|
||||
Self::new_with_ttl(
|
||||
memory_config,
|
||||
use_spawn_local,
|
||||
persistent_shell,
|
||||
login_shell_capture,
|
||||
search_shadows,
|
||||
COMPLETED_TASK_TTL,
|
||||
foreground_block_budget_from_env(),
|
||||
|
|
@ -2100,6 +2297,7 @@ impl LocalTerminalBackend {
|
|||
memory_config: Option<CgroupMemoryConfig>,
|
||||
use_spawn_local: bool,
|
||||
persistent_shell: bool,
|
||||
login_shell_capture: bool,
|
||||
search_shadows: SearchShadowConfig,
|
||||
completed_task_ttl: Duration,
|
||||
foreground_block_budget: Duration,
|
||||
|
|
@ -2125,6 +2323,7 @@ impl LocalTerminalBackend {
|
|||
cgroup_guard,
|
||||
memory_monitor,
|
||||
persistent_shell,
|
||||
login_shell_capture,
|
||||
search_shadows,
|
||||
completed_task_ttl,
|
||||
foreground_block_budget,
|
||||
|
|
@ -2261,6 +2460,15 @@ impl TerminalBackend for LocalTerminalBackend {
|
|||
reply_rx.await.ok().flatten()
|
||||
}
|
||||
|
||||
async fn warm_shell(&self, cwd: &std::path::Path) {
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
.send(TerminalCommand::WarmShell {
|
||||
cwd: cwd.to_path_buf(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn kill_foreground_commands(&self) {
|
||||
let _ = self
|
||||
.cmd_tx
|
||||
|
|
@ -2591,17 +2799,63 @@ async fn open_output_file(path: &std::path::Path) -> std::io::Result<File> {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Capture the user's login-shell PATH so CLI tools from rc files are discoverable.
|
||||
///
|
||||
/// Non-interactive shells (`/bin/bash -c`) don't source rc files, so tools
|
||||
/// installed via `.bashrc`/`.zshrc`/virtualenvs are invisible. This runs the
|
||||
/// detected shell with `-lc` plus an explicit `source` of the rc file, extracts
|
||||
/// PATH using SOH byte markers, and merges it with the current process PATH.
|
||||
///
|
||||
/// Returns a `HashMap` with a single `PATH` key, or an empty map on failure.
|
||||
/// A 5-second timeout kills the child if rc files hang (conda init, nvm, etc.).
|
||||
#[cfg(unix)]
|
||||
async fn capture_login_path() -> HashMap<String, String> {
|
||||
const ENV_LOGIN_ENV: &str = "GROK_LOGIN_ENV";
|
||||
|
||||
#[cfg(unix)]
|
||||
fn login_env_capture_enabled() -> bool {
|
||||
!matches!(
|
||||
std::env::var(ENV_LOGIN_ENV).as_deref(),
|
||||
Ok("0") | Ok("false")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn login_env_var_excluded(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"PWD"
|
||||
| "OLDPWD"
|
||||
| "SHLVL"
|
||||
| "_"
|
||||
| "TERM"
|
||||
| "GROK_AGENT"
|
||||
| "SUDO_ASKPASS"
|
||||
| "GROK_ASKPASS"
|
||||
| "ELECTRON_RUN_AS_NODE"
|
||||
| "SSH_AUTH_SOCK"
|
||||
| "DBUS_SESSION_BUS_ADDRESS"
|
||||
| "XDG_RUNTIME_DIR"
|
||||
| "WAYLAND_DISPLAY"
|
||||
| "GPG_TTY"
|
||||
) || key.to_ascii_lowercase().ends_with("_proxy")
|
||||
|| key.starts_with("GROK_SANDBOX")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn parse_login_env_capture(stdout: &str) -> (Option<String>, HashMap<String, String>) {
|
||||
let parts: Vec<&str> = stdout.split('\x01').collect();
|
||||
let login_path = parts
|
||||
.get(1)
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty());
|
||||
let mut env_map = HashMap::new();
|
||||
if let Some(blob) = parts.get(2) {
|
||||
for pair in blob.split('\0') {
|
||||
if let Some((key, value)) = pair.split_once('=')
|
||||
&& !key.is_empty()
|
||||
&& key != "PATH"
|
||||
&& !login_env_var_excluded(key)
|
||||
{
|
||||
env_map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
(login_path, env_map)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn capture_login_env() -> HashMap<String, String> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let shell = shell_state::ShellKind::detect();
|
||||
|
|
@ -2609,7 +2863,9 @@ async fn capture_login_path() -> HashMap<String, String> {
|
|||
|
||||
// Use $HOME inside the script (not interpolated from Rust) to avoid
|
||||
// shell injection if HOME contains special characters.
|
||||
let script = format!("source \"$HOME/{rc_file}\" 2>/dev/null; printf '\\x01%s\\x01' \"$PATH\"");
|
||||
let script = format!(
|
||||
"source \"$HOME/{rc_file}\" 2>/dev/null; printf '\\x01%s\\x01' \"$PATH\"; command env -0 2>/dev/null; printf '\\x01'"
|
||||
);
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut cmd = tokio::process::Command::new(shell.binary_path());
|
||||
|
|
@ -2633,11 +2889,11 @@ async fn capture_login_path() -> HashMap<String, String> {
|
|||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&stdout_buf);
|
||||
let parts: Vec<&str> = stdout.split('\x01').collect();
|
||||
let login_path = (parts.len() >= 3).then(|| parts[1].trim())?;
|
||||
let (login_path, mut env_map) = parse_login_env_capture(&stdout);
|
||||
let login_path = login_path?;
|
||||
|
||||
if login_path.is_empty() {
|
||||
return None;
|
||||
if !login_env_capture_enabled() {
|
||||
env_map.clear();
|
||||
}
|
||||
|
||||
// Merge: login PATH first, then current-process entries not already present.
|
||||
|
|
@ -2648,16 +2904,17 @@ async fn capture_login_path() -> HashMap<String, String> {
|
|||
.chain(current_path.split(':'))
|
||||
.filter(|e| !e.is_empty() && seen.insert(*e))
|
||||
.collect();
|
||||
env_map.insert("PATH".to_string(), merged.join(":"));
|
||||
|
||||
Some(merged.join(":"))
|
||||
Some(env_map)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(path)) => HashMap::from([("PATH".to_string(), path)]),
|
||||
Ok(Some(env_map)) => env_map,
|
||||
Ok(None) => HashMap::new(),
|
||||
Err(_) => {
|
||||
tracing::warn!("login-shell PATH capture timed out after 5s");
|
||||
tracing::warn!("login-shell env capture timed out after 5s");
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
|
|
@ -2709,6 +2966,13 @@ fn spawn_shell_command(
|
|||
// detach_from_tty() handles both session and process group creation.
|
||||
.kill_on_drop(true);
|
||||
|
||||
if let Some(login) = login_env {
|
||||
for (key, value) in login {
|
||||
if key != "PATH" && std::env::var_os(key).is_none() {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply env vars from the request (e.g., .envrc, color vars, ACP-provided vars).
|
||||
cmd.envs(shell_state::shell_env_overrides());
|
||||
for (key, value) in env {
|
||||
|
|
@ -2721,8 +2985,8 @@ fn spawn_shell_command(
|
|||
// request env often carries a copy of the parent process's PATH which
|
||||
// doesn't include rc-file additions — applying login PATH after the
|
||||
// request env ensures those additions aren't clobbered.
|
||||
if let Some(login) = login_env {
|
||||
cmd.envs(login);
|
||||
if let Some(path) = login_env.and_then(|l| l.get("PATH")) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
// Agent marker must win over request/login env.
|
||||
crate::util::apply_grok_agent_marker(&mut cmd);
|
||||
|
|
@ -2792,7 +3056,9 @@ fn spawn_shell_command(
|
|||
#[cfg(unix)]
|
||||
let mut group = crate::util::ProcessGroup::new()?;
|
||||
#[cfg(unix)]
|
||||
let child = cmd.spawn()?;
|
||||
let child = cmd.spawn().map_err(|e| {
|
||||
std::io::Error::new(e.kind(), format!("spawn shell in {}: {e}", cwd.display()))
|
||||
})?;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let (child, mut group) = {
|
||||
|
|
@ -4235,6 +4501,150 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_persistent_shell_deleted_cwd_falls_back_to_request_cwd() {
|
||||
let backend = LocalTerminalBackend::with_persistent_shell();
|
||||
|
||||
let scratch = tempfile::TempDir::new().unwrap();
|
||||
let result = backend
|
||||
.run(make_request(&format!("cd {}", scratch.path().display())))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
drop(scratch);
|
||||
|
||||
let result = backend.run(make_request("pwd")).await.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
let output = &result.combined_output;
|
||||
assert!(
|
||||
output.contains("no longer exists"),
|
||||
"fallback warning must be in the command output, got: {output:?}"
|
||||
);
|
||||
let pwd = output.lines().last().unwrap_or_default().trim();
|
||||
assert!(
|
||||
pwd == "/tmp" || pwd == "/private/tmp",
|
||||
"command must run in the request working directory, got: {pwd:?}"
|
||||
);
|
||||
|
||||
let result = backend.run(make_request("pwd")).await.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert!(
|
||||
!result.combined_output.contains("no longer exists"),
|
||||
"state must heal after the fallback, got: {:?}",
|
||||
result.combined_output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_persistent_shell_spawn_error_names_missing_cwd() {
|
||||
let backend = LocalTerminalBackend::with_persistent_shell();
|
||||
|
||||
let scratch = tempfile::TempDir::new().unwrap();
|
||||
let result = backend
|
||||
.run(make_request(&format!("cd {}", scratch.path().display())))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
drop(scratch);
|
||||
|
||||
let gone = tempfile::TempDir::new().unwrap();
|
||||
let gone_path = gone.path().to_path_buf();
|
||||
drop(gone);
|
||||
let mut req = make_request("pwd");
|
||||
req.working_directory = gone_path.clone();
|
||||
|
||||
let Err(err) = backend.run(req).await else {
|
||||
panic!("spawn must fail when both directories are missing");
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("spawn shell in") && msg.contains(&gone_path.display().to_string()),
|
||||
"error must name the spawn directory, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_persistent_shell_does_not_inherit_dump_errexit() {
|
||||
let backend = LocalTerminalBackend::with_persistent_shell();
|
||||
|
||||
let result = backend.run(make_request("true")).await.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
|
||||
let result = backend
|
||||
.run(make_request("false; echo STILL_ALIVE"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.exit_code,
|
||||
Some(0),
|
||||
"a failing statement must not abort the command: {:?}",
|
||||
result.combined_output
|
||||
);
|
||||
assert!(
|
||||
result.combined_output.contains("STILL_ALIVE"),
|
||||
"execution must continue past a failing statement: {:?}",
|
||||
result.combined_output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_non_persistent_shell_unaffected_by_deleted_cd_target() {
|
||||
let backend = LocalTerminalBackend::new();
|
||||
|
||||
let scratch = tempfile::TempDir::new().unwrap();
|
||||
let result = backend
|
||||
.run(make_request(&format!("cd {}", scratch.path().display())))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
drop(scratch);
|
||||
|
||||
let result = backend.run(make_request("pwd")).await.unwrap();
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
let pwd = result.combined_output.trim();
|
||||
assert!(
|
||||
pwd == "/tmp" || pwd == "/private/tmp",
|
||||
"spawns must use the request cwd, got: {pwd:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_login_env_capture() {
|
||||
let stdout = "motd noise\n\x01/opt/rc/bin:/usr/bin\x01\
|
||||
XDG_CONFIG_HOME=/Users/u/.config\0\
|
||||
GH_CONFIG_DIR=/Users/u/.config/gh\0\
|
||||
MULTILINE=a\nb\0\
|
||||
PATH=/login/path\0\
|
||||
PWD=/somewhere\0\
|
||||
SHLVL=2\0\
|
||||
GPG_TTY=/dev/ttys001\0\
|
||||
http_proxy=http://p:3128\0\x01";
|
||||
let (path, env) = parse_login_env_capture(stdout);
|
||||
assert_eq!(path.as_deref(), Some("/opt/rc/bin:/usr/bin"));
|
||||
assert_eq!(
|
||||
env.get("XDG_CONFIG_HOME").map(String::as_str),
|
||||
Some("/Users/u/.config")
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("GH_CONFIG_DIR").map(String::as_str),
|
||||
Some("/Users/u/.config/gh")
|
||||
);
|
||||
assert_eq!(env.get("MULTILINE").map(String::as_str), Some("a\nb"));
|
||||
for excluded in ["PATH", "PWD", "SHLVL", "GPG_TTY", "http_proxy"] {
|
||||
assert!(
|
||||
!env.contains_key(excluded),
|
||||
"{excluded} must be filtered from the captured login env"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_login_env_capture_path_only() {
|
||||
let (path, env) = parse_login_env_capture("\x01/usr/bin\x01");
|
||||
assert_eq!(path.as_deref(), Some("/usr/bin"));
|
||||
assert!(env.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_non_persistent_shell_no_state() {
|
||||
// Verify the default (non-persistent) mode doesn't carry state.
|
||||
|
|
|
|||
|
|
@ -292,6 +292,8 @@ pub trait TerminalBackend: Send + Sync {
|
|||
/// only the subagent's own tasks are killed — not the parent's.
|
||||
async fn kill_all_background_tasks_by_owner(&self, _owner_session_id: &str) {}
|
||||
|
||||
async fn warm_shell(&self, _cwd: &std::path::Path) {}
|
||||
|
||||
/// Reparent notification handles for all tasks owned by `old_owner_session_id`.
|
||||
/// Swaps the dead child session's notification handle with the parent's
|
||||
/// live handle so events from surviving processes route correctly.
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
|||
|
||||
// ─── Description ────────────────────────────────────────────────────
|
||||
|
||||
const DESCRIPTION: &str =
|
||||
"Finds files whose contents match the pattern and lists them by modification time.";
|
||||
const DESCRIPTION: &str = "Finds files whose contents match the ${{ params.search.pattern }} and lists them by modification time.";
|
||||
|
||||
// ─── Input ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -1425,8 +1425,8 @@ impl BashTool {
|
|||
r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output.
|
||||
|
||||
Usage notes:
|
||||
- You can specify an optional timeout in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). ${%- if auto_background_on_timeout %} If not specified, commands exceeding the default timeout will be automatically backgrounded instead of killed. You will receive a task_id to check output later.${%- else %} If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.${%- endif %}
|
||||
- Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period). Descendants that did not detach via `setsid` / `nohup` will also be killed.${%- endif %} `timeout: 0` in `${%- if params is defined and params.execute is defined and params.execute.is_background %}${{ params.execute.is_background }}${%- else %}background${%- endif %}: true` mode disables the wrapper timeout entirely; the child's lifetime is owned by the model via ${{ tools.by_kind.kill_task_action }}.
|
||||
- You can specify an optional ${{ params.execute.timeout }} in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). ${%- if auto_background_on_timeout %} If not specified, commands exceeding the default timeout will be automatically backgrounded instead of killed. You will receive a task_id to check output later.${%- else %} If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.${%- endif %}
|
||||
- Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period). Descendants that did not detach via `setsid` / `nohup` will also be killed.${%- endif %} `${{ params.execute.timeout }}: 0` in `${%- if params is defined and params.execute is defined and params.execute.is_background %}${{ params.execute.is_background }}${%- else %}background${%- endif %}: true` mode disables the wrapper timeout entirely; the child's lifetime is owned by the model via ${{ tools.by_kind.kill_task_action }}.
|
||||
- If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you.
|
||||
- You can use the ${{ params.execute.is_background }} parameter to run the command in the background (e.g., dev servers, long builds): it returns a task_id immediately and keeps running in the background. You are notified on completion, so do not poll or sleep-wait for it.${%- if has_unix_utilities %} You do not need to use '&' at the end of the command when using this parameter.${%- endif %}
|
||||
${%- if shell_uses_semicolon %}
|
||||
|
|
@ -1441,7 +1441,7 @@ ${%- endif %}"#
|
|||
r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output.
|
||||
|
||||
Usage notes:
|
||||
- You can specify an optional timeout in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.
|
||||
- You can specify an optional ${{ params.execute.timeout }} in milliseconds (up to ${{ max_timeout_ms | default(300000) }}ms). If not specified, commands will timeout after ${{ default_timeout_ms | default(120000) }}ms.
|
||||
- Timeout enforcement: when the timeout fires, the wrapper${%- if is_windows %} terminates the child's Job Object, killing every descendant process immediately (no graceful-termination grace period).${%- else %} kills the child process group (SIGTERM, escalated to SIGKILL after a ~1s grace period).${%- endif %}
|
||||
- If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you.
|
||||
${%- if shell_uses_semicolon %}
|
||||
|
|
@ -4668,6 +4668,40 @@ mod tests {
|
|||
renderer.render_with_extra(template, &extras).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_tracks_renamed_timeout() {
|
||||
let renderer = TemplateRenderer::new(
|
||||
HashMap::from([
|
||||
(ToolKind::Execute, "run_terminal_cmd".to_string()),
|
||||
(ToolKind::KillTaskAction, "kill_task".to_string()),
|
||||
]),
|
||||
HashMap::from([(
|
||||
ToolKind::Execute,
|
||||
HashMap::from([
|
||||
("timeout".to_string(), "max_wait".to_string()),
|
||||
("is_background".to_string(), "is_background".to_string()),
|
||||
]),
|
||||
)]),
|
||||
);
|
||||
let extras = serde_json::json!({
|
||||
"auto_background_on_timeout": true,
|
||||
"is_windows": false,
|
||||
"shell_uses_semicolon": false,
|
||||
"has_unix_utilities": true,
|
||||
});
|
||||
let out = renderer
|
||||
.render_with_extra(BashTool::default_description_template_enabled(), &extras)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.contains("optional max_wait in milliseconds") && out.contains("`max_wait: 0`"),
|
||||
"renamed timeout must appear:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("optional timeout in milliseconds") && !out.contains("`timeout: 0`"),
|
||||
"canonical timeout must not remain after rename:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_shell_omits_utility_and_chaining_notes() {
|
||||
let out = render(BashTool::default_description_template_enabled(), true);
|
||||
|
|
|
|||
|
|
@ -249,9 +249,9 @@ impl crate::types::tool_metadata::ToolMetadata for GrepTool {
|
|||
r#"Search file contents with regular expressions (ripgrep).
|
||||
|
||||
- Full regex syntax, so escape literal special characters: `functionCall\(`, or `interface\{\}` to find interface{} in Go.
|
||||
- Pass the pattern as a raw regex string — no surrounding quotes.
|
||||
- Pass ${{ params.search.pattern }} as a raw regex string — no surrounding quotes.
|
||||
- Respects .gitignore unless you pass a broad glob like '--glob *'.
|
||||
- Only filter by 'type' or 'glob' when you are sure of the file type; import paths may not match source file types (.js vs .ts).
|
||||
- Only filter by '${{ params.search.type }}' or '${{ params.search.glob }}' when you are sure of the file type; import paths may not match source file types (.js vs .ts).
|
||||
- Output is ripgrep-style: ':' marks match lines, '-' marks context lines, grouped by file. Large results are capped and report "at least" counts."#
|
||||
}
|
||||
}
|
||||
|
|
@ -1639,6 +1639,39 @@ mod tests {
|
|||
assert!(tool.description_template().contains("regex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_template_tracks_renamed_search_params() {
|
||||
use crate::types::template_renderer::TemplateRenderer;
|
||||
use crate::types::tool::ToolKind;
|
||||
use crate::types::tool_metadata::ToolMetadata;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let tools = HashMap::from([(ToolKind::Search, "grep".to_string())]);
|
||||
let params = HashMap::from([(
|
||||
ToolKind::Search,
|
||||
HashMap::from([
|
||||
("pattern".to_string(), "query".to_string()),
|
||||
("type".to_string(), "filetype".to_string()),
|
||||
("glob".to_string(), "include".to_string()),
|
||||
]),
|
||||
)]);
|
||||
let rendered = TemplateRenderer::new(tools, params)
|
||||
.render(ToolMetadata::description_template(&GrepTool))
|
||||
.unwrap();
|
||||
assert!(
|
||||
rendered.contains("Pass query as a raw regex")
|
||||
&& rendered.contains("'filetype'")
|
||||
&& rendered.contains("'include'"),
|
||||
"renamed search params must appear:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("Pass pattern as")
|
||||
&& !rendered.contains("'type'")
|
||||
&& !rendered.contains("'glob'"),
|
||||
"canonical search param names must not remain after rename:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_grep_no_matches() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ impl crate::types::tool_metadata::ToolMetadata for KillTaskTool {
|
|||
subagent_present: true,
|
||||
bash_present: true,
|
||||
is_windows: cfg!(not(unix)),
|
||||
task_id_param: "task_id",
|
||||
})
|
||||
});
|
||||
&DESC
|
||||
|
|
@ -147,6 +148,9 @@ fn kill_task_description(
|
|||
subagent_present: renderer.tool_for_kind(ToolKind::Task).is_some(),
|
||||
bash_present: renderer.tool_for_kind(ToolKind::Execute).is_some(),
|
||||
is_windows: cfg!(not(unix)),
|
||||
task_id_param: renderer
|
||||
.param_for_kind(ToolKind::KillTaskAction, "task_id")
|
||||
.unwrap_or("task_id"),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -437,6 +441,32 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_tracks_renamed_task_id() {
|
||||
use crate::types::template_renderer::TemplateRenderer;
|
||||
use crate::types::tool::ToolKind;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let tools = HashMap::from([
|
||||
(ToolKind::Execute, "run_terminal_command".to_string()),
|
||||
(ToolKind::Monitor, "monitor".to_string()),
|
||||
(ToolKind::KillTaskAction, "kill_task".to_string()),
|
||||
]);
|
||||
let params = HashMap::from([(
|
||||
ToolKind::KillTaskAction,
|
||||
HashMap::from([("task_id".to_string(), "id".to_string())]),
|
||||
)]);
|
||||
let rendered = kill_task_description(&TemplateRenderer::new(tools, params), None);
|
||||
assert!(
|
||||
rendered.contains("Pass its id (a monitor's id is returned by monitor)"),
|
||||
"renamed task_id must appear in pass-line and monitor aside:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("task_id"),
|
||||
"canonical task_id must not remain after rename:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The kill mechanism is OS-level: Windows describes Job Object termination,
|
||||
/// Unix/Git Bash describe SIGTERM/SIGKILL.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ impl crate::types::tool_metadata::ToolMetadata for KillTerminalCommandTool {
|
|||
r#"Terminate a running background terminal command${%- if tools.by_kind.monitor %} or monitor${%- endif %}.
|
||||
|
||||
Usage notes:
|
||||
- Pass its task_id${%- if tools.by_kind.monitor %} (a monitor's task_id is returned by ${{ tools.by_kind.monitor }})${%- endif %}.
|
||||
- Pass its ${{ params.kill_task_action.task_id }}${%- if tools.by_kind.monitor %} (a monitor's ${{ params.kill_task_action.task_id }} is returned by ${{ tools.by_kind.monitor }})${%- endif %}.
|
||||
- ${%- if is_windows %} Terminates the Job Object of${%- else %} Sends SIGTERM/SIGKILL to${%- endif %} a background command${%- if tools.by_kind.monitor %} or monitor${%- endif %}.
|
||||
- Returns success if the command was killed or had already exited."#
|
||||
}
|
||||
|
|
@ -148,6 +148,36 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_template_tracks_renamed_task_id() {
|
||||
use crate::types::template_renderer::TemplateRenderer;
|
||||
use crate::types::tool::ToolKind;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let tools = HashMap::from([
|
||||
(ToolKind::Monitor, "monitor".to_string()),
|
||||
(
|
||||
ToolKind::KillTaskAction,
|
||||
"kill_terminal_command".to_string(),
|
||||
),
|
||||
]);
|
||||
let params = HashMap::from([(
|
||||
ToolKind::KillTaskAction,
|
||||
HashMap::from([("task_id".to_string(), "id".to_string())]),
|
||||
)]);
|
||||
let rendered = TemplateRenderer::new(tools, params)
|
||||
.render(ToolMetadata::description_template(&KillTerminalCommandTool))
|
||||
.unwrap();
|
||||
assert!(
|
||||
rendered.contains("Pass its id (a monitor's id is returned by monitor)"),
|
||||
"renamed task_id must appear in pass-line and monitor aside:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("task_id"),
|
||||
"canonical task_id must not remain after rename:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegates_kill_killed() {
|
||||
let resources = resources_with_terminal(KillOutcome::Killed);
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ fn extract_pptx_text(file_bytes: Vec<u8>) -> Result<ReadFileOutput, String> {
|
|||
pub(crate) const DESCRIPTION_FULL: &str = r#"Read a file.
|
||||
|
||||
Usage:
|
||||
- The target_file parameter can be a relative path in the workspace or an absolute path
|
||||
- The ${{ params.read.target_file }} parameter can be a relative path in the workspace or an absolute path
|
||||
- By default, it reads up to {max_lines_read} lines starting from the beginning of the file
|
||||
- Results are returned with line numbers starting at 1. The format is: LINE_NUMBER→LINE_CONTENT
|
||||
- This tool can read PDF files (.pdf), PowerPoint files (.pptx), Jupyter notebooks (.ipynb files), and image files (e.g. PNG, JPG, etc).
|
||||
|
|
|
|||
|
|
@ -682,6 +682,9 @@ impl crate::types::tool_metadata::ToolMetadata for TaskOutputTool {
|
|||
read_tool: Some("read_file"),
|
||||
bash_background_param: Some("is_background"),
|
||||
subagent_background_param: Some("run_in_background"),
|
||||
task_ids_param: "task_ids",
|
||||
timeout_ms_param: "timeout_ms",
|
||||
task_id_param: "task_id",
|
||||
})
|
||||
});
|
||||
&DESC
|
||||
|
|
@ -739,6 +742,16 @@ fn task_output_description(
|
|||
read_tool: renderer.tool_for_kind(ToolKind::Read),
|
||||
bash_background_param: renderer.param_for_kind(ToolKind::Execute, "is_background"),
|
||||
subagent_background_param: renderer.param_for_kind(ToolKind::Task, "run_in_background"),
|
||||
task_ids_param: renderer
|
||||
.param_for_kind(ToolKind::BackgroundTaskAction, "task_ids")
|
||||
.unwrap_or("task_ids"),
|
||||
timeout_ms_param: renderer
|
||||
.param_for_kind(ToolKind::BackgroundTaskAction, "timeout_ms")
|
||||
.unwrap_or("timeout_ms"),
|
||||
// Same singular id name kill_task uses in its monitor aside.
|
||||
task_id_param: renderer
|
||||
.param_for_kind(ToolKind::KillTaskAction, "task_id")
|
||||
.unwrap_or("task_id"),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1070,6 +1083,59 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_tracks_renamed_task_ids_and_timeout_ms() {
|
||||
use crate::types::template_renderer::TemplateRenderer;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let tools = HashMap::from([
|
||||
(ToolKind::Execute, "run_terminal_command".to_string()),
|
||||
(ToolKind::Monitor, "monitor".to_string()),
|
||||
(
|
||||
ToolKind::BackgroundTaskAction,
|
||||
"get_task_output".to_string(),
|
||||
),
|
||||
(ToolKind::KillTaskAction, "kill_task".to_string()),
|
||||
]);
|
||||
let params = HashMap::from([
|
||||
(
|
||||
ToolKind::Execute,
|
||||
HashMap::from([("is_background".to_string(), "is_background".to_string())]),
|
||||
),
|
||||
(
|
||||
ToolKind::BackgroundTaskAction,
|
||||
HashMap::from([
|
||||
("task_ids".to_string(), "process_ids".to_string()),
|
||||
("timeout_ms".to_string(), "max_wait".to_string()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
ToolKind::KillTaskAction,
|
||||
HashMap::from([("task_id".to_string(), "id".to_string())]),
|
||||
),
|
||||
]);
|
||||
let rendered = task_output_description(&TemplateRenderer::new(tools, params), None);
|
||||
assert!(
|
||||
rendered.contains("Pass process_ids with"),
|
||||
"renamed task_ids must appear:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Omit max_wait or pass 0")
|
||||
&& rendered.contains("positive max_wait wait"),
|
||||
"renamed timeout_ms must appear:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("a monitor's id is returned by monitor"),
|
||||
"renamed kill_task task_id must appear in monitor aside:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("task_ids")
|
||||
&& !rendered.contains("timeout_ms")
|
||||
&& !rendered.contains("task_id"),
|
||||
"canonical param names must not remain after rename:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_task_running() {
|
||||
let snapshot = make_snapshot("task-1", false, None);
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ impl crate::types::tool_metadata::ToolMetadata for GetTerminalCommandOutputTool
|
|||
r#"Get output and status from a background terminal command${%- if tools.by_kind.monitor %} or monitor${%- endif %}.
|
||||
|
||||
Usage notes:
|
||||
- Pass task_ids with one or more ids from ${{ params.execute.is_background }}=true commands${%- if tools.by_kind.monitor %} (a monitor's task_id is returned by ${{ tools.by_kind.monitor }})${%- endif %}; for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete
|
||||
- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at ~10 min
|
||||
- Pass ${{ params.background_task_action.task_ids }} with one or more ids from ${{ params.execute.is_background }}=true commands${%- if tools.by_kind.monitor %} (a monitor's ${{ params.kill_task_action.task_id }} is returned by ${{ tools.by_kind.monitor }})${%- endif %}; for a single task use a one-element array. Multiple ids with a positive ${{ params.background_task_action.timeout_ms }} wait until all complete
|
||||
- Omit ${{ params.background_task_action.timeout_ms }} or pass 0 for a non-blocking status snapshot; set a positive ${{ params.background_task_action.timeout_ms }} to wait up to that many milliseconds, capped at ~10 min
|
||||
- Returns current output, status, and exit code if completed${%- if tools.by_kind.read %}
|
||||
- If output is large, use ${{ tools.by_kind.read }} on the output_file path${%- endif %}"#
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,12 +94,19 @@ impl WebFetchClient {
|
|||
}
|
||||
}
|
||||
|
||||
// SSRF check.
|
||||
ssrf::check_ssrf(&url).await?;
|
||||
// SSRF check (policy from tool params — not process env at call time).
|
||||
ssrf::check_ssrf(&url, self.params.allow_local()).await?;
|
||||
|
||||
// Make request and build output.
|
||||
let http = self.http.get_or_rebuild()?;
|
||||
let result = match fetch_url(&http, &url, self.params.max_content_length()).await {
|
||||
let result = match fetch_url(
|
||||
&http,
|
||||
&url,
|
||||
self.params.max_content_length(),
|
||||
self.params.allow_local(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e @ WebFetchError::HttpRequest(_)) => {
|
||||
self.http.invalidate();
|
||||
|
|
@ -301,6 +308,9 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
|||
|
||||
if let Some(host) = parsed.host_str()
|
||||
&& host.split('.').count() < 2
|
||||
// `localhost` is a single-label name; SSRF still requires
|
||||
// allow_local for explicit local hosts.
|
||||
&& !ssrf::is_explicit_local_host(host)
|
||||
{
|
||||
return Err(WebFetchError::SingleLabelHost {
|
||||
host: host.to_string(),
|
||||
|
|
@ -310,11 +320,20 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
|||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Upgrade `http://` to `https://`.
|
||||
/// Upgrade `http://` to `https://`, except for explicit loopback hosts.
|
||||
///
|
||||
/// Local dev servers almost always speak plain HTTP; forcing TLS would break
|
||||
/// `http://127.0.0.1` / `http://localhost` when local binding is opted in.
|
||||
fn upgrade_to_https(url: &mut Url) {
|
||||
if url.scheme() == "http" {
|
||||
let _ = url.set_scheme("https");
|
||||
if url.scheme() != "http" {
|
||||
return;
|
||||
}
|
||||
if let Some(host) = url.host_str()
|
||||
&& ssrf::is_explicit_local_host(host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = url.set_scheme("https");
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -335,16 +354,25 @@ enum FetchResult {
|
|||
}
|
||||
|
||||
/// Fetch a URL with manual same-host redirect handling.
|
||||
///
|
||||
/// Re-runs SSRF checks on every hop so DNS rebinding between redirects cannot
|
||||
/// sneak a previously-blocked address past the initial check (partial TOCTOU
|
||||
/// mitigation; peer IP on the live TCP connection is not available from reqwest).
|
||||
async fn fetch_url(
|
||||
client: &reqwest::Client,
|
||||
url: &Url,
|
||||
max_content_length: usize,
|
||||
allow_local: bool,
|
||||
) -> Result<FetchResult, WebFetchError> {
|
||||
let mut current_url = url.clone();
|
||||
let mut hops = 0;
|
||||
|
||||
// Loop to follow redirects under the same host.
|
||||
loop {
|
||||
// Re-check on every hop (including the first) so a rebinding name that
|
||||
// was public at the pre-fetch check cannot become loopback/private here.
|
||||
ssrf::check_ssrf(¤t_url, allow_local).await?;
|
||||
|
||||
let resp = client
|
||||
.get(current_url.as_str())
|
||||
.header(USER_AGENT, USER_AGENT_STRING)
|
||||
|
|
@ -367,10 +395,15 @@ async fn fetch_url(
|
|||
// Follow same host; break on cross-host.
|
||||
if let Some(location) = resp.headers().get("location") {
|
||||
let location_str = location.to_str().unwrap_or("");
|
||||
let next_url = current_url
|
||||
let mut next_url = current_url
|
||||
.join(location_str)
|
||||
.map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?;
|
||||
if is_same_host(¤t_url, &next_url) {
|
||||
// Re-apply https upgrade on every hop: Location may be
|
||||
// absolute `http://…` and would otherwise silently
|
||||
// downgrade an https fetch. Local hosts still skip TLS.
|
||||
upgrade_to_https(&mut next_url);
|
||||
// check_ssrf runs at the top of the next loop iteration.
|
||||
current_url = next_url;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -407,13 +440,11 @@ async fn fetch_url(
|
|||
}
|
||||
}
|
||||
|
||||
/// Exact host equality — no `www.` stripping. Distinct DNS labels (even when
|
||||
/// one is a `www` subdomain of the other) have independent A records and must
|
||||
/// surface as cross-host redirects rather than auto-follow.
|
||||
fn is_same_host(a: &Url, b: &Url) -> bool {
|
||||
fn strip_www(h: &str) -> &str {
|
||||
h.strip_prefix("www.").unwrap_or(h)
|
||||
}
|
||||
let host_a = a.host_str().unwrap_or("");
|
||||
let host_b = b.host_str().unwrap_or("");
|
||||
strip_www(host_a) == strip_www(host_b)
|
||||
a.host_str() == b.host_str()
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -877,11 +908,28 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn validate_url_rejects_single_label_hosts() {
|
||||
assert!(validate_url("http://localhost:8080/foo").is_err());
|
||||
// localhost is an explicit local host; SSRF still blocks it unless
|
||||
// allow_local is set on tool params.
|
||||
assert!(validate_url("http://localhost:8080/foo").is_ok());
|
||||
assert!(validate_url("http://intranet/foo").is_err());
|
||||
assert!(validate_url("http://metadata/computeMetadata").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_to_https_skips_explicit_local_hosts() {
|
||||
let mut local = Url::parse("http://127.0.0.1:8080/").unwrap();
|
||||
upgrade_to_https(&mut local);
|
||||
assert_eq!(local.scheme(), "http");
|
||||
|
||||
let mut localhost = Url::parse("http://localhost:3000/").unwrap();
|
||||
upgrade_to_https(&mut localhost);
|
||||
assert_eq!(localhost.scheme(), "http");
|
||||
|
||||
let mut public = Url::parse("http://example.com/").unwrap();
|
||||
upgrade_to_https(&mut public);
|
||||
assert_eq!(public.scheme(), "https");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_credentials() {
|
||||
assert!(validate_url("https://user:pass@example.com/foo").is_err());
|
||||
|
|
@ -931,11 +979,11 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn same_host_www_stripping() {
|
||||
fn www_subdomain_is_cross_host() {
|
||||
let a = Url::parse("https://example.com/a").unwrap();
|
||||
let c = Url::parse("https://www.example.com/a").unwrap();
|
||||
assert!(is_same_host(&a, &c));
|
||||
assert!(is_same_host(&c, &a));
|
||||
assert!(!is_same_host(&a, &c));
|
||||
assert!(!is_same_host(&c, &a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -945,6 +993,19 @@ mod tests {
|
|||
assert!(!is_same_host(&a, &d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_host_redirect_location_reupgrades_http() {
|
||||
// Absolute http Location on an https origin must not stay http when
|
||||
// followed as a same-host hop (upgrade_to_https reapplied each hop).
|
||||
let origin = Url::parse("https://example.com/start").unwrap();
|
||||
let mut next = origin.join("http://example.com/next").unwrap();
|
||||
assert_eq!(next.scheme(), "http");
|
||||
assert!(is_same_host(&origin, &next));
|
||||
upgrade_to_https(&mut next);
|
||||
assert_eq!(next.scheme(), "https");
|
||||
assert_eq!(next.as_str(), "https://example.com/next");
|
||||
}
|
||||
|
||||
// ── Content type detection ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ pub struct WebFetchParams {
|
|||
/// routed through this URL.
|
||||
#[serde(default)]
|
||||
pub proxy_endpoint: Option<String>,
|
||||
/// When true, allow fetches to **explicit** loopback hosts only
|
||||
/// (`localhost`, `127.0.0.0/8`, `::1`). Private/metadata stay blocked.
|
||||
/// Default: `false` (fail closed). Set via `[toolset.web_fetch]
|
||||
/// allow_local = true` or `GROK_WEB_FETCH_ALLOW_LOCAL=1`.
|
||||
#[serde(default)]
|
||||
pub allow_local: Option<bool>,
|
||||
}
|
||||
|
||||
register_resource!("grok_build", "WebFetch", WebFetchParams);
|
||||
|
|
@ -71,6 +77,10 @@ impl WebFetchParams {
|
|||
self.context_window_tokens.unwrap_or(128_000)
|
||||
}
|
||||
|
||||
pub fn allow_local(&self) -> bool {
|
||||
self.allow_local.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn allowed_domains(&self) -> Vec<String> {
|
||||
match &self.allowed_domains {
|
||||
Some(v) => v.clone(),
|
||||
|
|
|
|||
|
|
@ -1,87 +1,133 @@
|
|||
//! SSRF (Server-Side Request Forgery) protection for `web_fetch`.
|
||||
//!
|
||||
//! Validates that resolved IP addresses are not in private, link-local, or
|
||||
//! cloud metadata ranges before allowing outbound HTTP requests.
|
||||
//! Policy:
|
||||
//! - Non-public addresses (loopback, RFC 1918, link-local, CGNAT, TEST-NET,
|
||||
//! multicast, etc.) are blocked by default.
|
||||
//! - Local access is opt-in via tool params (`WebFetchParams::allow_local`,
|
||||
//! set from `[toolset.web_fetch] allow_local` or `GROK_WEB_FETCH_ALLOW_LOCAL=1`).
|
||||
//! Even when enabled, only **explicit** loopback hosts are allowed
|
||||
//! (`localhost`, `127.0.0.0/8` literals, `::1`). A public hostname that
|
||||
//! resolves to loopback/private stays blocked.
|
||||
//!
|
||||
//! Reference: [IANA IPv4 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv4-special-registry/)
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use url::Url;
|
||||
|
||||
use super::error::WebFetchError;
|
||||
|
||||
/// Returns `true` if an IP address is in a private, link-local, or cloud
|
||||
/// metadata range that should be blocked to prevent SSRF attacks.
|
||||
///
|
||||
/// **Allowed:** loopback (`127.x` / `::1`) for local development.
|
||||
/// **Blocked:** RFC 1918, link-local, CGNAT/cloud metadata, unspecified.
|
||||
pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
/// Hostnames/IP literals that may reach loopback when local binding is
|
||||
/// enabled. Public names that *resolve* to loopback are not included — that
|
||||
/// closes DNS rebinding through a non-local hostname.
|
||||
pub(crate) fn is_explicit_local_host(host: &str) -> bool {
|
||||
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
let host = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|h| h.strip_suffix(']'))
|
||||
.unwrap_or(&host);
|
||||
// Drop IPv6 zone id if present (`fe80::1%lo0`).
|
||||
let host = host.split('%').next().unwrap_or(host);
|
||||
|
||||
if host == "localhost" {
|
||||
return true;
|
||||
}
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return ip.is_loopback();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns `true` if an IP is not globally routable and should be treated as
|
||||
/// local/private for SSRF.
|
||||
pub(crate) fn is_non_public_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
// Loopback (127.0.0.0/8) — allowed for local dev servers.
|
||||
if octets[0] == 127 {
|
||||
return false;
|
||||
}
|
||||
// RFC 1918: 10.0.0.0/8 — private network.
|
||||
if octets[0] == 10 {
|
||||
return true;
|
||||
}
|
||||
// RFC 1918: 172.16.0.0/12 — private network.
|
||||
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
|
||||
return true;
|
||||
}
|
||||
// RFC 1918: 192.168.0.0/16 — private network.
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
return true;
|
||||
}
|
||||
// RFC 3927: 169.254.0.0/16 — link-local.
|
||||
// Includes AWS/GCP/Azure metadata endpoint 169.254.169.254.
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
return true;
|
||||
}
|
||||
// RFC 6598: 100.64.0.0/10 — CGNAT / shared address space.
|
||||
// Used by some cloud providers for internal metadata services.
|
||||
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
|
||||
return true;
|
||||
}
|
||||
// 0.0.0.0 — unspecified address.
|
||||
if v4.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
// ::1 — loopback, allowed for local dev.
|
||||
if v6.is_loopback() {
|
||||
return false;
|
||||
}
|
||||
// :: — unspecified.
|
||||
if v6.is_unspecified() {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x) — delegate to v4 checks.
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return is_blocked_ip(&IpAddr::V4(v4));
|
||||
}
|
||||
let segments = v6.segments();
|
||||
// RFC 4291: fe80::/10 — link-local unicast.
|
||||
if segments[0] & 0xffc0 == 0xfe80 {
|
||||
return true;
|
||||
}
|
||||
// RFC 4193: fc00::/7 — unique local address (ULA).
|
||||
if segments[0] & 0xfe00 == 0xfc00 {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
IpAddr::V4(v4) => is_non_public_ipv4(v4),
|
||||
IpAddr::V6(v6) => is_non_public_ipv6(v6),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_non_public_ipv4(ip: Ipv4Addr) -> bool {
|
||||
ip.is_loopback()
|
||||
|| ip.is_private()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_broadcast()
|
||||
// "This network" (RFC 1122) 0.0.0.0/8
|
||||
|| ipv4_in_cidr(ip, [0, 0, 0, 0], 8)
|
||||
// CGNAT (RFC 6598) 100.64.0.0/10 — cloud metadata-ish
|
||||
|| ipv4_in_cidr(ip, [100, 64, 0, 0], 10)
|
||||
// IETF Protocol Assignments (RFC 6890) 192.0.0.0/24
|
||||
|| ipv4_in_cidr(ip, [192, 0, 0, 0], 24)
|
||||
// TEST-NET-1 (RFC 5737)
|
||||
|| ipv4_in_cidr(ip, [192, 0, 2, 0], 24)
|
||||
// Benchmarking (RFC 2544)
|
||||
|| ipv4_in_cidr(ip, [198, 18, 0, 0], 15)
|
||||
// TEST-NET-2 / TEST-NET-3
|
||||
|| ipv4_in_cidr(ip, [198, 51, 100, 0], 24)
|
||||
|| ipv4_in_cidr(ip, [203, 0, 113, 0], 24)
|
||||
// Reserved (RFC 6890) 240.0.0.0/4
|
||||
|| ipv4_in_cidr(ip, [240, 0, 0, 0], 4)
|
||||
}
|
||||
|
||||
fn ipv4_in_cidr(ip: Ipv4Addr, base: [u8; 4], prefix: u8) -> bool {
|
||||
let ip = u32::from(ip);
|
||||
let base = u32::from(Ipv4Addr::from(base));
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - prefix)
|
||||
};
|
||||
(ip & mask) == (base & mask)
|
||||
}
|
||||
|
||||
fn is_non_public_ipv6(ip: Ipv6Addr) -> bool {
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return is_non_public_ipv4(v4);
|
||||
}
|
||||
// Anything not globally routable: loopback, ULA, link-local, unspecified, multicast.
|
||||
ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_unique_local()
|
||||
|| ip.is_unicast_link_local()
|
||||
}
|
||||
|
||||
/// Loopback including IPv4-mapped forms (`::ffff:127.0.0.1`).
|
||||
///
|
||||
/// `IpAddr::is_loopback` is false for mapped addresses even when the embedded
|
||||
/// v4 is loopback, so local opt-in must use this helper.
|
||||
fn is_loopback_addr(ip: IpAddr) -> bool {
|
||||
if ip.is_loopback() {
|
||||
return true;
|
||||
}
|
||||
match ip {
|
||||
IpAddr::V6(v6) => v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()),
|
||||
IpAddr::V4(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a resolved address is blocked for this request host.
|
||||
///
|
||||
/// Dual-gate: even with local binding allowed, only explicit loopback hosts
|
||||
/// may use loopback IPs; private/link-local never open via this flag.
|
||||
pub(crate) fn is_blocked_for_host(ip: IpAddr, host: &str, allow_local: bool) -> bool {
|
||||
if !is_non_public_ip(ip) {
|
||||
return false;
|
||||
}
|
||||
if allow_local && is_loopback_addr(ip) && is_explicit_local_host(host) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Resolve hostname via DNS and verify none of the resolved addresses are
|
||||
/// in blocked private/link-local ranges.
|
||||
pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
||||
/// blocked under the SSRF policy.
|
||||
///
|
||||
/// `allow_local` comes from tool config (`WebFetchParams::allow_local`); it is
|
||||
/// not read from the environment here so the agent cannot flip the policy.
|
||||
pub(crate) async fn check_ssrf(url: &Url, allow_local: bool) -> Result<(), WebFetchError> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| WebFetchError::SingleLabelHost {
|
||||
|
|
@ -90,7 +136,7 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
|||
|
||||
// If the host is already a literal IP, check it directly.
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_blocked_ip(&ip) {
|
||||
if is_blocked_for_host(ip, host, allow_local) {
|
||||
return Err(WebFetchError::SsrfBlocked {
|
||||
host: host.to_string(),
|
||||
ip,
|
||||
|
|
@ -114,9 +160,12 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
|||
return Err(WebFetchError::DnsEmpty(host.to_string()));
|
||||
}
|
||||
|
||||
// Any non-public address blocks the request. When allow_local is on,
|
||||
// only *explicit* loopback hosts may use loopback IPs — a rebinding name
|
||||
// that resolves to 127.0.0.1 stays blocked.
|
||||
addrs
|
||||
.iter()
|
||||
.find(|addr| is_blocked_ip(&addr.ip()))
|
||||
.find(|addr| is_blocked_for_host(addr.ip(), host, allow_local))
|
||||
.map_or(Ok(()), |addr| {
|
||||
Err(WebFetchError::SsrfBlocked {
|
||||
host: host.to_string(),
|
||||
|
|
@ -133,82 +182,206 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn blocks_rfc1918_10x() {
|
||||
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap()));
|
||||
assert!(is_non_public_ip("10.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_for_host(
|
||||
"10.0.0.1".parse().unwrap(),
|
||||
"10.0.0.1",
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_rfc1918_172x() {
|
||||
assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("172.16.0.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("172.31.255.255".parse().unwrap()));
|
||||
assert!(!is_non_public_ip("172.15.0.1".parse().unwrap()));
|
||||
assert!(!is_non_public_ip("172.32.0.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_rfc1918_192168() {
|
||||
assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap()));
|
||||
assert!(is_non_public_ip("192.168.0.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("192.168.255.255".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_link_local() {
|
||||
assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap()));
|
||||
assert!(is_non_public_ip("169.254.0.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("169.254.169.254".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_cgnat_cloud_metadata() {
|
||||
assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"100.128.0.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("100.64.0.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("100.127.255.255".parse().unwrap()));
|
||||
assert!(!is_non_public_ip("100.63.0.1".parse().unwrap()));
|
||||
assert!(!is_non_public_ip("100.128.0.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_unspecified() {
|
||||
assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"::".parse().unwrap()));
|
||||
assert!(is_non_public_ip("0.0.0.0".parse().unwrap()));
|
||||
assert!(is_non_public_ip("::".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_loopback() {
|
||||
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"127.0.0.2".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"::1".parse().unwrap()));
|
||||
fn blocks_testnet_reserved_and_this_network() {
|
||||
// TEST-NET / reserved / this-network special-purpose ranges
|
||||
assert!(is_non_public_ip("192.0.2.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("198.51.100.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("203.0.113.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("240.0.0.1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("0.1.2.3".parse().unwrap()));
|
||||
assert!(is_non_public_ip("198.18.0.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_loopback_by_default() {
|
||||
assert!(is_blocked_for_host(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
"127.0.0.1",
|
||||
false
|
||||
));
|
||||
assert!(is_blocked_for_host(
|
||||
"127.0.0.2".parse().unwrap(),
|
||||
"127.0.0.2",
|
||||
false
|
||||
));
|
||||
assert!(is_blocked_for_host("::1".parse().unwrap(), "::1", false));
|
||||
assert!(is_blocked_for_host(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
"localhost",
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_explicit_loopback_when_local_binding_enabled() {
|
||||
assert!(!is_blocked_for_host(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
"127.0.0.1",
|
||||
true
|
||||
));
|
||||
assert!(!is_blocked_for_host(
|
||||
"127.0.0.2".parse().unwrap(),
|
||||
"127.0.0.2",
|
||||
true
|
||||
));
|
||||
assert!(!is_blocked_for_host("::1".parse().unwrap(), "::1", true));
|
||||
assert!(!is_blocked_for_host(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
"localhost",
|
||||
true
|
||||
));
|
||||
assert!(!is_blocked_for_host(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
"localhost.",
|
||||
true
|
||||
));
|
||||
// IPv4-mapped loopback (common dual-stack DNS result for localhost).
|
||||
assert!(!is_blocked_for_host(
|
||||
"::ffff:127.0.0.1".parse().unwrap(),
|
||||
"localhost",
|
||||
true
|
||||
));
|
||||
assert!(!is_blocked_for_host(
|
||||
"::ffff:127.0.0.1".parse().unwrap(),
|
||||
"127.0.0.1",
|
||||
true
|
||||
));
|
||||
// Metadata / private ranges stay blocked even with the opt-in.
|
||||
assert!(is_blocked_for_host(
|
||||
"169.254.169.254".parse().unwrap(),
|
||||
"169.254.169.254",
|
||||
true
|
||||
));
|
||||
assert!(is_blocked_for_host(
|
||||
"10.0.0.1".parse().unwrap(),
|
||||
"10.0.0.1",
|
||||
true
|
||||
));
|
||||
// Mapped private is still blocked under local opt-in.
|
||||
assert!(is_blocked_for_host(
|
||||
"::ffff:10.0.0.1".parse().unwrap(),
|
||||
"localhost",
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_hostname_to_loopback_stays_blocked() {
|
||||
// Hostnames that resolve to local IPs stay blocked even when local
|
||||
// binding is allowed — only explicit local hosts open loopback.
|
||||
assert!(is_blocked_for_host(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
"evil.example.com",
|
||||
true
|
||||
));
|
||||
assert!(is_blocked_for_host(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
"localtest.me",
|
||||
true
|
||||
));
|
||||
assert!(is_blocked_for_host(
|
||||
"::1".parse().unwrap(),
|
||||
"attacker.test",
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_local_host_detection() {
|
||||
assert!(is_explicit_local_host("localhost"));
|
||||
assert!(is_explicit_local_host("LOCALHOST."));
|
||||
assert!(is_explicit_local_host("127.0.0.1"));
|
||||
assert!(is_explicit_local_host("127.1.2.3"));
|
||||
assert!(is_explicit_local_host("::1"));
|
||||
assert!(is_explicit_local_host("[::1]"));
|
||||
assert!(!is_explicit_local_host("example.com"));
|
||||
assert!(!is_explicit_local_host("10.0.0.1"));
|
||||
assert!(!is_explicit_local_host("notlocalhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_ips() {
|
||||
assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"142.250.80.46".parse().unwrap()));
|
||||
assert!(!is_non_public_ip("1.1.1.1".parse().unwrap()));
|
||||
assert!(!is_non_public_ip("8.8.8.8".parse().unwrap()));
|
||||
assert!(!is_non_public_ip("142.250.80.46".parse().unwrap()));
|
||||
assert!(!is_blocked_for_host(
|
||||
"1.1.1.1".parse().unwrap(),
|
||||
"1.1.1.1",
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
// ── IPv6 ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv6_link_local() {
|
||||
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("fe80::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv6_unique_local() {
|
||||
assert!(is_blocked_ip(&"fc00::1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"fd00::1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("fc00::1".parse().unwrap()));
|
||||
assert!(is_non_public_ip("fd00::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv4_mapped_ipv6_private() {
|
||||
assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_blocked_ip(
|
||||
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
|
||||
assert!(is_non_public_ip(
|
||||
"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_non_public_ip(
|
||||
"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_ipv4_mapped_ipv6_public() {
|
||||
assert!(!is_blocked_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
assert!(!is_non_public_ip(
|
||||
"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
// ── check_ssrf integration ──────────────────────────────────────────
|
||||
|
|
@ -216,15 +389,28 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn ssrf_blocks_ip_literal_private() {
|
||||
let url = Url::parse("https://10.0.0.1/secret").unwrap();
|
||||
let result = check_ssrf(&url).await;
|
||||
let result = check_ssrf(&url, false).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("private"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssrf_blocks_loopback_literal_by_default() {
|
||||
let url = Url::parse("http://127.0.0.1:8080/").unwrap();
|
||||
let result = check_ssrf(&url, false).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssrf_allows_loopback_literal_when_opted_in() {
|
||||
let url = Url::parse("http://127.0.0.1:8080/").unwrap();
|
||||
assert!(check_ssrf(&url, true).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssrf_allows_ip_literal_public() {
|
||||
let url = Url::parse("https://1.1.1.1/").unwrap();
|
||||
let result = check_ssrf(&url).await;
|
||||
let result = check_ssrf(&url, false).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,10 +146,10 @@ Content output format:
|
|||
{grep_context} ← context (-)
|
||||
|
||||
Usage:
|
||||
- Pattern is a regex: `log.*Error`, `function\s+\w+`, `TODO`
|
||||
- ${{ params.search.pattern }} is a regex: `log.*Error`, `function\s+\w+`, `TODO`
|
||||
- Output modes: "content" (default, with anchors), "files_with_matches", "count"
|
||||
- Use -A, -B, -C for context lines around matches
|
||||
- Only use 'type' or 'glob' when certain of the file type
|
||||
- Only use '${{ params.search.type }}' or '${{ params.search.glob }}' when certain of the file type
|
||||
- Results are capped; truncated results show "at least" counts"#;
|
||||
|
||||
/// `hashline_grep` — searches with anchor-annotated results.
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ Anchors are valid only for the file state at read time — after any edit,
|
|||
use the fresh anchors returned by ${{ tools.by_kind.edit }} or re-read the file.${%- endif %}
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- The ${{ params.read.target_file }} parameter must be an absolute path, not a relative path
|
||||
- By default reads up to {max_lines_read} lines from the beginning
|
||||
- Optionally specify offset and limit for large files
|
||||
- Can read images (PNG, JPG, etc.) and PDF files (each page rendered as an image; use `pages` parameter for PDFs with more than 10 pages, max 20 per call)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Before executing the command, please follow these steps:
|
|||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will use the default timeout.
|
||||
- You can specify an optional ${{ params.execute.timeout }} in milliseconds. If not specified, commands will use the default timeout.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds {max_output_bytes} characters, output will be truncated before being returned to you.
|
||||
${%- if tools.by_kind.list or tools.by_kind.search or tools.by_kind.read or tools.by_kind.edit or tools.by_kind.write %}
|
||||
|
|
@ -572,6 +572,31 @@ mod tests {
|
|||
resources
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_template_tracks_renamed_timeout() {
|
||||
use crate::types::template_renderer::TemplateRenderer;
|
||||
use crate::types::tool::ToolKind;
|
||||
use crate::types::tool_metadata::ToolMetadata;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let tools = HashMap::from([(ToolKind::Execute, "bash".to_string())]);
|
||||
let params = HashMap::from([(
|
||||
ToolKind::Execute,
|
||||
HashMap::from([("timeout".to_string(), "max_wait".to_string())]),
|
||||
)]);
|
||||
let rendered = TemplateRenderer::new(tools, params)
|
||||
.render(ToolMetadata::description_template(&BashTool))
|
||||
.unwrap();
|
||||
assert!(
|
||||
rendered.contains("optional max_wait in milliseconds"),
|
||||
"renamed timeout must appear:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains("optional timeout in milliseconds"),
|
||||
"canonical timeout must not remain after rename:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_input(command: &str) -> BashInput {
|
||||
BashInput {
|
||||
command: command.to_string(),
|
||||
|
|
@ -1106,8 +1131,12 @@ mod tests {
|
|||
(ToolKind::Read, "read_file".to_string()),
|
||||
(ToolKind::Edit, "search_replace".to_string()),
|
||||
(ToolKind::Write, "write".to_string()),
|
||||
(ToolKind::Execute, "bash".to_string()),
|
||||
]),
|
||||
HashMap::new(),
|
||||
HashMap::from([(
|
||||
ToolKind::Execute,
|
||||
HashMap::from([("timeout".to_string(), "timeout".to_string())]),
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,15 +42,19 @@ use crate::types::tool::{ToolKind, ToolNamespace};
|
|||
// Description
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// NOTE: OpenCode's `EditInput` serializes camelCase (`oldString`, `newString`,
|
||||
// `replaceAll`), so param refs must use the camelCase schema property names —
|
||||
// the snake_case `params.edit.old_string` keys of the grok_build twin resolve
|
||||
// to "" here (the kind-params map is keyed by schema property names).
|
||||
const DESCRIPTION: &str = r#"Performs exact string replacements in files.
|
||||
|
||||
Usage:
|
||||
- You must use your `${{ tools.by_kind.read }}` tool at least once in the conversation before editing.
|
||||
- When editing text from ${{ tools.by_kind.read }} tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + →. Everything after that → separator is the actual file content to match. Never include any part of the line number prefix in the ${{ params.edit.old_string }} or ${{ params.edit.new_string }}.
|
||||
- When editing text from ${{ tools.by_kind.read }} tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + →. Everything after that → separator is the actual file content to match. Never include any part of the line number prefix in the ${{ params.edit.oldString }} or ${{ params.edit.newString }}.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- The edit will FAIL if `${{ params.edit.old_string }}` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `${{ params.edit.replace_all }}` to change every instance of `${{ params.edit.old_string }}`.
|
||||
- Use `${{ params.edit.replace_all }}` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
- To create a new file, set ${{ params.edit.old_string }} to an empty string.
|
||||
- The edit will FAIL if `${{ params.edit.oldString }}` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `${{ params.edit.replaceAll }}` to change every instance of `${{ params.edit.oldString }}`.
|
||||
- Use `${{ params.edit.replaceAll }}` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
- To create a new file, set ${{ params.edit.oldString }} to an empty string.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked."#;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -70,7 +74,9 @@ pub struct EditInput {
|
|||
pub old_string: String,
|
||||
|
||||
/// The replacement text (must differ from old_string).
|
||||
#[schemars(description = "The text to replace it with (must be different from old_string)")]
|
||||
#[schemars(
|
||||
description = "The text to replace it with (must be different from ${{ params.edit.oldString }})"
|
||||
)]
|
||||
pub new_string: String,
|
||||
|
||||
/// When true, replace every occurrence of `old_string` (default false).
|
||||
|
|
@ -78,7 +84,9 @@ pub struct EditInput {
|
|||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
|
||||
)]
|
||||
#[schemars(description = "Replace all occurrences of old_string (default false)")]
|
||||
#[schemars(
|
||||
description = "Replace all occurrences of ${{ params.edit.oldString }} (default false)"
|
||||
)]
|
||||
pub replace_all: Option<bool>,
|
||||
}
|
||||
|
||||
|
|
@ -373,7 +381,7 @@ async fn handle_replacement(
|
|||
if positions.len() > 1 && !replace_all {
|
||||
let replace_all_name = crate::types::template_renderer::TemplateRenderer::resolve(
|
||||
&resources,
|
||||
"${{ params.edit.replace_all }}",
|
||||
"${{ params.edit.replaceAll }}",
|
||||
)
|
||||
.await?;
|
||||
return Ok(SearchReplaceOutput::MultipleMatchesFound(format!(
|
||||
|
|
@ -493,10 +501,12 @@ mod tests {
|
|||
resources.insert(FileSystem(Arc::new(LocalFs)));
|
||||
resources.insert(NotificationHandle(ToolNotificationHandle::noop()));
|
||||
|
||||
// Keys mirror finalize-time seeding: schema property names, which are
|
||||
// camelCase for OpenCode's EditInput.
|
||||
let edit_params = std::collections::HashMap::from([
|
||||
("old_string".to_string(), "old_string".to_string()),
|
||||
("new_string".to_string(), "new_string".to_string()),
|
||||
("replace_all".to_string(), "replaceAll".to_string()),
|
||||
("oldString".to_string(), "oldString".to_string()),
|
||||
("newString".to_string(), "newString".to_string()),
|
||||
("replaceAll".to_string(), "replaceAll".to_string()),
|
||||
]);
|
||||
resources.insert(TemplateRenderer::new(
|
||||
std::collections::HashMap::from([(ToolKind::Read, "read_file".to_string())]),
|
||||
|
|
@ -765,8 +775,10 @@ mod tests {
|
|||
std::collections::HashMap::from([(ToolKind::Read, "file_reader".to_string())]),
|
||||
std::collections::HashMap::from([(
|
||||
ToolKind::Edit,
|
||||
// Keyed by the camelCase schema property name (finalize seeds
|
||||
// kind params from schema properties).
|
||||
std::collections::HashMap::from([(
|
||||
"replace_all".to_string(),
|
||||
"replaceAll".to_string(),
|
||||
"replaceEverything".to_string(),
|
||||
)]),
|
||||
)]),
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ Usage:
|
|||
- Prefer ${{ tools.by_kind.search }} for exact symbol/string searches. Whenever possible, use this instead of terminal grep/rg. This tool is faster and respects .gitignore
|
||||
- Supports full regex syntax, e.g. `log.*Error`, `function\s+\w+`. Ensure you escape special chars to get exact matches, e.g. `functionCall\(`
|
||||
- Avoid overly broad glob patterns (e.g., '--glob *') as they bypass .gitignore rules and may be slow
|
||||
- The pattern field is a raw regex string: do NOT wrap it in quotes or add trailing quote characters unnecessarily
|
||||
- Only use 'include' when certain of the file type needed. Note: import paths may not match source file types (.js vs .ts)
|
||||
- The ${{ params.search.pattern }} field is a raw regex string: do NOT wrap it in quotes or add trailing quote characters unnecessarily
|
||||
- Only use '${{ params.search.include }}' when certain of the file type needed. Note: import paths may not match source file types (.js vs .ts)
|
||||
- Results are capped for responsiveness; truncated results show "at least" counts.
|
||||
- Filter files by pattern with the include parameter (e.g. "*.js", "*.{ts,tsx}")
|
||||
- Filter files by pattern with the ${{ params.search.include }} parameter (e.g. "*.js", "*.{ts,tsx}")
|
||||
- Returns file paths and line numbers with at least one match sorted by modification time
|
||||
- Use this tool when you need to find files containing specific patterns"#;
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const DESCRIPTION: &str = r#"Reads a file from the local filesystem. You can acc
|
|||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- The ${{ params.read.filePath }} parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to {max_lines_read} lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than {max_chars_per_line} characters will be truncated
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use crate::types::output::ToolOutput;
|
|||
use crate::types::resources::{SharedResources, State, Terminal};
|
||||
use crate::types::tool::{Reminder, ToolKind};
|
||||
use crate::util::truncate::{PREVIEW_SIZE, truncate_with_preview};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use xai_tool_types::KillTaskOutput;
|
||||
use xai_tool_types::SubagentCompletedOutput;
|
||||
|
|
@ -33,56 +33,55 @@ pub const DEFAULT_TASK_OUTPUT_TOOL: &str = "get_task_output";
|
|||
/// disk-backed output file) are never truncated -- the inline branch is
|
||||
/// their only chance to see the output.
|
||||
const MAX_INLINE_COMPLETION_BYTES: usize = 4_000;
|
||||
/// Shared set of IDs that have already been delivered via auto-wake synthetic
|
||||
/// prompts. `TaskCompletionReminder` drains this set on each reminder pass
|
||||
/// and extends its suppress list, preventing duplicate reminders for
|
||||
/// completions that already triggered an auto-wake turn.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AutoWakeDeliveredIds(pub Arc<std::sync::Mutex<HashSet<String>>>);
|
||||
impl AutoWakeDeliveredIds {
|
||||
/// Insert an ID into the delivered set.
|
||||
pub fn insert(&self, id: String) {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).insert(id);
|
||||
pub struct TaskCompletionReservations(pub Arc<std::sync::Mutex<HashMap<String, usize>>>);
|
||||
impl TaskCompletionReservations {
|
||||
pub fn reserve(&self, id: String) {
|
||||
let mut ids = self.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*ids.entry(id).or_default() += 1;
|
||||
}
|
||||
/// Remove a single ID from the delivered set (e.g. when a synthetic
|
||||
/// prompt is preempted or cancelled before being processed).
|
||||
pub fn remove(&self, id: &str) {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).remove(id);
|
||||
pub fn release(&self, id: &str) {
|
||||
let mut ids = self.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(count) = ids.get_mut(id) {
|
||||
if *count > 1 {
|
||||
*count -= 1;
|
||||
} else {
|
||||
ids.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Return `true` if `id` is currently marked as delivered, without
|
||||
/// draining the set. Preferred over [`snapshot`](Self::snapshot) for a
|
||||
/// single-membership check on a hot path (e.g. per monitor stdout event):
|
||||
/// it avoids cloning every ID into a `Vec`.
|
||||
pub fn contains(&self, id: &str) -> bool {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.contains(id)
|
||||
.contains_key(id)
|
||||
}
|
||||
/// Drain all IDs from the set, returning them.
|
||||
pub fn drain(&self) -> Vec<String> {
|
||||
let mut guard = self.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.drain().collect()
|
||||
}
|
||||
/// Return a snapshot of the currently-marked IDs **without** draining them.
|
||||
///
|
||||
/// Used by the between-turn completion drain in `xai-grok-shell` to
|
||||
/// suppress completions already delivered via auto-wake synthetic prompts.
|
||||
/// Unlike [`drain`](Self::drain) (the per-tool-call surface's consumption
|
||||
/// point), this is read-only so the existing drain/un-mark lifecycle —
|
||||
/// `TaskCompletionReminder` draining on each tool call and the
|
||||
/// preempt/cancel paths un-marking dropped synthetic prompts — stays the
|
||||
/// single source of truth for the set's contents.
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
crate::register_resource!("grok_build", "AutoWakeDeliveredIds", AutoWakeDeliveredIds);
|
||||
crate::register_resource!(
|
||||
"grok_build",
|
||||
"TaskCompletionReservations",
|
||||
TaskCompletionReservations
|
||||
);
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TaskWakeSuppressed(pub Arc<std::sync::atomic::AtomicBool>);
|
||||
impl TaskWakeSuppressed {
|
||||
pub fn set(&self, suppressed: bool) {
|
||||
self.0
|
||||
.store(suppressed, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
pub fn get(&self) -> bool {
|
||||
self.0.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
crate::register_resource!("grok_build", "TaskWakeSuppressed", TaskWakeSuppressed);
|
||||
/// Set of task IDs whose completion has already been surfaced as a
|
||||
/// `<system-reminder>`. Persisted via `State<T>` so it survives across
|
||||
/// tool calls within a session.
|
||||
|
|
@ -621,16 +620,28 @@ impl Reminder for TaskCompletionReminder {
|
|||
resources: SharedResources,
|
||||
tool_output: &ToolOutput,
|
||||
) -> Vec<String> {
|
||||
let mut suppress: Vec<String> = consumed_completion_ids(tool_output)
|
||||
let consumed_ids: Vec<String> = consumed_completion_ids(tool_output)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
{
|
||||
let reserved_ids = {
|
||||
let res = resources.lock().await;
|
||||
if let Some(auto_wake) = res.get::<AutoWakeDeliveredIds>() {
|
||||
suppress.extend(auto_wake.drain());
|
||||
if res
|
||||
.get::<TaskWakeSuppressed>()
|
||||
.is_some_and(TaskWakeSuppressed::get)
|
||||
{
|
||||
tracing::debug!("task wake reminder suppressed");
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
res.get::<TaskCompletionReservations>()
|
||||
.map(TaskCompletionReservations::snapshot)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let suppress_ids = consumed_ids
|
||||
.iter()
|
||||
.chain(&reserved_ids)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let (terminal, event_sender) = {
|
||||
let res = resources.lock().await;
|
||||
(
|
||||
|
|
@ -673,7 +684,7 @@ impl Reminder for TaskCompletionReminder {
|
|||
.map(str::to_string)
|
||||
});
|
||||
let state = res.get_or_default::<State<ReportedTaskCompletions>>();
|
||||
for id in &suppress {
|
||||
for id in &consumed_ids {
|
||||
state.reported.insert(id.clone());
|
||||
}
|
||||
if surface_reminders {
|
||||
|
|
@ -681,7 +692,9 @@ impl Reminder for TaskCompletionReminder {
|
|||
tasks
|
||||
.iter()
|
||||
.filter(|task| {
|
||||
task.completed && state.reported.insert(task.task_id.clone())
|
||||
task.completed
|
||||
&& !reserved_ids.contains(&task.task_id)
|
||||
&& state.reported.insert(task.task_id.clone())
|
||||
})
|
||||
.map(|task| {
|
||||
format_bash_completion(
|
||||
|
|
@ -693,7 +706,7 @@ impl Reminder for TaskCompletionReminder {
|
|||
);
|
||||
} else {
|
||||
for task in &tasks {
|
||||
if task.completed {
|
||||
if task.completed && !reserved_ids.contains(&task.task_id) {
|
||||
state.reported.insert(task.task_id.clone());
|
||||
}
|
||||
}
|
||||
|
|
@ -716,7 +729,7 @@ impl Reminder for TaskCompletionReminder {
|
|||
if sender
|
||||
.0
|
||||
.send(SubagentEvent::Completions(SubagentCompletionsRequest {
|
||||
suppress_ids: suppress,
|
||||
suppress_ids,
|
||||
respond_to: tx,
|
||||
}))
|
||||
.is_err()
|
||||
|
|
@ -1197,6 +1210,14 @@ mod tests {
|
|||
res.register_state::<ReportedTaskCompletions>();
|
||||
res.into_shared()
|
||||
}
|
||||
fn shared_with_gate(tasks: Vec<TaskSnapshot>, gate: TaskWakeSuppressed) -> SharedResources {
|
||||
let mut res = Resources::new();
|
||||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal { tasks });
|
||||
res.insert(Terminal(backend));
|
||||
res.insert(gate);
|
||||
res.register_state::<ReportedTaskCompletions>();
|
||||
res.into_shared()
|
||||
}
|
||||
/// Like `shared_with` but inserts `BashParams` with
|
||||
/// `surface_bg_completion_reminders = false` so the
|
||||
/// reminder is suppressed.
|
||||
|
|
@ -1301,11 +1322,46 @@ mod tests {
|
|||
truncation_hint: String::new(),
|
||||
raw_output_bytes: 4,
|
||||
}));
|
||||
let r = reminder.collect_reminders(shared, &output).await;
|
||||
let r = reminder.collect_reminders(shared.clone(), &output).await;
|
||||
assert!(
|
||||
r.is_empty(),
|
||||
"get_task_output(completed) should suppress reminder"
|
||||
);
|
||||
assert!(
|
||||
shared
|
||||
.lock()
|
||||
.await
|
||||
.get::<State<ReportedTaskCompletions>>()
|
||||
.expect("reported state")
|
||||
.reported
|
||||
.contains("t1")
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_gate_suppresses_visible_completion_without_reporting_it() {
|
||||
let gate = TaskWakeSuppressed::default();
|
||||
gate.set(true);
|
||||
let shared = shared_with_gate(vec![make_completed("visible")], gate.clone());
|
||||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||||
assert!(
|
||||
TaskCompletionReminder
|
||||
.collect_reminders(shared.clone(), &output)
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
shared
|
||||
.lock()
|
||||
.await
|
||||
.get::<State<ReportedTaskCompletions>>()
|
||||
.is_none_or(|state| !state.reported.contains("visible"))
|
||||
);
|
||||
gate.set(false);
|
||||
let reminders = TaskCompletionReminder
|
||||
.collect_reminders(shared, &output)
|
||||
.await;
|
||||
assert_eq!(reminders.len(), 1);
|
||||
assert!(reminders[0].contains("visible"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn not_suppressed_for_unrelated_output() {
|
||||
|
|
@ -1608,56 +1664,99 @@ mod tests {
|
|||
);
|
||||
}
|
||||
#[test]
|
||||
fn auto_wake_delivered_ids_insert_and_drain() {
|
||||
let ids = AutoWakeDeliveredIds::default();
|
||||
ids.insert("t1".into());
|
||||
ids.insert("t2".into());
|
||||
let drained = ids.drain();
|
||||
assert_eq!(drained.len(), 2);
|
||||
assert!(drained.contains(&"t1".to_string()));
|
||||
assert!(drained.contains(&"t2".to_string()));
|
||||
assert!(ids.drain().is_empty());
|
||||
fn task_completion_reservations_are_reference_counted() {
|
||||
let reservations = TaskCompletionReservations::default();
|
||||
reservations.reserve("t1".into());
|
||||
reservations.reserve("t1".into());
|
||||
reservations.release("t1");
|
||||
assert!(reservations.contains("t1"));
|
||||
reservations.release("t1");
|
||||
assert!(!reservations.contains("t1"));
|
||||
}
|
||||
#[test]
|
||||
fn auto_wake_delivered_ids_dedup() {
|
||||
let ids = AutoWakeDeliveredIds::default();
|
||||
ids.insert("t1".into());
|
||||
ids.insert("t1".into());
|
||||
let drained = ids.drain();
|
||||
assert_eq!(drained.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn auto_wake_delivered_ids_snapshot_is_non_destructive() {
|
||||
let ids = AutoWakeDeliveredIds::default();
|
||||
ids.insert("t1".into());
|
||||
ids.insert("t2".into());
|
||||
let snap = ids.snapshot();
|
||||
assert_eq!(snap.len(), 2);
|
||||
assert!(snap.contains(&"t1".to_string()));
|
||||
assert!(snap.contains(&"t2".to_string()));
|
||||
assert_eq!(ids.drain().len(), 2);
|
||||
fn task_completion_reservations_snapshot_is_non_destructive() {
|
||||
let reservations = TaskCompletionReservations::default();
|
||||
reservations.reserve("t1".into());
|
||||
reservations.reserve("t2".into());
|
||||
let snapshot = reservations.snapshot();
|
||||
assert_eq!(snapshot.len(), 2);
|
||||
assert!(snapshot.contains(&"t1".to_string()));
|
||||
assert!(snapshot.contains(&"t2".to_string()));
|
||||
assert!(reservations.contains("t1"));
|
||||
assert!(reservations.contains("t2"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn auto_wake_delivered_ids_suppress_reminders() {
|
||||
async fn task_completion_reservations_suppress_reminders() {
|
||||
let mut res = Resources::new();
|
||||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal {
|
||||
tasks: vec![make_completed("t1"), make_completed("t2")],
|
||||
});
|
||||
res.insert(Terminal(backend));
|
||||
res.register_state::<ReportedTaskCompletions>();
|
||||
let auto_wake = AutoWakeDeliveredIds::default();
|
||||
auto_wake.insert("t1".into());
|
||||
res.insert(auto_wake);
|
||||
let reservations = TaskCompletionReservations::default();
|
||||
reservations.reserve("t1".into());
|
||||
res.insert(reservations);
|
||||
let shared = res.into_shared();
|
||||
let reminder = TaskCompletionReminder;
|
||||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||||
let r = reminder.collect_reminders(shared, &output).await;
|
||||
assert_eq!(
|
||||
r.len(),
|
||||
1,
|
||||
"auto-wake delivered ID should suppress reminder"
|
||||
);
|
||||
let r = reminder.collect_reminders(shared.clone(), &output).await;
|
||||
assert_eq!(r.len(), 1, "reserved ID should suppress reminder");
|
||||
assert!(r[0].contains("t2"));
|
||||
let res = shared.lock().await;
|
||||
assert!(
|
||||
res.get::<TaskCompletionReservations>()
|
||||
.is_some_and(|ids| ids.contains("t1"))
|
||||
);
|
||||
assert!(
|
||||
!res.get::<State<ReportedTaskCompletions>>()
|
||||
.expect("reported state")
|
||||
.reported
|
||||
.contains("t1")
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn reserved_completion_surfaces_after_release() {
|
||||
let mut res = Resources::new();
|
||||
let backend: Arc<dyn TerminalBackend> = Arc::new(MockTerminal {
|
||||
tasks: vec![make_completed("reserved")],
|
||||
});
|
||||
res.insert(Terminal(backend));
|
||||
res.register_state::<ReportedTaskCompletions>();
|
||||
let reservations = TaskCompletionReservations::default();
|
||||
reservations.reserve("reserved".into());
|
||||
res.insert(reservations.clone());
|
||||
let shared = res.into_shared();
|
||||
let reminder = TaskCompletionReminder;
|
||||
let output = ToolOutput::Dynamic(serde_json::Value::Null.into());
|
||||
assert!(
|
||||
reminder
|
||||
.collect_reminders(shared.clone(), &output)
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
assert!(reservations.contains("reserved"));
|
||||
assert!(
|
||||
!shared
|
||||
.lock()
|
||||
.await
|
||||
.get::<State<ReportedTaskCompletions>>()
|
||||
.expect("reported state")
|
||||
.reported
|
||||
.contains("reserved")
|
||||
);
|
||||
reservations.release("reserved");
|
||||
let reminders = reminder.collect_reminders(shared.clone(), &output).await;
|
||||
assert_eq!(reminders.len(), 1);
|
||||
assert!(reminders[0].contains("reserved"));
|
||||
assert!(
|
||||
shared
|
||||
.lock()
|
||||
.await
|
||||
.get::<State<ReportedTaskCompletions>>()
|
||||
.expect("reported state")
|
||||
.reported
|
||||
.contains("reserved")
|
||||
);
|
||||
}
|
||||
/// Regression: subagent inline output larger than the bash-completion
|
||||
/// inline cap MUST be preserved verbatim. The inline branch is the
|
||||
|
|
|
|||
|
|
@ -1417,6 +1417,17 @@ mod tests {
|
|||
std::path::PathBuf::from("/worktree/abc/src/main.rs")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn resolve_model_path_sensitive_edit_spellings() {
|
||||
let cwd = std::path::Path::new("/worktree/abc");
|
||||
for input in [" /etc/hosts ", "\"/etc/hosts\\n\"", "'/etc/hosts\\r\\t'"] {
|
||||
assert_eq!(
|
||||
super::resolve_model_path(cwd, None, input),
|
||||
std::path::PathBuf::from("/etc/hosts"),
|
||||
"{input:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
/// An *unquoted* path keeps its backslashes: `\n` there may be a real
|
||||
/// path component (e.g. a Windows-style separator + dir named `n`).
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue