1use std::collections::VecDeque;
4use std::fmt::Write as FmtWrite;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex as StdMutex, OnceLock};
8use tokio::sync::Mutex;
9use tracing::{info, instrument, warn};
10
11use crate::errors::{Result, WinxError};
12use crate::runtime::{EmbeddedShellRuntime, ShellRuntime, ShellSessionTransition};
13use crate::state::bash_state::{generate_thread_id, BashState};
14use crate::types::{
15 normalize_thread_id, AllowedCommands, AllowedGlobs, BashCommandMode, BashMode,
16 CodeWriterConfig, FileEditMode, Initialize, InitializeType, Modes, WriteIfEmptyMode,
17};
18use crate::utils::mmap::read_file_to_string;
19use crate::utils::path::{ensure_directory_exists, expand_user, validate_path_in_workspace};
20
21const POLICY_WARNING_CACHE_CAPACITY: usize = 128;
22static WARNED_POLICY_CONFIGURATIONS: OnceLock<StdMutex<VecDeque<Vec<String>>>> = OnceLock::new();
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub(crate) enum InitializeTransition {
26 Created,
27 AttachedExisting,
28 ModeChanged,
29 ShellReset,
30 WorkspaceChanged,
31}
32
33impl InitializeTransition {
34 pub(crate) const fn as_str(self) -> &'static str {
35 match self {
36 Self::Created => "created",
37 Self::AttachedExisting => "attached_existing",
38 Self::ModeChanged => "mode_changed",
39 Self::ShellReset => "shell_reset",
40 Self::WorkspaceChanged => "workspace_changed",
41 }
42 }
43}
44
45#[derive(Debug)]
46pub(crate) struct InitializeOutcome {
47 pub(crate) text: String,
48 pub(crate) transition: InitializeTransition,
49 pub(crate) context_bytes: usize,
50 pub(crate) guidelines_bytes: usize,
51 pub(crate) initial_files_count: usize,
52 pub(crate) compact_response: bool,
53 pub(crate) code_writer_policy_strength: Option<&'static str>,
54 pub(crate) shell_spawners_present: bool,
55}
56
57fn create_playground_dir(thread_id: &str) -> Result<PathBuf> {
60 #[cfg(unix)]
61 let owner = crate::os::unix::effective_uid().to_string();
62 #[cfg(not(unix))]
63 let owner = std::process::id().to_string();
64
65 let dir = std::env::temp_dir().join(format!("winx-playground-{owner}-{thread_id}"));
66 ensure_directory_exists(&dir)?;
67 Ok(dir)
68}
69
70fn command_exists(cmd: &str) -> bool {
72 std::process::Command::new("sh")
73 .args(["-c", &format!("command -v {cmd}")])
74 .output()
75 .is_ok_and(|o| o.status.success())
76}
77
78fn code_writer_state(
79 config: &CodeWriterConfig,
80 workspace_root: &Path,
81) -> (BashCommandMode, FileEditMode, WriteIfEmptyMode) {
82 let mut config = config.clone();
83 config.allowed_globs.normalize();
85 config.allowed_commands.normalize();
86 config.update_relative_globs(&workspace_root.to_string_lossy());
87
88 (
89 BashCommandMode {
90 bash_mode: BashMode::NormalMode,
91 allowed_commands: config.allowed_commands,
92 },
93 FileEditMode { allowed_globs: config.allowed_globs.clone() },
94 WriteIfEmptyMode { allowed_globs: config.allowed_globs },
95 )
96}
97
98fn mode_to_state(
99 mode: Modes,
100 config: Option<&CodeWriterConfig>,
101 workspace_root: &Path,
102) -> Result<(BashCommandMode, FileEditMode, WriteIfEmptyMode)> {
103 match mode {
104 Modes::Wcgw => Ok((
105 BashCommandMode {
106 bash_mode: BashMode::NormalMode,
107 allowed_commands: AllowedCommands::All("all".to_string()),
108 },
109 FileEditMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
110 WriteIfEmptyMode { allowed_globs: AllowedGlobs::All("all".to_string()) },
111 )),
112 Modes::Architect => Ok((
113 BashCommandMode {
114 bash_mode: BashMode::RestrictedMode,
115 allowed_commands: AllowedCommands::List(
116 crate::utils::bash_parser::architect_allowed_commands(),
117 ),
118 },
119 FileEditMode { allowed_globs: AllowedGlobs::List(vec![]) },
120 WriteIfEmptyMode { allowed_globs: AllowedGlobs::List(vec![]) },
121 )),
122 Modes::CodeWriter => {
123 let config = config.ok_or_else(|| {
124 WinxError::ArgumentParseError(
125 "code_writer_config is required when mode_name is code_writer.".to_string(),
126 )
127 })?;
128 Ok(code_writer_state(config, workspace_root))
129 }
130 }
131}
132
133fn active_code_writer_config(state: &BashState) -> Option<CodeWriterConfig> {
134 (state.mode == Modes::CodeWriter).then(|| CodeWriterConfig {
135 allowed_globs: state.file_edit_mode.allowed_globs.clone(),
136 allowed_commands: state.bash_command_mode.allowed_commands.clone(),
137 })
138}
139
140fn active_code_writer_policy(state: &BashState) -> (Option<&'static str>, Vec<String>) {
141 if state.mode != Modes::CodeWriter {
142 return (None, Vec::new());
143 }
144
145 match &state.bash_command_mode.allowed_commands {
146 AllowedCommands::All(value) if value == "all" => (Some("unrestricted"), Vec::new()),
147 AllowedCommands::All(_) => (Some("restricted"), Vec::new()),
148 AllowedCommands::List(commands) => {
149 let bypass = crate::utils::bash_parser::detect_allowlist_bypass(commands);
150 let strength = if bypass.is_empty() { "restricted" } else { "weak" };
151 (Some(strength), bypass)
152 }
153 }
154}
155
156fn warn_code_writer_policy_once(state: &BashState, bypass: &[String]) {
157 if bypass.is_empty() {
158 return;
159 }
160 let AllowedCommands::List(commands) = &state.bash_command_mode.allowed_commands else {
161 return;
162 };
163
164 let cache = WARNED_POLICY_CONFIGURATIONS
165 .get_or_init(|| StdMutex::new(VecDeque::with_capacity(POLICY_WARNING_CACHE_CAPACITY)));
166 let mut cache = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
167 if cache.iter().any(|seen| seen == commands) {
168 return;
169 }
170 if cache.len() == POLICY_WARNING_CACHE_CAPACITY {
171 cache.pop_front();
172 }
173 cache.push_back(commands.clone());
174 drop(cache);
175
176 warn!(
177 commands = ?bypass,
178 "code_writer allowlist includes shell-spawning commands; the command allowlist is \
179 effectively bypassable and does not sandbox the agent"
180 );
181}
182
183fn append_code_writer_policy_warning(response: &mut String, bypass: &[String]) {
184 if bypass.is_empty() {
185 return;
186 }
187 let _ = writeln!(
188 response,
189 "\n⚠️ SECURITY: code_writer allowlist includes shell/eval commands ({}). \
190 They execute arbitrary code from string arguments (e.g. `bash -c …`, \
191 `find -exec …`), so the command allowlist is effectively bypassable and \
192 does NOT sandbox the agent. Drop them if you intended a hard restriction.",
193 bypass.join(", ")
194 );
195}
196
197fn read_initial_files_simple(files: &[String], workspace: &std::path::Path) -> String {
198 let mut output = String::new();
199 for file_path in files {
200 let expanded = expand_user(file_path);
201 let path = if std::path::Path::new(&expanded).is_absolute() {
202 PathBuf::from(&expanded)
203 } else {
204 workspace.join(&expanded)
205 };
206
207 if let Ok(validated) = validate_path_in_workspace(&path, workspace) {
208 if validated.exists() && validated.is_file() {
209 if let Ok(content) = read_file_to_string(&validated, 10_000_000) {
210 let _ = write!(output, "\n{file_path}\n```\n{content}\n```\n");
211 }
212 }
213 }
214 }
215 output
216}
217
218fn prepare_workspace(
219 initialize: &Initialize,
220 thread_id: &str,
221 response: &mut String,
222) -> Result<PathBuf> {
223 let workspace_path_str = expand_user(&initialize.any_workspace_path);
224 if workspace_path_str.is_empty() {
225 let playground = create_playground_dir(thread_id)?;
228 let _ = writeln!(
229 response,
230 "No workspace path provided; created a playground at {}",
231 playground.display()
232 );
233 return Ok(playground);
234 }
235
236 let workspace_path = PathBuf::from(&workspace_path_str);
237 let mut folder_to_start = workspace_path.clone();
238
239 if workspace_path.exists() {
240 if workspace_path.is_file() {
241 folder_to_start = workspace_path.parent().unwrap_or(&workspace_path).to_path_buf();
242 let _ =
243 writeln!(response, "Using parent directory of file: {}", folder_to_start.display());
244 } else if workspace_path.is_dir() {
245 let _ = writeln!(response, "Using workspace directory: {}", folder_to_start.display());
246 }
247 } else if workspace_path.is_absolute() {
248 ensure_directory_exists(&workspace_path).map_err(|e| {
249 WinxError::WorkspacePathError(format!("Failed to create workspace: {e}"))
250 })?;
251 let _ = writeln!(response, "Created workspace directory: {}", workspace_path.display());
252 }
253
254 if folder_to_start.exists() {
258 if let Ok(canonical) = folder_to_start.canonicalize() {
259 folder_to_start = canonical;
260 }
261 }
262
263 Ok(folder_to_start)
264}
265
266fn initialize_thread_id(initialize: &Initialize) -> String {
267 let thread_id = normalize_thread_id(&initialize.thread_id);
268 if thread_id.is_empty() {
269 generate_thread_id()
270 } else {
271 thread_id
272 }
273}
274
275fn validate_thread_id(initialize: &Initialize) -> Result<()> {
276 if initialize.init_type != InitializeType::FirstCall
277 && normalize_thread_id(&initialize.thread_id).is_empty()
278 {
279 return Err(WinxError::ThreadIdMismatch(
280 "Thread id should be provided if type != 'first_call', including when resetting."
281 .to_string(),
282 ));
283 }
284
285 Ok(())
286}
287
288fn load_guidelines(workspace: &Path) -> String {
289 let mut output = String::new();
290 let mut candidates = Vec::new();
291 if let Some(home) = home::home_dir() {
292 candidates.push(home.join(".winx").join("AGENTS.md"));
293 candidates.push(home.join(".winx").join("CLAUDE.md"));
294 candidates.push(home.join(".wcgw").join("AGENTS.md"));
295 candidates.push(home.join(".wcgw").join("CLAUDE.md"));
296 }
297 candidates.push(workspace.join("AGENTS.md"));
298 candidates.push(workspace.join("CLAUDE.md"));
299
300 for path in candidates {
301 if path.is_file() {
302 if let Ok(content) = fs::read_to_string(&path) {
303 let _ = writeln!(output, "\n## {}\n{}", path.display(), content);
304 }
305 }
306 }
307 output
308}
309
310pub async fn handle_tool_call(
311 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
312 initialize: Initialize,
313) -> Result<String> {
314 handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state_arc, initialize).await
315}
316
317pub async fn handle_tool_call_with_runtime(
318 runtime: &dyn ShellRuntime,
319 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
320 initialize: Initialize,
321) -> Result<String> {
322 Ok(handle_tool_call_with_runtime_detailed(runtime, bash_state_arc, initialize).await?.text)
323}
324
325#[instrument(level = "info", skip(runtime, bash_state_arc, initialize))]
326#[allow(clippy::too_many_lines)]
327pub(crate) async fn handle_tool_call_with_runtime_detailed(
328 runtime: &dyn ShellRuntime,
329 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
330 initialize: Initialize,
331) -> Result<InitializeOutcome> {
332 let mut response = String::new();
333 let mut context_bytes = 0;
334 let mut guidelines_bytes = 0;
335 let initial_files_count = initialize.initial_files_to_read.len();
336
337 info!("Initialize called for workspace: {}", initialize.any_workspace_path);
338
339 validate_thread_id(&initialize)?;
340 let thread_id = initialize_thread_id(&initialize);
341 let mut bash_state_guard = bash_state_arc.lock().await;
342 if initialize.init_type != InitializeType::FirstCall && bash_state_guard.is_none() {
343 return Err(WinxError::BashStateNotInitialized);
344 }
345 let local_attach = initialize.init_type == InitializeType::FirstCall
346 && bash_state_guard
347 .as_ref()
348 .is_some_and(|state| state.initialized && state.current_thread_id == thread_id);
349 let folder_to_start = if local_attach {
350 bash_state_guard
351 .as_ref()
352 .map_or_else(|| PathBuf::from("/tmp"), |state| state.workspace_root.clone())
353 } else {
354 prepare_workspace(&initialize, &thread_id, &mut response)?
355 };
356 let requested_mode = Modes::from(&initialize.mode_name);
357 let preserve_active_mode =
358 local_attach || initialize.init_type == InitializeType::UserAskedChangeWorkspace;
359 let (mode, bash_command_mode, file_edit_mode, write_if_empty_mode) = if preserve_active_mode {
360 let Some(state) = bash_state_guard.as_ref() else {
361 return Err(WinxError::BashStateNotInitialized);
362 };
363 (
364 state.mode,
365 state.bash_command_mode.clone(),
366 state.file_edit_mode.clone(),
367 state.write_if_empty_mode.clone(),
368 )
369 } else {
370 let (bash_command_mode, file_edit_mode, write_if_empty_mode) = mode_to_state(
371 requested_mode,
372 initialize.code_writer_config.as_ref(),
373 &folder_to_start,
374 )?;
375 (requested_mode, bash_command_mode, file_edit_mode, write_if_empty_mode)
376 };
377
378 let transition;
379 let mut compact_response = false;
380
381 match initialize.init_type {
382 InitializeType::FirstCall => {
383 let configured = if local_attach {
384 let Some(state) = bash_state_guard.as_mut() else {
385 return Err(WinxError::BashStateNotInitialized);
386 };
387 let mut configured =
391 runtime.configure_session(state, ShellSessionTransition::ModeChange).await?;
392 configured.attached_existing = true;
393 configured
394 } else {
395 let mut new_bash_state = BashState::new();
396 new_bash_state.current_thread_id.clone_from(&thread_id);
397 new_bash_state.mode = mode;
398 new_bash_state.bash_command_mode = bash_command_mode;
399 new_bash_state.file_edit_mode = file_edit_mode;
400 new_bash_state.write_if_empty_mode = write_if_empty_mode;
401 new_bash_state.initialized = true;
402
403 let resumed_context = if initialize.task_id_to_resume.is_empty() {
404 None
405 } else {
406 crate::tools::context_save::load_saved_context(&initialize.task_id_to_resume)?
407 };
408
409 if let Some((memory_data, snapshot)) = &resumed_context {
410 if let Some(snapshot) = snapshot {
411 new_bash_state.apply_snapshot(snapshot);
412 new_bash_state.current_thread_id.clone_from(&thread_id);
413 }
414 let _ = writeln!(
415 response,
416 "\n# Resumed task {}\nFollowing is the retrieved task context:\n{}",
417 initialize.task_id_to_resume, memory_data
418 );
419 }
420
421 if resumed_context.as_ref().and_then(|(_, snapshot)| snapshot.as_ref()).is_none() {
425 let resumed_root = resumed_context
426 .as_ref()
427 .and_then(|(memory, _)| {
428 crate::tools::context_save::extract_project_root(memory)
429 })
430 .filter(|root| root.exists());
431 let target = resumed_root.as_deref().unwrap_or(folder_to_start.as_path());
432 if target.exists() {
433 new_bash_state.update_cwd(target)?;
434 new_bash_state.update_workspace_root(target)?;
435 }
436 }
437 let configured = runtime
438 .configure_session(&mut new_bash_state, ShellSessionTransition::FirstCall)
439 .await?;
440 *bash_state_guard = Some(new_bash_state);
441 configured
442 };
443
444 transition = if configured.attached_existing {
445 InitializeTransition::AttachedExisting
446 } else {
447 InitializeTransition::Created
448 };
449 compact_response = configured.attached_existing
450 && initialize.initial_files_to_read.is_empty()
451 && initialize.task_id_to_resume.is_empty();
452
453 if compact_response {
454 response.clear();
455 let state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
456 let _ = writeln!(
457 response,
458 "Attached to the existing durable Winx session; PTY, cwd, mode, and prior \
459 context were preserved.\nUse thread_id={thread_id} for all winx tool calls.\n\
460 cwd={}\nmode={}",
461 state.cwd.display(),
462 state.mode
463 );
464 if let Some(attach_hint) = configured.attach_hint.as_deref() {
465 let _ = writeln!(response, "Attach terminal: {attach_hint}");
466 }
467 response.push_str(
468 "Context and instructions are unchanged. Continue with the existing thread; \
469 use an explicit mode/workspace change or reset only when intended.\n",
470 );
471 } else {
472 if configured.attached_existing {
473 response.push_str(
474 "\nAttached to the existing durable session for this principal/workspace; \
475 the guardian-owned PTY and cwd were preserved. Use `reset_shell` or an \
476 explicit workspace/mode change when replacement is intended.\n",
477 );
478 }
479
480 let _ = write!(
481 response,
482 "\n# Environment\nSystem: {}\nMachine: {}\nInitialized in directory: {}\n",
483 std::env::consts::OS,
484 std::env::consts::ARCH,
485 bash_state_guard
486 .as_ref()
487 .map_or(folder_to_start.as_path(), |state| state.cwd.as_path())
488 .display()
489 );
490
491 if command_exists("rg") {
492 let _ = writeln!(
493 response,
494 "\n# Available commands\nUse ripgrep `rg` instead of `grep`/`find -name` — \
495 it's much faster and respects .gitignore."
496 );
497 }
498
499 let _ = writeln!(response, "\nUse thread_id={thread_id} for all winx tool calls.");
500 if let Some(attach_hint) = configured.attach_hint.as_deref() {
501 let _ = writeln!(response, "\nAttach terminal: {attach_hint}");
502 }
503
504 let active_config = bash_state_guard.as_ref().and_then(active_code_writer_config);
507 let active_mode = bash_state_guard.as_ref().map_or(mode, |state| state.mode);
508 let _ = writeln!(
509 response,
510 "\n{}",
511 crate::utils::mode_prompts::mode_prompt(active_mode, active_config.as_ref())
512 );
513
514 let active_workspace = bash_state_guard
515 .as_ref()
516 .map_or(folder_to_start.as_path(), |state| state.workspace_root.as_path());
517
518 let guidelines = load_guidelines(active_workspace);
519 guidelines_bytes = guidelines.len();
520 if !guidelines.is_empty() {
521 let _ = writeln!(response, "\n# Agent guidelines\n{guidelines}");
522 }
523
524 if let Ok((repo_context, _)) =
525 crate::utils::repo::get_repo_context(active_workspace)
526 {
527 context_bytes = repo_context.len();
528 let _ = writeln!(response, "\n# Workspace structure\n{repo_context}");
529 }
530
531 if !initialize.initial_files_to_read.is_empty() {
532 let content = read_initial_files_simple(
533 &initialize.initial_files_to_read,
534 active_workspace,
535 );
536 if !content.is_empty() {
537 let _ = writeln!(response, "\n# Requested files\n{content}");
538 }
539 }
540 }
541 }
542 InitializeType::UserAskedModeChange => {
543 transition = InitializeTransition::ModeChanged;
544 if let Some(state) = bash_state_guard.as_mut() {
545 state.mode = mode;
546 state.bash_command_mode = bash_command_mode;
547 state.file_edit_mode = file_edit_mode;
548 state.write_if_empty_mode = write_if_empty_mode;
549 runtime.configure_session(state, ShellSessionTransition::ModeChange).await?;
550 let _ = writeln!(response, "Changed mode to: {mode:?}");
551 let active_config = active_code_writer_config(state);
552 let _ = writeln!(
553 response,
554 "\n{}",
555 crate::utils::mode_prompts::mode_prompt(mode, active_config.as_ref())
556 );
557 } else {
558 return Err(WinxError::BashStateNotInitialized);
559 }
560 }
561 InitializeType::ResetShell => {
562 transition = InitializeTransition::ShellReset;
563 if let Some(state) = bash_state_guard.as_mut() {
564 state.mode = mode;
565 state.bash_command_mode = bash_command_mode;
566 state.file_edit_mode = file_edit_mode;
567 state.write_if_empty_mode = write_if_empty_mode;
568 runtime.configure_session(state, ShellSessionTransition::Reset).await?;
569 response.push_str("Reset shell (new PTY created)\n");
570 } else {
571 return Err(WinxError::BashStateNotInitialized);
572 }
573 }
574 InitializeType::UserAskedChangeWorkspace => {
575 transition = InitializeTransition::WorkspaceChanged;
576 if let Some(state) = bash_state_guard.as_mut() {
577 if folder_to_start.exists() {
578 state.update_cwd(&folder_to_start)?;
579 state.update_workspace_root(&folder_to_start)?;
580 runtime
581 .configure_session(state, ShellSessionTransition::WorkspaceChange)
582 .await?;
583 let _ =
584 writeln!(response, "Changed workspace to: {}", folder_to_start.display());
585 } else {
586 let _ = writeln!(
587 response,
588 "Warning: Workspace path {} does not exist",
589 folder_to_start.display()
590 );
591 }
592 } else {
593 return Err(WinxError::BashStateNotInitialized);
594 }
595 }
596 }
597
598 let (code_writer_policy_strength, bypass) =
599 bash_state_guard.as_ref().map_or((None, Vec::new()), active_code_writer_policy);
600 if let Some(state) = bash_state_guard.as_ref() {
601 warn_code_writer_policy_once(state, &bypass);
602 }
603 if compact_response {
604 if let Some(strength) = code_writer_policy_strength {
605 let _ = writeln!(
606 response,
607 "code_writer_policy={strength} shell_spawners_present={}",
608 !bypass.is_empty()
609 );
610 }
611 } else {
612 append_code_writer_policy_warning(&mut response, &bypass);
613 crate::utils::orchestration::append_initialize_instructions(&mut response);
614 }
615
616 Ok(InitializeOutcome {
617 text: response,
618 transition,
619 context_bytes,
620 guidelines_bytes,
621 initial_files_count,
622 compact_response,
623 code_writer_policy_strength,
624 shell_spawners_present: !bypass.is_empty(),
625 })
626}