1use std::fmt::Write as FmtWrite;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tokio::sync::Mutex;
8use tracing::{debug, info, instrument, warn};
9
10use crate::errors::{Result, WinxError};
11use crate::state::bash_state::{generate_thread_id, BashState};
12use crate::types::{
13 normalize_thread_id, AllowedCommands, AllowedGlobs, BashCommandMode, BashMode,
14 CodeWriterConfig, FileEditMode, Initialize, InitializeType, Modes, WriteIfEmptyMode,
15};
16use crate::utils::mmap::read_file_to_string;
17use crate::utils::path::{ensure_directory_exists, expand_user, validate_path_in_workspace};
18
19fn create_playground_dir() -> Result<PathBuf> {
22 let stamp = std::time::SystemTime::now()
23 .duration_since(std::time::UNIX_EPOCH)
24 .map_or(0, |d| d.as_nanos());
25 let dir =
26 std::env::temp_dir().join(format!("winx-playground-{}-{:x}", std::process::id(), stamp));
27 ensure_directory_exists(&dir)?;
28 Ok(dir)
29}
30
31fn command_exists(cmd: &str) -> bool {
33 std::process::Command::new("sh")
34 .args(["-c", &format!("command -v {cmd}")])
35 .output()
36 .is_ok_and(|o| o.status.success())
37}
38
39fn code_writer_state(
40 config: &CodeWriterConfig,
41 workspace_root: &Path,
42) -> (BashCommandMode, FileEditMode, WriteIfEmptyMode) {
43 let mut config = config.clone();
44 config.allowed_globs.normalize();
46 config.allowed_commands.normalize();
47 config.update_relative_globs(&workspace_root.to_string_lossy());
48
49 if let AllowedCommands::List(cmds) = &config.allowed_commands {
50 let bypass = crate::utils::bash_parser::detect_allowlist_bypass(cmds);
51 if !bypass.is_empty() {
52 warn!(
53 commands = ?bypass,
54 "code_writer allowlist includes shell-spawning commands; the command \
55 allowlist is effectively bypassable and does not sandbox the agent"
56 );
57 }
58 }
59
60 (
61 BashCommandMode {
62 bash_mode: BashMode::NormalMode,
63 allowed_commands: config.allowed_commands,
64 },
65 FileEditMode { allowed_globs: config.allowed_globs.clone() },
66 WriteIfEmptyMode { allowed_globs: config.allowed_globs },
67 )
68}
69
70fn mode_to_state(
71 mode: Modes,
72 config: Option<&CodeWriterConfig>,
73 workspace_root: &Path,
74) -> Result<(BashCommandMode, FileEditMode, WriteIfEmptyMode)> {
75 match mode {
76 Modes::Wcgw => Ok((
77 BashCommandMode {
78 bash_mode: BashMode::NormalMode,
79 allowed_commands: AllowedCommands::All("all".to_string()),
80 },
81 FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
82 WriteIfEmptyMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
83 )),
84 Modes::Architect => Ok((
85 BashCommandMode {
86 bash_mode: BashMode::RestrictedMode,
87 allowed_commands: AllowedCommands::List(
88 crate::utils::bash_parser::architect_allowed_commands(),
89 ),
90 },
91 FileEditMode { allowed_globs: AllowedGlobs::List(vec![]) },
92 WriteIfEmptyMode { allowed_globs: AllowedGlobs::List(vec![]) },
93 )),
94 Modes::CodeWriter => {
95 let config = config.ok_or_else(|| {
96 WinxError::ArgumentParseError(
97 "code_writer_config is required when mode_name is code_writer.".to_string(),
98 )
99 })?;
100 Ok(code_writer_state(config, workspace_root))
101 }
102 }
103}
104
105fn read_initial_files_simple(files: &[String], workspace: &std::path::Path) -> String {
106 let mut output = String::new();
107 for file_path in files {
108 let expanded = expand_user(file_path);
109 let path = if std::path::Path::new(&expanded).is_absolute() {
110 PathBuf::from(&expanded)
111 } else {
112 workspace.join(&expanded)
113 };
114
115 if let Ok(validated) = validate_path_in_workspace(&path, workspace) {
116 if validated.exists() && validated.is_file() {
117 if let Ok(content) = read_file_to_string(&validated, 10_000_000) {
118 let _ = write!(output, "\n{file_path}\n```\n{content}\n```\n");
119 }
120 }
121 }
122 }
123 output
124}
125
126fn prepare_workspace(initialize: &Initialize, response: &mut String) -> Result<PathBuf> {
127 let workspace_path_str = expand_user(&initialize.any_workspace_path);
128 if workspace_path_str.is_empty() {
129 let playground = create_playground_dir()?;
132 let _ = writeln!(
133 response,
134 "No workspace path provided; created a playground at {}",
135 playground.display()
136 );
137 return Ok(playground);
138 }
139
140 let workspace_path = PathBuf::from(&workspace_path_str);
141 let mut folder_to_start = workspace_path.clone();
142
143 if workspace_path.exists() {
144 if workspace_path.is_file() {
145 folder_to_start = workspace_path.parent().unwrap_or(&workspace_path).to_path_buf();
146 let _ =
147 writeln!(response, "Using parent directory of file: {}", folder_to_start.display());
148 } else if workspace_path.is_dir() {
149 let _ = writeln!(response, "Using workspace directory: {}", folder_to_start.display());
150 }
151 } else if workspace_path.is_absolute() {
152 ensure_directory_exists(&workspace_path).map_err(|e| {
153 WinxError::WorkspacePathError(format!("Failed to create workspace: {e}"))
154 })?;
155 let _ = writeln!(response, "Created workspace directory: {}", workspace_path.display());
156 }
157
158 if folder_to_start.exists() {
162 if let Ok(canonical) = folder_to_start.canonicalize() {
163 folder_to_start = canonical;
164 }
165 }
166
167 Ok(folder_to_start)
168}
169
170fn initialize_thread_id(initialize: &Initialize) -> String {
171 let thread_id = normalize_thread_id(&initialize.thread_id);
172 if thread_id.is_empty() {
173 generate_thread_id()
174 } else {
175 thread_id
176 }
177}
178
179fn validate_thread_id(initialize: &Initialize) -> Result<()> {
180 if initialize.init_type != InitializeType::FirstCall
181 && normalize_thread_id(&initialize.thread_id).is_empty()
182 {
183 return Err(WinxError::ThreadIdMismatch(
184 "Thread id should be provided if type != 'first_call', including when resetting."
185 .to_string(),
186 ));
187 }
188
189 Ok(())
190}
191
192fn load_guidelines(workspace: &Path) -> String {
193 let mut output = String::new();
194 let mut candidates = Vec::new();
195 if let Some(home) = home::home_dir() {
196 candidates.push(home.join(".winx").join("AGENTS.md"));
197 candidates.push(home.join(".winx").join("CLAUDE.md"));
198 candidates.push(home.join(".wcgw").join("AGENTS.md"));
199 candidates.push(home.join(".wcgw").join("CLAUDE.md"));
200 }
201 candidates.push(workspace.join("AGENTS.md"));
202 candidates.push(workspace.join("CLAUDE.md"));
203
204 for path in candidates {
205 if path.is_file() {
206 if let Ok(content) = fs::read_to_string(&path) {
207 let _ = writeln!(output, "\n## {}\n{}", path.display(), content);
208 }
209 }
210 }
211 output
212}
213
214#[instrument(level = "info", skip(bash_state_arc, initialize))]
215#[allow(clippy::too_many_lines)]
216pub async fn handle_tool_call(
217 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
218 initialize: Initialize,
219) -> Result<String> {
220 let mut response = String::new();
221
222 info!("Initialize called for workspace: {}", initialize.any_workspace_path);
223
224 validate_thread_id(&initialize)?;
225 let folder_to_start = prepare_workspace(&initialize, &mut response)?;
226 let thread_id = initialize_thread_id(&initialize);
227
228 let mut bash_state_guard = bash_state_arc.lock().await;
229 let mode = Modes::from(&initialize.mode_name);
230 let (bash_command_mode, file_edit_mode, write_if_empty_mode) =
231 mode_to_state(mode, initialize.code_writer_config.as_ref(), &folder_to_start)?;
232
233 match initialize.init_type {
234 InitializeType::FirstCall => {
235 let mut new_bash_state = BashState::new();
236 new_bash_state.current_thread_id.clone_from(&thread_id);
237 new_bash_state.mode = mode;
238 new_bash_state.bash_command_mode = bash_command_mode;
239 new_bash_state.file_edit_mode = file_edit_mode;
240 new_bash_state.write_if_empty_mode = write_if_empty_mode;
241 new_bash_state.initialized = true;
242
243 let resumed_context = if initialize.task_id_to_resume.is_empty() {
244 None
245 } else {
246 crate::tools::context_save::load_saved_context(&initialize.task_id_to_resume)?
247 };
248
249 if let Some((memory_data, snapshot)) = &resumed_context {
250 if let Some(snapshot) = snapshot {
251 new_bash_state.apply_snapshot(snapshot);
252 new_bash_state.current_thread_id.clone_from(&thread_id);
253 }
254 let _ = writeln!(
255 response,
256 "\n# Resumed task {}\nFollowing is the retrieved task context:\n{}",
257 initialize.task_id_to_resume, memory_data
258 );
259 }
260
261 if resumed_context.as_ref().and_then(|(_, snapshot)| snapshot.as_ref()).is_none() {
265 let resumed_root = resumed_context
266 .as_ref()
267 .and_then(|(memory, _)| {
268 crate::tools::context_save::extract_project_root(memory)
269 })
270 .filter(|root| root.exists());
271 let target = resumed_root.as_deref().unwrap_or(folder_to_start.as_path());
272 if target.exists() {
273 new_bash_state.update_cwd(target)?;
274 new_bash_state.update_workspace_root(target)?;
275 }
276 }
277 if new_bash_state.cwd.exists() {
278 new_bash_state.init_pty_shell().await?;
279 }
280
281 let attach_hint = {
282 let pty_guard = new_bash_state.pty_shell.lock().await;
283 pty_guard.as_ref().and_then(|shell| shell.attach_hint.clone())
284 };
285
286 *bash_state_guard = Some(new_bash_state);
287
288 let _ = write!(
289 response,
290 "\n# Environment\nSystem: {}\nMachine: {}\nInitialized in directory: {}\n",
291 std::env::consts::OS,
292 std::env::consts::ARCH,
293 bash_state_guard
294 .as_ref()
295 .map_or(folder_to_start.as_path(), |state| state.cwd.as_path())
296 .display()
297 );
298
299 if command_exists("rg") {
300 let _ = writeln!(
301 response,
302 "\n# Available commands\nUse ripgrep `rg` instead of `grep`/`find -name` — \
303 it's much faster and respects .gitignore."
304 );
305 }
306
307 let _ = writeln!(response, "\nUse thread_id={thread_id} for all winx tool calls.");
308 if let Some(attach_hint) = attach_hint {
309 let _ = writeln!(response, "\nAttach terminal: {attach_hint}");
310 }
311
312 let _ = writeln!(
316 response,
317 "\n{}",
318 crate::utils::mode_prompts::mode_prompt(
319 mode,
320 initialize.code_writer_config.as_ref()
321 )
322 );
323
324 if let Some(cfg) = initialize.code_writer_config.as_ref() {
329 if let AllowedCommands::List(cmds) = &cfg.allowed_commands {
330 let bypass = crate::utils::bash_parser::detect_allowlist_bypass(cmds);
331 if !bypass.is_empty() {
332 let _ = writeln!(
333 response,
334 "\n⚠️ SECURITY: code_writer allowlist includes shell/eval commands ({}). \
335 They execute arbitrary code from string arguments (e.g. `bash -c …`, \
336 `find -exec …`), so the command allowlist is effectively bypassable and \
337 does NOT sandbox the agent. Drop them if you intended a hard restriction.",
338 bypass.join(", ")
339 );
340 }
341 }
342 }
343
344 let active_workspace = bash_state_guard
345 .as_ref()
346 .map_or(folder_to_start.as_path(), |state| state.workspace_root.as_path());
347
348 let guidelines = load_guidelines(active_workspace);
349 if !guidelines.is_empty() {
350 let _ = writeln!(response, "\n# Agent guidelines\n{guidelines}");
351 }
352
353 if let Ok((repo_context, _)) = crate::utils::repo::get_repo_context(active_workspace) {
354 let _ = writeln!(response, "\n# Workspace structure\n{repo_context}");
355 }
356
357 if !initialize.initial_files_to_read.is_empty() {
358 let content =
359 read_initial_files_simple(&initialize.initial_files_to_read, active_workspace);
360 if !content.is_empty() {
361 let _ = writeln!(response, "\n# Requested files\n{content}");
362 }
363 }
364 }
365 InitializeType::UserAskedModeChange => {
366 if let Some(state) = bash_state_guard.as_mut() {
367 state.mode = mode;
368 state.bash_command_mode = bash_command_mode;
369 state.file_edit_mode = file_edit_mode;
370 state.write_if_empty_mode = write_if_empty_mode;
371 let _ = writeln!(response, "Changed mode to: {mode:?}");
372 let _ = writeln!(
373 response,
374 "\n{}",
375 crate::utils::mode_prompts::mode_prompt(
376 mode,
377 initialize.code_writer_config.as_ref()
378 )
379 );
380 } else {
381 return Err(WinxError::BashStateNotInitialized);
382 }
383 }
384 InitializeType::ResetShell => {
385 if let Some(state) = bash_state_guard.as_mut() {
386 state.mode = mode;
387 state.bash_command_mode = bash_command_mode;
388 state.file_edit_mode = file_edit_mode;
389 state.write_if_empty_mode = write_if_empty_mode;
390 state.init_pty_shell().await?;
391 response.push_str("Reset shell (new PTY created)\n");
392 } else {
393 return Err(WinxError::BashStateNotInitialized);
394 }
395 }
396 InitializeType::UserAskedChangeWorkspace => {
397 if let Some(state) = bash_state_guard.as_mut() {
398 if folder_to_start.exists() {
399 state.update_cwd(&folder_to_start)?;
400 state.update_workspace_root(&folder_to_start)?;
401 let _ =
402 writeln!(response, "Changed workspace to: {}", folder_to_start.display());
403 } else {
404 let _ = writeln!(
405 response,
406 "Warning: Workspace path {} does not exist",
407 folder_to_start.display()
408 );
409 }
410 } else {
411 return Err(WinxError::BashStateNotInitialized);
412 }
413 }
414 }
415
416 append_server_instructions(&mut response);
417
418 Ok(response)
419}
420
421fn append_server_instructions(response: &mut String) {
424 response.push_str(
425 "\nAs soon as you encounter \"The user has chosen to disallow the tool call.\", \
426 immediately stop doing everything and ask the user for the reason.\n",
427 );
428 if let Ok(extra) = std::env::var("WINX_SERVER_INSTRUCTIONS") {
429 let extra = extra.trim();
430 if !extra.is_empty() {
431 let _ = write!(response, "\n# Additional instructions\n{extra}\n");
432 }
433 }
434}