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