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