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