Skip to main content

winx_code_agent/tools/
initialize.rs

1//! Implementation of the Initialize tool.
2
3use 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
19/// Create a unique scratch workspace under the system temp dir, used when the
20/// caller initializes without a workspace path.
21fn 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
31/// Whether `cmd` is on PATH (best-effort, used only for advisory hints).
32fn 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    // Forgive the common `["all"]` mistake before turning relative globs absolute.
45    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        // wcgw parity: no path given → spin up a scratch playground instead of
130        // forcing the agent to always supply a workspace.
131        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    // Canonicalize so downstream comparisons (workspace checks, glob prefixes) match
159    // paths that were canonicalized via fs::canonicalize — important on macOS where
160    // /var, /tmp etc. are symlinks to /private/var, /private/tmp.
161    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            // A bash snapshot already carries cwd/workspace. Without one, prefer
262            // the project root recorded in the resumed memory (so the agent lands
263            // back in the right repo), then fall back to the provided folder.
264            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            // Inject the behavioral prompt for the active mode so the agent knows
313            // how to behave (read-only / allowed globs / etc.) before its first
314            // action, instead of discovering the rules by hitting enforcement errors.
315            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            // Transparency: a code_writer command allowlist that includes a
325            // shell/eval spawner is bypassable (`bash -c '...'`, `find -exec`),
326            // so surface it in the response the model reads — here the allowlist
327            // is a convenience filter, not a sandbox.
328            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
421/// Append the standard "disallow" note plus any operator-provided instructions
422/// from `WINX_SERVER_INSTRUCTIONS`, mirroring wcgw's Initialize output.
423fn 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}