Skip to main content

winx_code_agent/utils/
agent_temp.rs

1//! Managed workspace-local temporary artifacts for model-driven helpers.
2//!
3//! `Initialize` advertises one stable directory per Winx session under
4//! `<workspace>/.winx/tmp/`. The directory is intentionally not created until a
5//! file tool actually needs it, so merely inspecting a repository does not
6//! dirty its working tree. File tools enforce a small path and storage budget;
7//! Bash remains governed by its own operator-selected command policy.
8
9use std::collections::BTreeSet;
10use std::ffi::OsStr;
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13use std::sync::Mutex;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use sha2::{Digest, Sha256};
17use tracing::debug;
18
19use crate::errors::{Result, WinxError};
20
21const TEMP_ROOT: &str = ".winx/tmp";
22const SESSION_PREFIX: &str = "session-";
23const SESSION_HASH_BYTES: usize = 8;
24
25/// Session directories with no filesystem activity for this long are pruned
26/// when another session initializes in the same workspace.
27pub const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
28/// Workspace-wide budget shared by all managed temporary sessions.
29pub const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
30/// One active conversation cannot consume the whole workspace-wide budget.
31pub const MAX_SESSION_BYTES: u64 = 64 * 1024 * 1024;
32/// One helper should remain small enough to inspect and remove cheaply.
33pub const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
34/// Hundreds of one-shot carriers are almost always an orchestration failure.
35pub const MAX_SESSION_FILES: usize = 128;
36/// Derived syntax maps are intentionally much smaller than canonical maps.
37pub const MAX_DERIVED_CODE_MAP_PAYLOAD_BYTES: usize = 12 * 1024;
38/// A session should reuse a small working set instead of minting new carriers.
39pub const MAX_DERIVED_CODE_MAP_UNIQUE_FILES: usize = 24;
40/// Bound aggregate helper-map churn while leaving canonical `CodeMap` unlimited.
41pub const MAX_DERIVED_CODE_MAP_CALLS: usize = 64;
42/// Depth beneath the session directory, including the target filename.
43pub const MAX_RELATIVE_COMPONENTS: usize = 8;
44/// Prevent payloads from being smuggled into a filename or directory component.
45pub const MAX_COMPONENT_BYTES: usize = 120;
46/// Bound the whole session-relative path independently of component count.
47pub const MAX_RELATIVE_PATH_BYTES: usize = 512;
48
49const MAX_SCAN_ENTRIES: usize = 50_000;
50const MAX_SCAN_DEPTH: usize = 16;
51static TEMP_IO_LOCK: Mutex<()> = Mutex::new(());
52
53/// Policy information returned by `Initialize` and reused by file-tool errors.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct AgentTempInfo {
56    pub directory: PathBuf,
57    pub ttl_seconds: u64,
58    pub max_total_bytes: u64,
59    pub max_session_bytes: u64,
60    pub max_file_bytes: u64,
61    pub max_session_files: usize,
62}
63
64/// In-memory budget for syntax navigation over non-canonical helpers.
65///
66/// It deliberately resets with the server process. Filesystem quotas remain the
67/// durable backstop across restarts, while this keeps the hot session path free
68/// from another persistence write after every read-only `CodeMap` call.
69#[derive(Clone, Debug, Default)]
70pub struct DerivedCodeMapUsage {
71    mapped_files: BTreeSet<PathBuf>,
72    calls: usize,
73}
74
75/// Counters returned with an accepted derived-helper map.
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub struct DerivedCodeMapPermit {
78    pub calls_used: usize,
79    pub calls_limit: usize,
80    pub unique_files_used: usize,
81    pub unique_files_limit: usize,
82}
83
84/// A resolved edit and its size change, used to validate a `MultiFileEdit`
85/// against the workspace-wide quota before any file is committed.
86#[derive(Clone, Copy, Debug)]
87pub struct TempEdit<'a> {
88    pub path: &'a Path,
89    pub previous_bytes: u64,
90    pub new_bytes: u64,
91    pub is_new: bool,
92}
93
94/// Compute the stable session directory without touching the filesystem.
95pub fn session_info(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
96    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
97    AgentTempInfo {
98        directory: workspace.join(TEMP_ROOT).join(session_name(thread_id)),
99        ttl_seconds: MAX_AGE.as_secs(),
100        max_total_bytes: MAX_TOTAL_BYTES,
101        max_session_bytes: MAX_SESSION_BYTES,
102        max_file_bytes: MAX_FILE_BYTES,
103        max_session_files: MAX_SESSION_FILES,
104    }
105}
106
107/// Return the session policy and best-effort prune expired sibling sessions.
108/// The active directory is not created here; `FileWriteOrEdit` creates it on
109/// demand through its normal parent-directory path.
110pub fn prepare_session(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
111    let info = session_info(workspace_root, thread_id);
112    let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
113    prune_sessions_unlocked(workspace_root, &info.directory, MAX_AGE);
114    info
115}
116
117/// Validate one `FileWriteOrEdit` target. Non-temporary project files keep their
118/// existing behavior; only legacy Winx root artifacts and `.winx/tmp` receive
119/// this additional governance.
120pub fn validate_edit_target(
121    workspace_root: &Path,
122    thread_id: &str,
123    requested_path: &Path,
124    resolved_path: &Path,
125    previous_bytes: Option<u64>,
126    new_bytes: u64,
127) -> Result<()> {
128    let info = session_info(workspace_root, thread_id);
129    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
130    // On macOS, tempfile and callers may spell the same workspace as `/var/...`
131    // while canonicalization produces `/private/var/...`. Accept either spelling
132    // at this boundary, then rebase policy checks onto the canonical workspace.
133    // The resolved path from the file-safety layer is normally canonical already;
134    // keeping the lexical fallback makes this helper safe and independently
135    // testable without allowing an alias to bypass temporary-artifact policy.
136    let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
137    let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
138
139    if let Some(relative) = resolved_relative.as_deref() {
140        reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
141    }
142    if requested_relative != resolved_relative {
143        if let Some(relative) = requested_relative.as_deref() {
144            reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
145        }
146    }
147
148    let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
149    let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
150    if !requested_temp && !resolved_temp {
151        return Ok(());
152    }
153
154    reject_symlinked_temp_root(&workspace, requested_path, &info)?;
155    let policy_path = resolved_relative
156        .as_deref()
157        .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
158    validate_session_relative_path(&policy_path, &info)?;
159    if new_bytes > MAX_FILE_BYTES {
160        return Err(policy_error(
161            &policy_path,
162            &info,
163            format!(
164                "helper content is {new_bytes} bytes; one temporary file is limited to \
165                 {MAX_FILE_BYTES} bytes"
166            ),
167        ));
168    }
169
170    Ok(())
171}
172
173/// Reject statically visible shell writes that bypass the managed session area.
174///
175/// Bash remains a general-purpose operator-controlled capability, so this is not
176/// presented as a filesystem sandbox. It covers the high-confidence cases an
177/// agent can correct immediately: literal output redirects, common destination
178/// arguments, and embedded language file-writer calls that name a Winx helper.
179pub fn validate_bash_command(
180    workspace_root: &Path,
181    cwd: &Path,
182    thread_id: &str,
183    command: &str,
184) -> Result<()> {
185    let info = session_info(workspace_root, thread_id);
186    let mut paths = crate::utils::bash_parser::extract_static_write_paths(command);
187    paths.extend(embedded_writer_paths(command));
188    paths.sort();
189    paths.dedup();
190
191    let mut managed_targets = Vec::new();
192    for path in paths {
193        if let Some(target) = validate_bash_write_target(workspace_root, cwd, &path, &info)? {
194            managed_targets.push(target);
195        }
196    }
197    validate_known_bash_targets_quota(workspace_root, &info, &managed_targets)?;
198    Ok(())
199}
200
201fn validate_bash_write_target(
202    workspace_root: &Path,
203    cwd: &Path,
204    path: &str,
205    info: &AgentTempInfo,
206) -> Result<Option<PathBuf>> {
207    let managed_env_path = expand_temp_env_path(path, info);
208    let expanded: String = managed_env_path.as_ref().map_or_else(
209        || crate::utils::path::expand_user(path),
210        |path| path.to_string_lossy().into_owned(),
211    );
212    let requested = if Path::new(&expanded).is_absolute() {
213        PathBuf::from(&expanded)
214    } else {
215        cwd.join(&expanded)
216    };
217    let resolved = if managed_env_path.is_some() {
218        requested.clone()
219    } else {
220        crate::utils::path::resolve_in_workspace(path, cwd, workspace_root)
221            .unwrap_or_else(|_| requested.clone())
222    };
223    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
224
225    let requested_relative = workspace_relative(&requested, workspace_root, &workspace);
226    let resolved_relative = workspace_relative(&resolved, workspace_root, &workspace);
227    let mut managed_target = None;
228    for relative in
229        [requested_relative.as_deref(), resolved_relative.as_deref()].into_iter().flatten()
230    {
231        let Some(Component::Normal(first)) = relative.components().next() else { continue };
232        let first = first.to_string_lossy();
233        if first == ".winx_tmp" || first.starts_with(".winx-") {
234            return Err(policy_error(
235                &requested,
236                info,
237                format!(
238                    "shell writes to Winx helper artifacts at the workspace root are not \
239                     allowed; use WINX_TEMP_DIR={} instead",
240                    info.directory.display()
241                ),
242            ));
243        }
244        if is_temp_relative(relative) {
245            reject_symlinked_temp_root(&workspace, &requested, info)?;
246            let target = workspace.join(relative);
247            if target != info.directory {
248                validate_session_relative_path(&target, info)?;
249                managed_target = Some(target);
250            }
251        }
252    }
253    Ok(managed_target)
254}
255
256/// Expand only Winx's server-owned temporary path variable. Other shell
257/// expansions remain dynamic and are intentionally left to the shell.
258fn expand_temp_env_path(path: &str, info: &AgentTempInfo) -> Option<PathBuf> {
259    let path = path.trim();
260    let path = if path.len() >= 2
261        && matches!(
262            (path.as_bytes().first(), path.as_bytes().last()),
263            (Some(b'\''), Some(b'\'')) | (Some(b'"'), Some(b'"'))
264        ) {
265        &path[1..path.len() - 1]
266    } else {
267        path
268    };
269    let suffix =
270        path.strip_prefix("$WINX_TEMP_DIR").or_else(|| path.strip_prefix("${WINX_TEMP_DIR}"))?;
271    if !suffix.is_empty() && !suffix.starts_with('/') {
272        return None;
273    }
274    let suffix = suffix.trim_start_matches('/');
275    Some(if suffix.is_empty() { info.directory.clone() } else { info.directory.join(suffix) })
276}
277
278fn embedded_writer_paths(command: &str) -> Vec<String> {
279    let lower = command.to_ascii_lowercase();
280    let explicit_writer = [
281        ".write_text(",
282        ".write_bytes(",
283        "writefilesync(",
284        "appendfilesync(",
285        "writefile(",
286        "appendfile(",
287        "bun.write(",
288        "deno.write",
289        "file.write(",
290        "file.binwrite(",
291    ]
292    .iter()
293    .any(|marker| lower.contains(marker));
294    let writable_open = lower.contains("open(")
295        && [", 'w", ", \"w", ", 'a", ", \"a", ", 'x", ", \"x", ", 'r+", ", \"r+"]
296            .iter()
297            .any(|mode| lower.contains(mode));
298    if !explicit_writer && !writable_open {
299        return Vec::new();
300    }
301
302    quoted_literals(command)
303        .into_iter()
304        .filter(|literal| literal.contains(".winx") || literal.contains("$WINX_TEMP_DIR"))
305        .collect()
306}
307
308fn quoted_literals(value: &str) -> Vec<String> {
309    let mut literals = Vec::new();
310    let mut chars = value.chars();
311    while let Some(character) = chars.next() {
312        if character != '\'' && character != '"' {
313            continue;
314        }
315        let quote = character;
316        let mut literal = String::new();
317        let mut escaped = false;
318        for character in chars.by_ref() {
319            if escaped {
320                literal.push(character);
321                escaped = false;
322            } else if character == '\\' && quote == '"' {
323                escaped = true;
324            } else if character == quote {
325                break;
326            } else {
327                literal.push(character);
328            }
329        }
330        if !literal.is_empty() {
331            literals.push(literal);
332        }
333    }
334    literals
335}
336
337fn workspace_relative(
338    path: &Path,
339    workspace_root: &Path,
340    canonical_workspace: &Path,
341) -> Option<PathBuf> {
342    path.strip_prefix(canonical_workspace)
343        .or_else(|_| path.strip_prefix(workspace_root))
344        .ok()
345        .map(Path::to_path_buf)
346}
347
348/// Enforce the session boundary for an explicit `CodeMap` path.
349///
350/// Returns `true` only for a helper in the active session. A request spelling a
351/// temporary path that resolves elsewhere (for example through a symlink) is
352/// rejected instead of being misclassified as canonical source.
353pub fn validate_code_map_target(
354    workspace_root: &Path,
355    thread_id: &str,
356    requested_path: &Path,
357    resolved_path: &Path,
358) -> Result<bool> {
359    let info = session_info(workspace_root, thread_id);
360    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
361    let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
362    let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
363    let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
364    let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
365    if !requested_temp && !resolved_temp {
366        return Ok(false);
367    }
368
369    reject_symlinked_temp_root(&workspace, requested_path, &info)?;
370    if requested_temp != resolved_temp {
371        return Err(policy_error(
372            requested_path,
373            &info,
374            "a temporary helper must not resolve through a symlink outside its managed session"
375                .to_string(),
376        ));
377    }
378
379    let requested_policy = requested_relative
380        .as_deref()
381        .map_or_else(|| requested_path.to_path_buf(), |relative| workspace.join(relative));
382    let resolved_policy = resolved_relative
383        .as_deref()
384        .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
385    if requested_policy != resolved_policy {
386        return Err(policy_error(
387            requested_path,
388            &info,
389            "temporary helper paths must resolve directly without aliases, parent traversal, or symlinks"
390                .to_string(),
391        ));
392    }
393    if requested_policy != info.directory {
394        validate_session_relative_path(&requested_policy, &info)?;
395    }
396    Ok(true)
397}
398
399/// Reserve one syntax-navigation call over an active non-canonical helper.
400pub fn reserve_derived_code_map(
401    usage: &mut DerivedCodeMapUsage,
402    path: &Path,
403    info: &AgentTempInfo,
404) -> Result<DerivedCodeMapPermit> {
405    let is_new = !usage.mapped_files.contains(path);
406    let unique_files = usage.mapped_files.len();
407    if usage.calls >= MAX_DERIVED_CODE_MAP_CALLS
408        || (is_new && unique_files >= MAX_DERIVED_CODE_MAP_UNIQUE_FILES)
409    {
410        return Err(WinxError::DerivedCodeMapBudget {
411            path: path.to_path_buf(),
412            temporary_artifact_dir: info.directory.clone(),
413            calls_used: usage.calls,
414            calls_limit: MAX_DERIVED_CODE_MAP_CALLS,
415            unique_files_used: unique_files,
416            unique_files_limit: MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
417            message: format!(
418                "this session already used {} derived-helper map calls across {} unique files; \
419                 reuse prior results and inspect canonical source with CodeMap/ReadFiles or rg \
420                 instead of creating another carrier",
421                usage.calls, unique_files
422            ),
423        });
424    }
425
426    usage.calls += 1;
427    usage.mapped_files.insert(path.to_path_buf());
428    Ok(DerivedCodeMapPermit {
429        calls_used: usage.calls,
430        calls_limit: MAX_DERIVED_CODE_MAP_CALLS,
431        unique_files_used: usage.mapped_files.len(),
432        unique_files_limit: MAX_DERIVED_CODE_MAP_UNIQUE_FILES,
433    })
434}
435
436fn validate_known_bash_targets_quota(
437    workspace_root: &Path,
438    info: &AgentTempInfo,
439    targets: &[PathBuf],
440) -> Result<()> {
441    if targets.is_empty() {
442        return Ok(());
443    }
444    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
445    let current =
446        temp_tree_usage(&workspace.join(TEMP_ROOT), &info.directory).ok_or_else(|| {
447            policy_error(
448                &targets[0],
449                info,
450                "temporary storage could not be measured safely; remove malformed or excessive \
451             artifacts before retrying"
452                    .to_string(),
453            )
454        })?;
455    let new_files = targets.iter().filter(|path| !path.exists()).collect::<BTreeSet<_>>().len();
456    let projected =
457        TempTreeUsage { session_files: current.session_files.saturating_add(new_files), ..current };
458    validate_projected_usage(&targets[0], info, current, projected)
459}
460
461/// Validate the aggregate size of a `MultiFileEdit` after every target has been
462/// planned and duplicate paths have been rejected.
463pub fn validate_batch_quota(
464    workspace_root: &Path,
465    thread_id: &str,
466    edits: &[TempEdit<'_>],
467) -> Result<()> {
468    let info = session_info(workspace_root, thread_id);
469    let managed: Vec<_> =
470        edits.iter().filter(|edit| edit.path.starts_with(&info.directory)).collect();
471    if managed.is_empty() {
472        return Ok(());
473    }
474    let representative = managed[0].path;
475
476    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
477    let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
478    let current =
479        temp_tree_usage(&workspace.join(TEMP_ROOT), &info.directory).ok_or_else(|| {
480            policy_error(
481                representative,
482                &info,
483                "temporary storage could not be measured safely; remove malformed or excessive \
484             artifacts before retrying"
485                    .to_string(),
486            )
487        })?;
488    let mut projected_total_bytes = current.total_bytes;
489    let mut projected_session_bytes = current.session_bytes;
490    let mut projected_session_files = current.session_files;
491    for edit in managed {
492        projected_total_bytes = projected_total_bytes
493            .saturating_sub(edit.previous_bytes)
494            .saturating_add(edit.new_bytes);
495        projected_session_bytes = projected_session_bytes
496            .saturating_sub(edit.previous_bytes)
497            .saturating_add(edit.new_bytes);
498        if edit.is_new {
499            projected_session_files = projected_session_files.saturating_add(1);
500        }
501    }
502    validate_projected_usage(
503        representative,
504        &info,
505        current,
506        TempTreeUsage {
507            total_bytes: projected_total_bytes,
508            session_bytes: projected_session_bytes,
509            session_files: projected_session_files,
510        },
511    )
512}
513
514fn session_name(thread_id: &str) -> String {
515    use std::fmt::Write as _;
516
517    let digest = Sha256::digest(thread_id.as_bytes());
518    let mut suffix = String::with_capacity(SESSION_HASH_BYTES * 2);
519    for byte in &digest[..SESSION_HASH_BYTES] {
520        let _ = write!(suffix, "{byte:02x}");
521    }
522    format!("{SESSION_PREFIX}{suffix}")
523}
524
525fn reject_legacy_root_artifact(
526    relative: &Path,
527    is_new: bool,
528    requested_path: &Path,
529    info: &AgentTempInfo,
530) -> Result<()> {
531    let Some(Component::Normal(first)) = relative.components().next() else { return Ok(()) };
532    let first = first.to_string_lossy();
533    if first == ".winx_tmp" || (is_new && first.starts_with(".winx-")) {
534        return Err(policy_error(
535            requested_path,
536            info,
537            format!(
538                "ad hoc Winx artifacts at the workspace root are not allowed; use {} with a \
539                 short descriptive filename",
540                info.directory.display()
541            ),
542        ));
543    }
544    Ok(())
545}
546
547fn is_temp_relative(relative: &Path) -> bool {
548    let mut components = relative.components();
549    matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new(".winx"))
550        && matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new("tmp"))
551}
552
553fn reject_symlinked_temp_root(workspace: &Path, path: &Path, info: &AgentTempInfo) -> Result<()> {
554    let temp_root = workspace.join(TEMP_ROOT);
555    if fs::symlink_metadata(&temp_root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
556        return Err(policy_error(
557            path,
558            info,
559            format!(
560                "{} is a symlink; the managed temporary root must be a real directory inside \
561                 the workspace",
562                temp_root.display()
563            ),
564        ));
565    }
566    Ok(())
567}
568
569fn validate_session_relative_path(path: &Path, info: &AgentTempInfo) -> Result<()> {
570    let relative = path.strip_prefix(&info.directory).map_err(|_| {
571        policy_error(
572            path,
573            info,
574            format!(
575                "temporary helpers are session-scoped; use the exact temporary_artifact_dir {} \
576                 returned by Initialize",
577                info.directory.display()
578            ),
579        )
580    })?;
581    let relative_bytes = relative.as_os_str().as_encoded_bytes().len();
582    if relative_bytes == 0 {
583        return Err(policy_error(path, info, "a helper filename is required".to_string()));
584    }
585    if relative_bytes > MAX_RELATIVE_PATH_BYTES {
586        return Err(policy_error(
587            path,
588            info,
589            format!(
590                "the session-relative path is {relative_bytes} bytes; temporary paths are limited \
591                 to {MAX_RELATIVE_PATH_BYTES} bytes"
592            ),
593        ));
594    }
595
596    let mut count = 0usize;
597    for component in relative.components() {
598        let Component::Normal(name) = component else {
599            return Err(policy_error(
600                path,
601                info,
602                "temporary paths may contain only normal filename components".to_string(),
603            ));
604        };
605        count += 1;
606        let bytes = name.as_encoded_bytes().len();
607        if bytes > MAX_COMPONENT_BYTES {
608            return Err(policy_error(
609                path,
610                info,
611                format!(
612                    "a temporary path component is {bytes} bytes; components are limited to \
613                     {MAX_COMPONENT_BYTES} bytes so content is never encoded in filesystem names"
614                ),
615            ));
616        }
617    }
618    if count > MAX_RELATIVE_COMPONENTS {
619        return Err(policy_error(
620            path,
621            info,
622            format!(
623                "temporary path depth is {count}; at most {MAX_RELATIVE_COMPONENTS} components \
624                 are allowed beneath the session directory"
625            ),
626        ));
627    }
628    Ok(())
629}
630
631fn validate_projected_usage(
632    path: &Path,
633    info: &AgentTempInfo,
634    current: TempTreeUsage,
635    projected: TempTreeUsage,
636) -> Result<()> {
637    let violation =
638        if projected.total_bytes > MAX_TOTAL_BYTES && projected.total_bytes > current.total_bytes {
639            Some(format!(
640                "this write would grow managed temporary storage to {} bytes; the workspace-wide \
641             limit is {MAX_TOTAL_BYTES} bytes",
642                projected.total_bytes
643            ))
644        } else if projected.session_bytes > MAX_SESSION_BYTES
645            && projected.session_bytes > current.session_bytes
646        {
647            Some(format!(
648                "this write would grow the active session's temporary storage to {} bytes; the \
649             per-session limit is {MAX_SESSION_BYTES} bytes",
650                projected.session_bytes
651            ))
652        } else if projected.session_files > MAX_SESSION_FILES
653            && projected.session_files > current.session_files
654        {
655            Some(format!(
656            "this write would grow the active session to {} helper files; the per-session limit \
657             is {MAX_SESSION_FILES}. Reuse or overwrite a stable helper and remove obsolete \
658             carriers instead of creating another file",
659            projected.session_files
660        ))
661        } else {
662            None
663        };
664    match violation {
665        Some(message) => Err(policy_error(
666            path,
667            info,
668            format!(
669                "{message}. Shrinking existing helpers remains allowed so the session can recover"
670            ),
671        )),
672        None => Ok(()),
673    }
674}
675
676fn policy_error(path: &Path, info: &AgentTempInfo, message: String) -> WinxError {
677    WinxError::TemporaryArtifactPolicy {
678        path: path.to_path_buf(),
679        temporary_artifact_dir: info.directory.clone(),
680        message,
681    }
682}
683
684#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
685struct TempTreeUsage {
686    total_bytes: u64,
687    session_bytes: u64,
688    session_files: usize,
689}
690
691fn temp_tree_usage(root: &Path, active_session: &Path) -> Option<TempTreeUsage> {
692    let metadata = match fs::symlink_metadata(root) {
693        Ok(metadata) => metadata,
694        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
695            return Some(TempTreeUsage::default())
696        }
697        Err(_) => return None,
698    };
699    if metadata.file_type().is_symlink() {
700        return None;
701    }
702
703    let mut usage = TempTreeUsage::default();
704    let mut seen = 0usize;
705    let mut stack = vec![(root.to_path_buf(), 0usize)];
706    while let Some((path, depth)) = stack.pop() {
707        if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
708            return None;
709        }
710        seen += 1;
711        let metadata = fs::symlink_metadata(&path).ok()?;
712        if metadata.file_type().is_symlink() {
713            continue;
714        }
715        if metadata.is_file() {
716            usage.total_bytes = usage.total_bytes.saturating_add(metadata.len());
717            if path.starts_with(active_session) {
718                usage.session_bytes = usage.session_bytes.saturating_add(metadata.len());
719                usage.session_files = usage.session_files.saturating_add(1);
720            }
721            continue;
722        }
723        if metadata.is_dir() {
724            for entry in fs::read_dir(&path).ok()? {
725                stack.push((entry.ok()?.path(), depth + 1));
726            }
727        }
728    }
729    Some(usage)
730}
731
732fn prune_sessions_unlocked(workspace_root: &Path, active: &Path, max_age: Duration) {
733    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
734    let root = workspace.join(TEMP_ROOT);
735    if fs::symlink_metadata(&root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
736        return;
737    }
738    let Ok(entries) = fs::read_dir(&root) else { return };
739    let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
740    for entry in entries.flatten() {
741        let path = entry.path();
742        if path == active || !is_managed_session_entry(&entry) || !session_is_stale(&path, cutoff) {
743            continue;
744        }
745        if let Err(error) = fs::remove_dir_all(&path) {
746            debug!(path = %path.display(), %error, "failed to prune expired agent temp session");
747        }
748    }
749}
750
751fn is_managed_session_entry(entry: &fs::DirEntry) -> bool {
752    if !entry.file_type().is_ok_and(|kind| kind.is_dir() && !kind.is_symlink()) {
753        return false;
754    }
755    let name = entry.file_name();
756    let name = name.to_string_lossy();
757    let Some(hash) = name.strip_prefix(SESSION_PREFIX) else { return false };
758    hash.len() == SESSION_HASH_BYTES * 2 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
759}
760
761/// Conservative bounded scan: any read/metadata error or an excessive tree
762/// retains the session rather than deleting data whose freshness is uncertain.
763fn session_is_stale(root: &Path, cutoff: SystemTime) -> bool {
764    let mut newest = UNIX_EPOCH;
765    let mut seen = 0usize;
766    let mut stack = vec![(root.to_path_buf(), 0usize)];
767    while let Some((path, depth)) = stack.pop() {
768        if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
769            return false;
770        }
771        seen += 1;
772        let Ok(metadata) = fs::symlink_metadata(&path) else { return false };
773        let Ok(modified) = metadata.modified() else { return false };
774        newest = newest.max(modified);
775        if metadata.is_dir() && !metadata.file_type().is_symlink() {
776            let Ok(entries) = fs::read_dir(&path) else { return false };
777            for entry in entries {
778                let Ok(entry) = entry else { return false };
779                stack.push((entry.path(), depth + 1));
780            }
781        }
782    }
783    newest < cutoff
784}
785
786#[cfg(test)]
787mod tests {
788    #![allow(clippy::expect_used, clippy::unwrap_used)]
789
790    use super::*;
791    use std::thread::sleep;
792    use tempfile::TempDir;
793
794    #[test]
795    fn session_path_is_stable_short_and_workspace_local() {
796        let workspace = TempDir::new().unwrap();
797        let first = session_info(workspace.path(), "chat/project:one");
798        let repeated = session_info(workspace.path(), "chat/project:one");
799        let other = session_info(workspace.path(), "chat/project:two");
800        assert_eq!(first, repeated);
801        assert_ne!(first.directory, other.directory);
802        assert!(first.directory.starts_with(workspace.path().canonicalize().unwrap()));
803        let name = first.directory.file_name().unwrap().to_string_lossy();
804        assert_eq!(name.len(), SESSION_PREFIX.len() + SESSION_HASH_BYTES * 2);
805    }
806
807    #[test]
808    fn accepts_short_helpers_only_in_the_active_session() {
809        let workspace = TempDir::new().unwrap();
810        let info = session_info(workspace.path(), "active");
811        let valid = info.directory.join("review/adapter.py");
812        validate_edit_target(workspace.path(), "active", &valid, &valid, None, 128).unwrap();
813
814        let other = session_info(workspace.path(), "other").directory.join("adapter.py");
815        let error = validate_edit_target(workspace.path(), "active", &other, &other, None, 128)
816            .expect_err("cross-session temp write must fail");
817        assert!(error.to_string().contains("session-scoped"), "{error}");
818    }
819
820    #[test]
821    fn bash_writes_accept_only_the_active_session_temp_path() {
822        let workspace = TempDir::new().unwrap();
823        let info = session_info(workspace.path(), "active");
824        let valid = format!("printf x > '{}'", info.directory.join("adapter.ts").display());
825        validate_bash_command(workspace.path(), workspace.path(), "active", &valid)
826            .expect("active session destination must be accepted");
827
828        for command in [
829            "printf x > .winx/tmp/direct.ts",
830            "printf x | tee .winx-review-carrier.js",
831            "python - <<'PY'\nfrom pathlib import Path\nPath('.winx/tmp/direct.py').write_text('x')\nPY",
832        ] {
833            let error = validate_bash_command(workspace.path(), workspace.path(), "active", command)
834                .expect_err("unmanaged shell helper must fail");
835            assert!(
836                error.to_string().to_ascii_lowercase().contains("temporary artifact policy"),
837                "{error}"
838            );
839        }
840    }
841
842    #[test]
843    fn bash_temp_preflight_does_not_block_reads_or_dynamic_active_path() {
844        let workspace = TempDir::new().unwrap();
845        for command in [
846            "rg needle .winx/tmp/direct.ts",
847            "cat .winx-review-carrier.js",
848            "mkdir -p \"$WINX_TEMP_DIR\" && printf x > \"$WINX_TEMP_DIR/helper.ts\"",
849        ] {
850            validate_bash_command(workspace.path(), workspace.path(), "active", command)
851                .expect("read or runtime-provided destination must remain available");
852        }
853    }
854
855    #[test]
856    fn rejects_legacy_root_artifacts_and_payload_names() {
857        let workspace = TempDir::new().unwrap();
858        let legacy = workspace.path().join(".winx_tmp/payload/file.txt");
859        let error = validate_edit_target(workspace.path(), "active", &legacy, &legacy, None, 10)
860            .expect_err("legacy root must fail");
861        assert!(error.to_string().contains("workspace root"), "{error}");
862
863        let info = session_info(workspace.path(), "active");
864        let encoded = info.directory.join(format!("{}.txt", "x".repeat(MAX_COMPONENT_BYTES + 1)));
865        let error = validate_edit_target(workspace.path(), "active", &encoded, &encoded, None, 10)
866            .expect_err("long component must fail");
867        assert!(error.to_string().contains("filesystem names"), "{error}");
868    }
869
870    #[test]
871    fn resolved_path_cannot_hide_a_root_artifact_behind_parent_components() {
872        let workspace = TempDir::new().unwrap();
873        let requested = workspace.path().join("nested/../.winx-review-carrier.py");
874        let resolved = workspace.path().join(".winx-review-carrier.py");
875        let error =
876            validate_edit_target(workspace.path(), "active", &requested, &resolved, None, 10)
877                .expect_err("resolved top-level artifact must fail");
878        assert!(error.to_string().contains("workspace root"), "{error}");
879    }
880
881    #[cfg(unix)]
882    #[test]
883    fn lexical_workspace_alias_cannot_bypass_temp_policy() {
884        use std::os::unix::fs::symlink;
885
886        let parent = TempDir::new().unwrap();
887        let real_workspace = parent.path().join("real-workspace");
888        let alias_workspace = parent.path().join("workspace-alias");
889        fs::create_dir(&real_workspace).unwrap();
890        symlink(&real_workspace, &alias_workspace).unwrap();
891
892        let legacy = alias_workspace.join(".winx_tmp/payload.txt");
893        let error = validate_edit_target(&alias_workspace, "active", &legacy, &legacy, None, 10)
894            .expect_err("a lexical workspace alias must not bypass root-artifact policy");
895        assert!(error.to_string().contains("workspace root"), "{error}");
896
897        let canonical_workspace = alias_workspace.canonicalize().unwrap();
898        let canonical_session = session_info(&alias_workspace, "active").directory;
899        let relative_session = canonical_session.strip_prefix(&canonical_workspace).unwrap();
900        let aliased_helper = alias_workspace.join(relative_session).join("adapter.py");
901        validate_edit_target(
902            &alias_workspace,
903            "active",
904            &aliased_helper,
905            &aliased_helper,
906            None,
907            10,
908        )
909        .expect("the active managed directory must work through the lexical alias");
910    }
911
912    #[test]
913    fn prune_removes_only_expired_managed_siblings() {
914        let workspace = TempDir::new().unwrap();
915        let active = session_info(workspace.path(), "active").directory;
916        let expired = session_info(workspace.path(), "expired").directory;
917        let unmanaged = workspace.path().join(TEMP_ROOT).join("manual-cache");
918        fs::create_dir_all(&active).unwrap();
919        fs::create_dir_all(&expired).unwrap();
920        fs::create_dir_all(&unmanaged).unwrap();
921        fs::write(expired.join("old.txt"), "old").unwrap();
922        sleep(Duration::from_millis(15));
923
924        let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
925        prune_sessions_unlocked(workspace.path(), &active, Duration::from_millis(1));
926        assert!(active.exists());
927        assert!(!expired.exists());
928        assert!(unmanaged.exists());
929    }
930
931    #[test]
932    fn quota_projection_accounts_for_replacements() {
933        let workspace = TempDir::new().unwrap();
934        let info = session_info(workspace.path(), "active");
935        let path = info.directory.join("large.bin");
936        let current = TempTreeUsage::default();
937        assert!(validate_projected_usage(
938            &path,
939            &info,
940            current,
941            TempTreeUsage { total_bytes: MAX_TOTAL_BYTES, ..current }
942        )
943        .is_ok());
944        assert!(validate_projected_usage(
945            &path,
946            &info,
947            current,
948            TempTreeUsage { total_bytes: MAX_TOTAL_BYTES + 1, ..current }
949        )
950        .is_err());
951
952        let oversized = TempTreeUsage {
953            total_bytes: MAX_TOTAL_BYTES + 10,
954            session_bytes: MAX_SESSION_BYTES + 10,
955            session_files: MAX_SESSION_FILES + 10,
956        };
957        let shrinking = TempTreeUsage {
958            total_bytes: oversized.total_bytes - 1,
959            session_bytes: oversized.session_bytes - 1,
960            session_files: oversized.session_files,
961        };
962        validate_projected_usage(&path, &info, oversized, shrinking)
963            .expect("an over-limit session must be allowed to shrink existing helpers");
964    }
965
966    #[test]
967    fn batch_quota_counts_every_planned_helper_before_committing() {
968        let workspace = TempDir::new().unwrap();
969        let info = session_info(workspace.path(), "active");
970        let paths = (0..9)
971            .map(|index| info.directory.join(format!("helper-{index}.bin")))
972            .collect::<Vec<_>>();
973        let edits = paths
974            .iter()
975            .map(|path| TempEdit {
976                path,
977                previous_bytes: 0,
978                new_bytes: MAX_FILE_BYTES,
979                is_new: true,
980            })
981            .collect::<Vec<_>>();
982
983        let error = validate_batch_quota(workspace.path(), "active", &edits)
984            .expect_err("aggregate batch must honor the workspace quota");
985        assert!(error.to_string().contains("workspace-wide limit"), "{error}");
986    }
987
988    #[test]
989    fn literal_winx_temp_destinations_honor_the_session_file_cap() {
990        let workspace = TempDir::new().unwrap();
991        let info = session_info(workspace.path(), "active");
992        fs::create_dir_all(&info.directory).unwrap();
993        for index in 0..MAX_SESSION_FILES {
994            fs::write(info.directory.join(format!("helper-{index}.txt")), "x").unwrap();
995        }
996
997        validate_bash_command(
998            workspace.path(),
999            workspace.path(),
1000            "active",
1001            "printf replacement > \"$WINX_TEMP_DIR/helper-0.txt\"",
1002        )
1003        .expect("overwriting a stable helper must remain possible at the cap");
1004        let error = validate_bash_command(
1005            workspace.path(),
1006            workspace.path(),
1007            "active",
1008            "printf new > \"$WINX_TEMP_DIR/one-more.txt\"",
1009        )
1010        .expect_err("a visible new shell helper must honor the cap");
1011        assert!(error.to_string().contains("helper files"), "{error}");
1012    }
1013
1014    #[test]
1015    fn code_map_accepts_only_the_active_direct_helper_path() {
1016        let workspace = TempDir::new().unwrap();
1017        let active = session_info(workspace.path(), "active");
1018        let helper = active.directory.join("adapter.py");
1019        assert!(validate_code_map_target(workspace.path(), "active", &helper, &helper).unwrap());
1020
1021        let foreign = session_info(workspace.path(), "other").directory.join("adapter.py");
1022        let error = validate_code_map_target(workspace.path(), "active", &foreign, &foreign)
1023            .expect_err("another session's helper must not be mapped");
1024        assert!(error.to_string().contains("session-scoped"), "{error}");
1025
1026        let canonical = workspace.path().join("src/lib.rs");
1027        assert!(
1028            !validate_code_map_target(workspace.path(), "active", &canonical, &canonical).unwrap()
1029        );
1030    }
1031
1032    #[test]
1033    fn derived_code_map_budget_rewards_reuse_and_caps_churn() {
1034        let workspace = TempDir::new().unwrap();
1035        let info = session_info(workspace.path(), "active");
1036        let mut usage = DerivedCodeMapUsage::default();
1037        let stable = info.directory.join("stable.py");
1038        let first = reserve_derived_code_map(&mut usage, &stable, &info).unwrap();
1039        let repeated = reserve_derived_code_map(&mut usage, &stable, &info).unwrap();
1040        assert_eq!(first.unique_files_used, 1);
1041        assert_eq!(repeated.unique_files_used, 1);
1042        assert_eq!(repeated.calls_used, 2);
1043
1044        for index in 1..MAX_DERIVED_CODE_MAP_UNIQUE_FILES {
1045            reserve_derived_code_map(
1046                &mut usage,
1047                &info.directory.join(format!("helper-{index}.py")),
1048                &info,
1049            )
1050            .unwrap();
1051        }
1052        let error =
1053            reserve_derived_code_map(&mut usage, &info.directory.join("one-too-many.py"), &info)
1054                .expect_err("a new carrier past the unique-file budget must fail");
1055        assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
1056
1057        let mut repeated_usage = DerivedCodeMapUsage::default();
1058        for _ in 0..MAX_DERIVED_CODE_MAP_CALLS {
1059            reserve_derived_code_map(&mut repeated_usage, &stable, &info).unwrap();
1060        }
1061        let error = reserve_derived_code_map(&mut repeated_usage, &stable, &info)
1062            .expect_err("even one stable helper has an aggregate call budget");
1063        assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
1064    }
1065}