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::{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(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
33/// Whether `cmd` is on PATH (best-effort, used only for advisory hints).
34fn 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    // Forgive the common `["all"]` mistake before turning relative globs absolute.
47    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        // wcgw parity: no path given → spin up a scratch playground instead of
136        // forcing the agent to always supply a workspace.
137        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    // Canonicalize so downstream comparisons (workspace checks, glob prefixes) match
165    // paths that were canonicalized via fs::canonicalize — important on macOS where
166    // /var, /tmp etc. are symlinks to /private/var, /private/tmp.
167    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                // Refresh guardian activity without resetting the PTY. Reusing the
257                // current snapshot as a mode transition is also compatible with
258                // protocol-1.2 guardians that predate attach-or-create.
259                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                // A bash snapshot already carries cwd/workspace. Without one, prefer
291                // the project root recorded in the resumed memory (so the agent lands
292                // back in the right repo), then fall back to the provided folder.
293                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            // Inject the behavioral prompt for the active mode so the agent knows
346            // how to behave (read-only / allowed globs / etc.) before its first
347            // action, instead of discovering the rules by hitting enforcement errors.
348            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            // Transparency: a code_writer command allowlist that includes a
358            // shell/eval spawner is bypassable (`bash -c '...'`, `find -exec`),
359            // so surface it in the response the model reads — here the allowlist
360            // is a convenience filter, not a sandbox.
361            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
458/// Append the standard "disallow" note plus any operator-provided instructions
459/// from `WINX_SERVER_INSTRUCTIONS`, mirroring wcgw's Initialize output.
460fn 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}