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::ffi::OsStr;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12use std::sync::Mutex;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use sha2::{Digest, Sha256};
16use tracing::debug;
17
18use crate::errors::{Result, WinxError};
19
20const TEMP_ROOT: &str = ".winx/tmp";
21const SESSION_PREFIX: &str = "session-";
22const SESSION_HASH_BYTES: usize = 8;
23
24/// Session directories with no filesystem activity for this long are pruned
25/// when another session initializes in the same workspace.
26pub const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
27/// Workspace-wide budget shared by all managed temporary sessions.
28pub const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
29/// One helper should remain small enough to inspect and remove cheaply.
30pub const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
31/// Depth beneath the session directory, including the target filename.
32pub const MAX_RELATIVE_COMPONENTS: usize = 8;
33/// Prevent payloads from being smuggled into a filename or directory component.
34pub const MAX_COMPONENT_BYTES: usize = 120;
35/// Bound the whole session-relative path independently of component count.
36pub const MAX_RELATIVE_PATH_BYTES: usize = 512;
37
38const MAX_SCAN_ENTRIES: usize = 50_000;
39const MAX_SCAN_DEPTH: usize = 16;
40static TEMP_IO_LOCK: Mutex<()> = Mutex::new(());
41
42/// Policy information returned by `Initialize` and reused by file-tool errors.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct AgentTempInfo {
45    pub directory: PathBuf,
46    pub ttl_seconds: u64,
47    pub max_total_bytes: u64,
48    pub max_file_bytes: u64,
49}
50
51/// A resolved edit and its size change, used to validate a `MultiFileEdit`
52/// against the workspace-wide quota before any file is committed.
53#[derive(Clone, Copy, Debug)]
54pub struct TempEdit<'a> {
55    pub path: &'a Path,
56    pub previous_bytes: u64,
57    pub new_bytes: u64,
58}
59
60/// Compute the stable session directory without touching the filesystem.
61pub fn session_info(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
62    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
63    AgentTempInfo {
64        directory: workspace.join(TEMP_ROOT).join(session_name(thread_id)),
65        ttl_seconds: MAX_AGE.as_secs(),
66        max_total_bytes: MAX_TOTAL_BYTES,
67        max_file_bytes: MAX_FILE_BYTES,
68    }
69}
70
71/// Return the session policy and best-effort prune expired sibling sessions.
72/// The active directory is not created here; `FileWriteOrEdit` creates it on
73/// demand through its normal parent-directory path.
74pub fn prepare_session(workspace_root: &Path, thread_id: &str) -> AgentTempInfo {
75    let info = session_info(workspace_root, thread_id);
76    let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
77    prune_sessions_unlocked(workspace_root, &info.directory, MAX_AGE);
78    info
79}
80
81/// Validate one `FileWriteOrEdit` target. Non-temporary project files keep their
82/// existing behavior; only legacy Winx root artifacts and `.winx/tmp` receive
83/// this additional governance.
84pub fn validate_edit_target(
85    workspace_root: &Path,
86    thread_id: &str,
87    requested_path: &Path,
88    resolved_path: &Path,
89    previous_bytes: Option<u64>,
90    new_bytes: u64,
91) -> Result<()> {
92    let info = session_info(workspace_root, thread_id);
93    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
94    // On macOS, tempfile and callers may spell the same workspace as `/var/...`
95    // while canonicalization produces `/private/var/...`. Accept either spelling
96    // at this boundary, then rebase policy checks onto the canonical workspace.
97    // The resolved path from the file-safety layer is normally canonical already;
98    // keeping the lexical fallback makes this helper safe and independently
99    // testable without allowing an alias to bypass temporary-artifact policy.
100    let requested_relative = workspace_relative(requested_path, workspace_root, &workspace);
101    let resolved_relative = workspace_relative(resolved_path, workspace_root, &workspace);
102
103    if let Some(relative) = resolved_relative.as_deref() {
104        reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
105    }
106    if requested_relative != resolved_relative {
107        if let Some(relative) = requested_relative.as_deref() {
108            reject_legacy_root_artifact(relative, previous_bytes.is_none(), requested_path, &info)?;
109        }
110    }
111
112    let requested_temp = requested_relative.as_deref().is_some_and(is_temp_relative);
113    let resolved_temp = resolved_relative.as_deref().is_some_and(is_temp_relative);
114    if !requested_temp && !resolved_temp {
115        return Ok(());
116    }
117
118    reject_symlinked_temp_root(&workspace, requested_path, &info)?;
119    let policy_path = resolved_relative
120        .as_deref()
121        .map_or_else(|| resolved_path.to_path_buf(), |relative| workspace.join(relative));
122    validate_session_relative_path(&policy_path, &info)?;
123    if new_bytes > MAX_FILE_BYTES {
124        return Err(policy_error(
125            &policy_path,
126            &info,
127            format!(
128                "helper content is {new_bytes} bytes; one temporary file is limited to \
129                 {MAX_FILE_BYTES} bytes"
130            ),
131        ));
132    }
133
134    Ok(())
135}
136
137/// Reject statically visible shell writes that bypass the managed session area.
138///
139/// Bash remains a general-purpose operator-controlled capability, so this is not
140/// presented as a filesystem sandbox. It covers the high-confidence cases an
141/// agent can correct immediately: literal output redirects, common destination
142/// arguments, and embedded language file-writer calls that name a Winx helper.
143pub fn validate_bash_command(
144    workspace_root: &Path,
145    cwd: &Path,
146    thread_id: &str,
147    command: &str,
148) -> Result<()> {
149    let info = session_info(workspace_root, thread_id);
150    let mut paths = crate::utils::bash_parser::extract_static_write_paths(command);
151    paths.extend(embedded_writer_paths(command));
152    paths.sort();
153    paths.dedup();
154
155    for path in paths {
156        validate_bash_write_target(workspace_root, cwd, &path, &info)?;
157    }
158    Ok(())
159}
160
161fn validate_bash_write_target(
162    workspace_root: &Path,
163    cwd: &Path,
164    path: &str,
165    info: &AgentTempInfo,
166) -> Result<()> {
167    let expanded = crate::utils::path::expand_user(path);
168    let requested = if Path::new(&expanded).is_absolute() {
169        PathBuf::from(&expanded)
170    } else {
171        cwd.join(&expanded)
172    };
173    let resolved = crate::utils::path::resolve_in_workspace(path, cwd, workspace_root)
174        .unwrap_or_else(|_| requested.clone());
175    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
176
177    let requested_relative = workspace_relative(&requested, workspace_root, &workspace);
178    let resolved_relative = workspace_relative(&resolved, workspace_root, &workspace);
179    for relative in
180        [requested_relative.as_deref(), resolved_relative.as_deref()].into_iter().flatten()
181    {
182        let Some(Component::Normal(first)) = relative.components().next() else { continue };
183        let first = first.to_string_lossy();
184        if first == ".winx_tmp" || first.starts_with(".winx-") {
185            return Err(policy_error(
186                &requested,
187                info,
188                format!(
189                    "shell writes to Winx helper artifacts at the workspace root are not \
190                     allowed; use WINX_TEMP_DIR={} instead",
191                    info.directory.display()
192                ),
193            ));
194        }
195        if is_temp_relative(relative) {
196            reject_symlinked_temp_root(&workspace, &requested, info)?;
197            validate_session_relative_path(&workspace.join(relative), info)?;
198        }
199    }
200    Ok(())
201}
202
203fn embedded_writer_paths(command: &str) -> Vec<String> {
204    let lower = command.to_ascii_lowercase();
205    let explicit_writer = [
206        ".write_text(",
207        ".write_bytes(",
208        "writefilesync(",
209        "appendfilesync(",
210        "writefile(",
211        "appendfile(",
212        "bun.write(",
213        "deno.write",
214        "file.write(",
215        "file.binwrite(",
216    ]
217    .iter()
218    .any(|marker| lower.contains(marker));
219    let writable_open = lower.contains("open(")
220        && [", 'w", ", \"w", ", 'a", ", \"a", ", 'x", ", \"x", ", 'r+", ", \"r+"]
221            .iter()
222            .any(|mode| lower.contains(mode));
223    if !explicit_writer && !writable_open {
224        return Vec::new();
225    }
226
227    quoted_literals(command).into_iter().filter(|literal| literal.contains(".winx")).collect()
228}
229
230fn quoted_literals(value: &str) -> Vec<String> {
231    let mut literals = Vec::new();
232    let mut chars = value.chars();
233    while let Some(character) = chars.next() {
234        if character != '\'' && character != '"' {
235            continue;
236        }
237        let quote = character;
238        let mut literal = String::new();
239        let mut escaped = false;
240        for character in chars.by_ref() {
241            if escaped {
242                literal.push(character);
243                escaped = false;
244            } else if character == '\\' && quote == '"' {
245                escaped = true;
246            } else if character == quote {
247                break;
248            } else {
249                literal.push(character);
250            }
251        }
252        if !literal.is_empty() {
253            literals.push(literal);
254        }
255    }
256    literals
257}
258
259fn workspace_relative(
260    path: &Path,
261    workspace_root: &Path,
262    canonical_workspace: &Path,
263) -> Option<PathBuf> {
264    path.strip_prefix(canonical_workspace)
265        .or_else(|_| path.strip_prefix(workspace_root))
266        .ok()
267        .map(Path::to_path_buf)
268}
269
270/// Validate the aggregate size of a `MultiFileEdit` after every target has been
271/// planned and duplicate paths have been rejected.
272pub fn validate_batch_quota(
273    workspace_root: &Path,
274    thread_id: &str,
275    edits: &[TempEdit<'_>],
276) -> Result<()> {
277    let info = session_info(workspace_root, thread_id);
278    let managed: Vec<_> =
279        edits.iter().filter(|edit| edit.path.starts_with(&info.directory)).collect();
280    if managed.is_empty() {
281        return Ok(());
282    }
283    let representative = managed[0].path;
284
285    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
286    let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
287    let mut projected = temp_tree_size(&workspace.join(TEMP_ROOT)).ok_or_else(|| {
288        policy_error(
289            representative,
290            &info,
291            "temporary storage could not be measured safely; remove malformed or excessive \
292             artifacts before retrying"
293                .to_string(),
294        )
295    })?;
296    for edit in managed {
297        projected = projected.saturating_sub(edit.previous_bytes).saturating_add(edit.new_bytes);
298    }
299    validate_projected_size(representative, &info, projected)
300}
301
302fn session_name(thread_id: &str) -> String {
303    use std::fmt::Write as _;
304
305    let digest = Sha256::digest(thread_id.as_bytes());
306    let mut suffix = String::with_capacity(SESSION_HASH_BYTES * 2);
307    for byte in &digest[..SESSION_HASH_BYTES] {
308        let _ = write!(suffix, "{byte:02x}");
309    }
310    format!("{SESSION_PREFIX}{suffix}")
311}
312
313fn reject_legacy_root_artifact(
314    relative: &Path,
315    is_new: bool,
316    requested_path: &Path,
317    info: &AgentTempInfo,
318) -> Result<()> {
319    let Some(Component::Normal(first)) = relative.components().next() else { return Ok(()) };
320    let first = first.to_string_lossy();
321    if first == ".winx_tmp" || (is_new && first.starts_with(".winx-")) {
322        return Err(policy_error(
323            requested_path,
324            info,
325            format!(
326                "ad hoc Winx artifacts at the workspace root are not allowed; use {} with a \
327                 short descriptive filename",
328                info.directory.display()
329            ),
330        ));
331    }
332    Ok(())
333}
334
335fn is_temp_relative(relative: &Path) -> bool {
336    let mut components = relative.components();
337    matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new(".winx"))
338        && matches!(components.next(), Some(Component::Normal(value)) if value == OsStr::new("tmp"))
339}
340
341fn reject_symlinked_temp_root(workspace: &Path, path: &Path, info: &AgentTempInfo) -> Result<()> {
342    let temp_root = workspace.join(TEMP_ROOT);
343    if fs::symlink_metadata(&temp_root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
344        return Err(policy_error(
345            path,
346            info,
347            format!(
348                "{} is a symlink; the managed temporary root must be a real directory inside \
349                 the workspace",
350                temp_root.display()
351            ),
352        ));
353    }
354    Ok(())
355}
356
357fn validate_session_relative_path(path: &Path, info: &AgentTempInfo) -> Result<()> {
358    let relative = path.strip_prefix(&info.directory).map_err(|_| {
359        policy_error(
360            path,
361            info,
362            format!(
363                "temporary helpers are session-scoped; use the exact temporary_artifact_dir {} \
364                 returned by Initialize",
365                info.directory.display()
366            ),
367        )
368    })?;
369    let relative_bytes = relative.as_os_str().as_encoded_bytes().len();
370    if relative_bytes == 0 {
371        return Err(policy_error(path, info, "a helper filename is required".to_string()));
372    }
373    if relative_bytes > MAX_RELATIVE_PATH_BYTES {
374        return Err(policy_error(
375            path,
376            info,
377            format!(
378                "the session-relative path is {relative_bytes} bytes; temporary paths are limited \
379                 to {MAX_RELATIVE_PATH_BYTES} bytes"
380            ),
381        ));
382    }
383
384    let mut count = 0usize;
385    for component in relative.components() {
386        let Component::Normal(name) = component else {
387            return Err(policy_error(
388                path,
389                info,
390                "temporary paths may contain only normal filename components".to_string(),
391            ));
392        };
393        count += 1;
394        let bytes = name.as_encoded_bytes().len();
395        if bytes > MAX_COMPONENT_BYTES {
396            return Err(policy_error(
397                path,
398                info,
399                format!(
400                    "a temporary path component is {bytes} bytes; components are limited to \
401                     {MAX_COMPONENT_BYTES} bytes so content is never encoded in filesystem names"
402                ),
403            ));
404        }
405    }
406    if count > MAX_RELATIVE_COMPONENTS {
407        return Err(policy_error(
408            path,
409            info,
410            format!(
411                "temporary path depth is {count}; at most {MAX_RELATIVE_COMPONENTS} components \
412                 are allowed beneath the session directory"
413            ),
414        ));
415    }
416    Ok(())
417}
418
419fn validate_projected_size(path: &Path, info: &AgentTempInfo, projected: u64) -> Result<()> {
420    if projected <= MAX_TOTAL_BYTES {
421        return Ok(());
422    }
423    Err(policy_error(
424        path,
425        info,
426        format!(
427            "this write would grow managed temporary storage to {projected} bytes; the \
428             workspace-wide limit is {MAX_TOTAL_BYTES} bytes. Remove helpers that are no longer \
429             needed, then retry with corrected content"
430        ),
431    ))
432}
433
434fn policy_error(path: &Path, info: &AgentTempInfo, message: String) -> WinxError {
435    WinxError::TemporaryArtifactPolicy {
436        path: path.to_path_buf(),
437        temporary_artifact_dir: info.directory.clone(),
438        message,
439    }
440}
441
442fn temp_tree_size(root: &Path) -> Option<u64> {
443    let metadata = match fs::symlink_metadata(root) {
444        Ok(metadata) => metadata,
445        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
446        Err(_) => return None,
447    };
448    if metadata.file_type().is_symlink() {
449        return None;
450    }
451
452    let mut total = 0u64;
453    let mut seen = 0usize;
454    let mut stack = vec![(root.to_path_buf(), 0usize)];
455    while let Some((path, depth)) = stack.pop() {
456        if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
457            return None;
458        }
459        seen += 1;
460        let metadata = fs::symlink_metadata(&path).ok()?;
461        if metadata.file_type().is_symlink() {
462            continue;
463        }
464        if metadata.is_file() {
465            total = total.saturating_add(metadata.len());
466            continue;
467        }
468        if metadata.is_dir() {
469            for entry in fs::read_dir(&path).ok()? {
470                stack.push((entry.ok()?.path(), depth + 1));
471            }
472        }
473    }
474    Some(total)
475}
476
477fn prune_sessions_unlocked(workspace_root: &Path, active: &Path, max_age: Duration) {
478    let workspace = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.to_path_buf());
479    let root = workspace.join(TEMP_ROOT);
480    if fs::symlink_metadata(&root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
481        return;
482    }
483    let Ok(entries) = fs::read_dir(&root) else { return };
484    let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
485    for entry in entries.flatten() {
486        let path = entry.path();
487        if path == active || !is_managed_session_entry(&entry) || !session_is_stale(&path, cutoff) {
488            continue;
489        }
490        if let Err(error) = fs::remove_dir_all(&path) {
491            debug!(path = %path.display(), %error, "failed to prune expired agent temp session");
492        }
493    }
494}
495
496fn is_managed_session_entry(entry: &fs::DirEntry) -> bool {
497    if !entry.file_type().is_ok_and(|kind| kind.is_dir() && !kind.is_symlink()) {
498        return false;
499    }
500    let name = entry.file_name();
501    let name = name.to_string_lossy();
502    let Some(hash) = name.strip_prefix(SESSION_PREFIX) else { return false };
503    hash.len() == SESSION_HASH_BYTES * 2 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
504}
505
506/// Conservative bounded scan: any read/metadata error or an excessive tree
507/// retains the session rather than deleting data whose freshness is uncertain.
508fn session_is_stale(root: &Path, cutoff: SystemTime) -> bool {
509    let mut newest = UNIX_EPOCH;
510    let mut seen = 0usize;
511    let mut stack = vec![(root.to_path_buf(), 0usize)];
512    while let Some((path, depth)) = stack.pop() {
513        if seen >= MAX_SCAN_ENTRIES || depth > MAX_SCAN_DEPTH {
514            return false;
515        }
516        seen += 1;
517        let Ok(metadata) = fs::symlink_metadata(&path) else { return false };
518        let Ok(modified) = metadata.modified() else { return false };
519        newest = newest.max(modified);
520        if metadata.is_dir() && !metadata.file_type().is_symlink() {
521            let Ok(entries) = fs::read_dir(&path) else { return false };
522            for entry in entries {
523                let Ok(entry) = entry else { return false };
524                stack.push((entry.path(), depth + 1));
525            }
526        }
527    }
528    newest < cutoff
529}
530
531#[cfg(test)]
532mod tests {
533    #![allow(clippy::expect_used, clippy::unwrap_used)]
534
535    use super::*;
536    use std::thread::sleep;
537    use tempfile::TempDir;
538
539    #[test]
540    fn session_path_is_stable_short_and_workspace_local() {
541        let workspace = TempDir::new().unwrap();
542        let first = session_info(workspace.path(), "chat/project:one");
543        let repeated = session_info(workspace.path(), "chat/project:one");
544        let other = session_info(workspace.path(), "chat/project:two");
545        assert_eq!(first, repeated);
546        assert_ne!(first.directory, other.directory);
547        assert!(first.directory.starts_with(workspace.path().canonicalize().unwrap()));
548        let name = first.directory.file_name().unwrap().to_string_lossy();
549        assert_eq!(name.len(), SESSION_PREFIX.len() + SESSION_HASH_BYTES * 2);
550    }
551
552    #[test]
553    fn accepts_short_helpers_only_in_the_active_session() {
554        let workspace = TempDir::new().unwrap();
555        let info = session_info(workspace.path(), "active");
556        let valid = info.directory.join("review/adapter.py");
557        validate_edit_target(workspace.path(), "active", &valid, &valid, None, 128).unwrap();
558
559        let other = session_info(workspace.path(), "other").directory.join("adapter.py");
560        let error = validate_edit_target(workspace.path(), "active", &other, &other, None, 128)
561            .expect_err("cross-session temp write must fail");
562        assert!(error.to_string().contains("session-scoped"), "{error}");
563    }
564
565    #[test]
566    fn bash_writes_accept_only_the_active_session_temp_path() {
567        let workspace = TempDir::new().unwrap();
568        let info = session_info(workspace.path(), "active");
569        let valid = format!("printf x > '{}'", info.directory.join("adapter.ts").display());
570        validate_bash_command(workspace.path(), workspace.path(), "active", &valid)
571            .expect("active session destination must be accepted");
572
573        for command in [
574            "printf x > .winx/tmp/direct.ts",
575            "printf x | tee .winx-review-carrier.js",
576            "python - <<'PY'\nfrom pathlib import Path\nPath('.winx/tmp/direct.py').write_text('x')\nPY",
577        ] {
578            let error = validate_bash_command(workspace.path(), workspace.path(), "active", command)
579                .expect_err("unmanaged shell helper must fail");
580            assert!(
581                error.to_string().to_ascii_lowercase().contains("temporary artifact policy"),
582                "{error}"
583            );
584        }
585    }
586
587    #[test]
588    fn bash_temp_preflight_does_not_block_reads_or_dynamic_active_path() {
589        let workspace = TempDir::new().unwrap();
590        for command in [
591            "rg needle .winx/tmp/direct.ts",
592            "cat .winx-review-carrier.js",
593            "mkdir -p \"$WINX_TEMP_DIR\" && printf x > \"$WINX_TEMP_DIR/helper.ts\"",
594        ] {
595            validate_bash_command(workspace.path(), workspace.path(), "active", command)
596                .expect("read or runtime-provided destination must remain available");
597        }
598    }
599
600    #[test]
601    fn rejects_legacy_root_artifacts_and_payload_names() {
602        let workspace = TempDir::new().unwrap();
603        let legacy = workspace.path().join(".winx_tmp/payload/file.txt");
604        let error = validate_edit_target(workspace.path(), "active", &legacy, &legacy, None, 10)
605            .expect_err("legacy root must fail");
606        assert!(error.to_string().contains("workspace root"), "{error}");
607
608        let info = session_info(workspace.path(), "active");
609        let encoded = info.directory.join(format!("{}.txt", "x".repeat(MAX_COMPONENT_BYTES + 1)));
610        let error = validate_edit_target(workspace.path(), "active", &encoded, &encoded, None, 10)
611            .expect_err("long component must fail");
612        assert!(error.to_string().contains("filesystem names"), "{error}");
613    }
614
615    #[test]
616    fn resolved_path_cannot_hide_a_root_artifact_behind_parent_components() {
617        let workspace = TempDir::new().unwrap();
618        let requested = workspace.path().join("nested/../.winx-review-carrier.py");
619        let resolved = workspace.path().join(".winx-review-carrier.py");
620        let error =
621            validate_edit_target(workspace.path(), "active", &requested, &resolved, None, 10)
622                .expect_err("resolved top-level artifact must fail");
623        assert!(error.to_string().contains("workspace root"), "{error}");
624    }
625
626    #[cfg(unix)]
627    #[test]
628    fn lexical_workspace_alias_cannot_bypass_temp_policy() {
629        use std::os::unix::fs::symlink;
630
631        let parent = TempDir::new().unwrap();
632        let real_workspace = parent.path().join("real-workspace");
633        let alias_workspace = parent.path().join("workspace-alias");
634        fs::create_dir(&real_workspace).unwrap();
635        symlink(&real_workspace, &alias_workspace).unwrap();
636
637        let legacy = alias_workspace.join(".winx_tmp/payload.txt");
638        let error = validate_edit_target(&alias_workspace, "active", &legacy, &legacy, None, 10)
639            .expect_err("a lexical workspace alias must not bypass root-artifact policy");
640        assert!(error.to_string().contains("workspace root"), "{error}");
641
642        let canonical_workspace = alias_workspace.canonicalize().unwrap();
643        let canonical_session = session_info(&alias_workspace, "active").directory;
644        let relative_session = canonical_session.strip_prefix(&canonical_workspace).unwrap();
645        let aliased_helper = alias_workspace.join(relative_session).join("adapter.py");
646        validate_edit_target(
647            &alias_workspace,
648            "active",
649            &aliased_helper,
650            &aliased_helper,
651            None,
652            10,
653        )
654        .expect("the active managed directory must work through the lexical alias");
655    }
656
657    #[test]
658    fn prune_removes_only_expired_managed_siblings() {
659        let workspace = TempDir::new().unwrap();
660        let active = session_info(workspace.path(), "active").directory;
661        let expired = session_info(workspace.path(), "expired").directory;
662        let unmanaged = workspace.path().join(TEMP_ROOT).join("manual-cache");
663        fs::create_dir_all(&active).unwrap();
664        fs::create_dir_all(&expired).unwrap();
665        fs::create_dir_all(&unmanaged).unwrap();
666        fs::write(expired.join("old.txt"), "old").unwrap();
667        sleep(Duration::from_millis(15));
668
669        let _guard = TEMP_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
670        prune_sessions_unlocked(workspace.path(), &active, Duration::from_millis(1));
671        assert!(active.exists());
672        assert!(!expired.exists());
673        assert!(unmanaged.exists());
674    }
675
676    #[test]
677    fn quota_projection_accounts_for_replacements() {
678        let workspace = TempDir::new().unwrap();
679        let info = session_info(workspace.path(), "active");
680        let path = info.directory.join("large.bin");
681        assert!(validate_projected_size(&path, &info, MAX_TOTAL_BYTES).is_ok());
682        assert!(validate_projected_size(&path, &info, MAX_TOTAL_BYTES + 1).is_err());
683    }
684
685    #[test]
686    fn batch_quota_counts_every_planned_helper_before_committing() {
687        let workspace = TempDir::new().unwrap();
688        let info = session_info(workspace.path(), "active");
689        let paths = (0..9)
690            .map(|index| info.directory.join(format!("helper-{index}.bin")))
691            .collect::<Vec<_>>();
692        let edits = paths
693            .iter()
694            .map(|path| TempEdit { path, previous_bytes: 0, new_bytes: MAX_FILE_BYTES })
695            .collect::<Vec<_>>();
696
697        let error = validate_batch_quota(workspace.path(), "active", &edits)
698            .expect_err("aggregate batch must honor the workspace quota");
699        assert!(error.to_string().contains("workspace-wide limit"), "{error}");
700    }
701}