storkit: create 365_story_surface_api_rate_limit_warnings_in_chat
This commit is contained in:
@@ -1,189 +0,0 @@
|
||||
use crate::state::SessionState;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Helper to get the root path (cloned) without joining
|
||||
fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
|
||||
state.get_project_root()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, poem_openapi::Object)]
|
||||
pub struct CommandOutput {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
/// Execute shell command logic (pure function for testing)
|
||||
async fn exec_shell_impl(
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
root: PathBuf,
|
||||
) -> Result<CommandOutput, String> {
|
||||
// Security Allowlist
|
||||
let allowed_commands = [
|
||||
"git", "cargo", "npm", "yarn", "pnpm", "node", "bun", "ls", "find", "grep", "mkdir", "rm",
|
||||
"mv", "cp", "touch", "rustc", "rustfmt",
|
||||
];
|
||||
|
||||
if !allowed_commands.contains(&command.as_str()) {
|
||||
return Err(format!("Command '{}' is not in the allowlist.", command));
|
||||
}
|
||||
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
Command::new(&command)
|
||||
.args(&args)
|
||||
.current_dir(root)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
.map_err(|e| format!("Failed to execute command: {}", e))?;
|
||||
|
||||
Ok(CommandOutput {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
exit_code: output.status.code().unwrap_or(-1),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn exec_shell(
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
state: &SessionState,
|
||||
) -> Result<CommandOutput, String> {
|
||||
let root = get_project_root(state)?;
|
||||
exec_shell_impl(command, args, root).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_shell_impl_rejects_disallowed_command() {
|
||||
let dir = tempdir().unwrap();
|
||||
let result = exec_shell_impl(
|
||||
"curl".to_string(),
|
||||
vec!["https://example.com".to_string()],
|
||||
dir.path().to_path_buf(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not in the allowlist"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_shell_impl_runs_allowed_command() {
|
||||
let dir = tempdir().unwrap();
|
||||
let result = exec_shell_impl(
|
||||
"ls".to_string(),
|
||||
Vec::new(),
|
||||
dir.path().to_path_buf(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert_eq!(output.exit_code, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_shell_impl_captures_stdout() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("hello.txt"), "").unwrap();
|
||||
|
||||
let result = exec_shell_impl(
|
||||
"ls".to_string(),
|
||||
Vec::new(),
|
||||
dir.path().to_path_buf(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.stdout.contains("hello.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_shell_impl_returns_nonzero_exit_code() {
|
||||
let dir = tempdir().unwrap();
|
||||
let result = exec_shell_impl(
|
||||
"ls".to_string(),
|
||||
vec!["nonexistent_file_xyz".to_string()],
|
||||
dir.path().to_path_buf(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(result.exit_code, 0);
|
||||
assert!(!result.stderr.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_shell_delegates_to_impl_via_state() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("marker.txt"), "hello").unwrap();
|
||||
|
||||
let state = SessionState::default();
|
||||
*state.project_root.lock().unwrap() = Some(dir.path().to_path_buf());
|
||||
|
||||
let result = exec_shell("ls".to_string(), Vec::new(), &state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.exit_code, 0);
|
||||
assert!(result.stdout.contains("marker.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_shell_errors_when_no_project_root() {
|
||||
let state = SessionState::default();
|
||||
|
||||
let result = exec_shell("ls".to_string(), Vec::new(), &state).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No project"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_shell_impl_errors_on_nonexistent_cwd() {
|
||||
let result = exec_shell_impl(
|
||||
"ls".to_string(),
|
||||
Vec::new(),
|
||||
PathBuf::from("/nonexistent_dir_that_does_not_exist_xyz"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Failed to execute command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_serializes_to_json() {
|
||||
let output = CommandOutput {
|
||||
stdout: "hello".to_string(),
|
||||
stderr: "".to_string(),
|
||||
exit_code: 0,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&output).unwrap();
|
||||
assert!(json.contains("\"stdout\":\"hello\""));
|
||||
assert!(json.contains("\"exit_code\":0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_debug_format() {
|
||||
let output = CommandOutput {
|
||||
stdout: "out".to_string(),
|
||||
stderr: "err".to_string(),
|
||||
exit_code: 1,
|
||||
};
|
||||
|
||||
let debug = format!("{:?}", output);
|
||||
assert!(debug.contains("CommandOutput"));
|
||||
assert!(debug.contains("out"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user