polyc-tools 2026.9.0

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! `#2286` — a delegated worker runs its coding tools inside its OWN workspace
//! subtree, not the conversation root it shares with its parent and siblings.
//!
//! Before this fencing, a worker's nested turn reused the parent's already-
//! composed executor unchanged, so every coding tool re-derived the same
//! process-wide `/workspace` root. Two concurrent workers writing the same
//! relative path therefore raced on one file. These tests pin the structural
//! fact that closes it: two workers derived from one registry resolve the same
//! relative path to two DIFFERENT absolute paths, both still lexically inside
//! the conversation root.
//!
//! The re-root is deliberately FULL — reads move with writes. A worker cannot
//! read what its parent wrote, which is a real capability loss tracked in
//! `#2295`; `worker_cannot_read_a_file_the_parent_wrote` pins it so a future
//! reader sees a decision rather than an accident.

#![allow(clippy::pedantic, clippy::nursery, missing_docs)]

use std::sync::atomic::{AtomicU64, Ordering};

use polyc_agent::ToolExecutor;
use polyc_agent::delegate::WorkerScope;
use polyc_tools::ToolRegistry;

/// A unique temp dir per test (atomic counter, not wall-clock — safe under
/// parallel test threads). Mirrors the crate-internal helper, which is
/// `#[cfg(test)]` and so invisible to an integration test.
fn tmp_root(prefix: &str) -> std::path::PathBuf {
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let mut p = std::env::temp_dir();
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    p.push(format!("pc-{prefix}-{}-{n}", std::process::id()));
    std::fs::create_dir_all(&p).expect("temp root is creatable");
    p
}

/// A registry rooted at an explicit directory. Never touches process env —
/// this workspace forbids `std::env::set_var` in tests.
fn registry_at(root: &std::path::Path) -> ToolRegistry {
    ToolRegistry::rooted_at(root.to_path_buf(), None)
}

async fn write_file(tools: &dyn ToolExecutor, path: &str, content: &str) -> String {
    let args = serde_json::json!({ "path": path, "content": content }).to_string();
    tools.execute("file_write", &args).await
}

async fn read_file(tools: &dyn ToolExecutor, path: &str) -> String {
    let args = serde_json::json!({ "path": path }).to_string();
    tools.execute("file_read", &args).await
}

/// Every regular file under `root`, as paths relative to it.
fn files_under(root: &std::path::Path) -> Vec<std::path::PathBuf> {
    let mut out = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if let Ok(rel) = path.strip_prefix(root) {
                out.push(rel.to_path_buf());
            }
        }
    }
    out.sort();
    out
}

/// THE regression test. Two concurrent workers writing the SAME relative path
/// must not clobber each other.
///
/// Without the fencing both writes resolve to `<root>/out.txt` and the second
/// overwrites the first, leaving ONE file whose contents belong to whichever
/// worker ran last — this test fails on that behavior at the file-count
/// assertion and again at the per-worker content assertions.
#[tokio::test]
async fn concurrent_workers_do_not_clobber_the_same_relative_path() {
    let root = tmp_root("fence-clobber");
    let parent = registry_at(&root);

    let worker_a = parent
        .for_worker(&WorkerScope::bare("call-a"))
        .expect("a registry owns a workspace, so it must re-root")
        .expect("a bare scope requests no seeding, so nothing can be refused")
        .tools;
    let worker_b = parent
        .for_worker(&WorkerScope::bare("call-b"))
        .expect("a registry owns a workspace, so it must re-root")
        .expect("a bare scope requests no seeding, so nothing can be refused")
        .tools;

    // Same relative path, different bytes, both workers.
    let (ra, rb) = tokio::join!(
        write_file(worker_a.as_ref(), "out.txt", "written-by-a"),
        write_file(worker_b.as_ref(), "out.txt", "written-by-b"),
    );
    for (who, result) in [("a", &ra), ("b", &rb)] {
        let v: serde_json::Value = serde_json::from_str(result).expect("JSON result");
        assert!(
            v.get("error").is_none(),
            "worker {who}: write should succeed, got {result}"
        );
    }

    // Two distinct files on disk, not one clobbered file.
    let files = files_under(&root);
    assert_eq!(
        files.len(),
        2,
        "each worker must get its own out.txt; found {files:?}"
    );
    for rel in &files {
        assert_eq!(
            rel.file_name().and_then(std::ffi::OsStr::to_str),
            Some("out.txt")
        );
        assert!(
            rel.parent().is_some_and(|p| p != std::path::Path::new("")),
            "each write must land in a per-worker subdirectory, got {rel:?}"
        );
    }
    assert_ne!(files[0], files[1], "the two workers shared a path");

    // Each worker reads back its OWN bytes.
    let back_a = read_file(worker_a.as_ref(), "out.txt").await;
    let back_b = read_file(worker_b.as_ref(), "out.txt").await;
    assert!(
        back_a.contains("written-by-a") && !back_a.contains("written-by-b"),
        "worker a read the wrong bytes: {back_a}"
    );
    assert!(
        back_b.contains("written-by-b") && !back_b.contains("written-by-a"),
        "worker b read the wrong bytes: {back_b}"
    );

    std::fs::remove_dir_all(&root).ok();
}

/// The full re-root applies to READS too: a worker cannot see a file its
/// parent wrote. This is a deliberate capability loss (`#2295` tracks the
/// share-in contract that would restore it), pinned here so it reads as a
/// decision rather than an accident.
#[tokio::test]
async fn worker_cannot_read_a_file_the_parent_wrote() {
    let root = tmp_root("fence-reads");
    let parent = registry_at(&root);

    let written = write_file(&parent, "parent.txt", "parent-bytes").await;
    let v: serde_json::Value = serde_json::from_str(&written).expect("JSON result");
    assert!(v.get("error").is_none(), "parent write failed: {written}");
    assert!(root.join("parent.txt").exists());

    let worker = parent
        .for_worker(&WorkerScope::bare("call-a"))
        .expect("must re-root")
        .expect("a bare scope requests no seeding, so nothing can be refused")
        .tools;
    let out = read_file(worker.as_ref(), "parent.txt").await;
    assert!(
        !out.contains("parent-bytes"),
        "a fenced worker must not see the parent's file: {out}"
    );

    // And the parent still sees its own file — re-rooting a worker does not
    // disturb the registry it was derived from.
    let parent_back = read_file(&parent, "parent.txt").await;
    assert!(
        parent_back.contains("parent-bytes"),
        "the parent lost its own file: {parent_back}"
    );

    std::fs::remove_dir_all(&root).ok();
}

/// Nesting a worker root must not weaken containment: traversal out of the
/// worker subtree is refused exactly as it is at the conversation root, so a
/// worker cannot climb into a sibling's directory or out of the workspace.
#[tokio::test]
async fn traversal_is_still_refused_from_inside_a_worker_root() {
    let root = tmp_root("fence-traversal");
    let parent = registry_at(&root);
    let worker = parent
        .for_worker(&WorkerScope::bare("call-a"))
        .expect("must re-root")
        .expect("a bare scope requests no seeding, so nothing can be refused")
        .tools;

    for path in ["../escape.txt", "../../etc/passwd", "/etc/passwd", ".."] {
        let out = write_file(worker.as_ref(), path, "x").await;
        let v: serde_json::Value = serde_json::from_str(&out).expect("JSON result");
        assert!(
            v.get("error").is_some(),
            "worker write to {path} must be refused, got {out}"
        );
    }
    // Nothing escaped into the conversation root.
    assert!(!root.join("escape.txt").exists());

    std::fs::remove_dir_all(&root).ok();
}

/// A worker id that is hostile as a path — traversal segments, separators,
/// empties — still yields exactly one directory directly under the
/// conversation root, and distinct ids never collapse into one directory.
#[tokio::test]
async fn hostile_worker_ids_stay_one_level_under_the_conversation_root() {
    let root = tmp_root("fence-ids");
    let parent = registry_at(&root);

    let hostile = ["../../etc", "a/b", "a_b", "", "..", "."];
    let mut dirs = Vec::new();
    for id in hostile {
        let worker = parent
            .for_worker(&WorkerScope::bare(id))
            .unwrap_or_else(|| panic!("re-root failed for {id:?}"))
            .expect("a bare scope requests no seeding, so nothing can be refused")
            .tools;
        let out = write_file(worker.as_ref(), "probe.txt", id).await;
        let v: serde_json::Value = serde_json::from_str(&out).expect("JSON result");
        assert!(v.get("error").is_none(), "{id:?}: write failed: {out}");

        let files = files_under(&root);
        let found = files
            .iter()
            .find(|f| std::fs::read_to_string(root.join(f)).is_ok_and(|c| c == id))
            .unwrap_or_else(|| panic!("{id:?}: no file carried its bytes"));
        // Exactly one directory level: <worker-subdir>/probe.txt.
        assert_eq!(
            found.components().count(),
            2,
            "{id:?} must land exactly one level deep, got {found:?}"
        );
        dirs.push(
            found
                .parent()
                .expect("a per-worker file always has a parent dir")
                .to_path_buf(),
        );
    }

    // `"a/b"` and `"a_b"` sanitize identically; the raw-id hash must keep
    // them in separate directories.
    let mut unique = dirs.clone();
    unique.sort();
    unique.dedup();
    assert_eq!(
        unique.len(),
        dirs.len(),
        "distinct worker ids collapsed into one directory: {dirs:?}"
    );

    std::fs::remove_dir_all(&root).ok();
}

/// A failed subtree creation must not fall back to the SHARED root.
///
/// `for_worker` returning `None` means "this executor owns no workspace", and
/// the caller honors that by running the worker un-re-rooted — correct for a
/// proxy, catastrophic for a registry that failed to `create_dir_all`. The
/// worker is write-capable, so the shared-root fallback is the exact
/// clobbering this fencing exists to prevent; re-rooting into a directory
/// that could not be created is the safe failure.
///
/// A plain FILE sitting at the worker's subdirectory path makes
/// `create_dir_all` fail without any privilege or mount games.
#[tokio::test]
async fn a_failed_subtree_creation_still_re_roots_away_from_the_shared_root() {
    let root = tmp_root("fence-faildir");
    let parent = registry_at(&root);

    // Occupy the worker's subdirectory path with a file.
    let subdir = polyc_tools::coding::workspace::worker_root(&root, "call-a");
    std::fs::write(&subdir, b"in the way").expect("the blocking file is writable");
    assert!(
        subdir.is_file(),
        "the worker path must be a file, not a dir"
    );

    let worker = parent
        .for_worker(&WorkerScope::bare("call-a"))
        .expect("a failed mkdir must not report `no workspace to re-root`")
        .expect("a bare scope requests no seeding, so nothing can be refused")
        .tools;

    let result = write_file(&*worker, "out.txt", "worker bytes").await;

    // The one thing that must never happen: the shared root gaining `out.txt`.
    assert!(
        !root.join("out.txt").exists(),
        "the worker escaped to the SHARED conversation root: {result}"
    );

    std::fs::remove_dir_all(&root).ok();
}

/// A parent's `glob`/`grep` must not walk its workers' scratch subtrees.
///
/// Worker roots are created eagerly for every delegate call and never cleaned
/// up, so without an explicit skip the parent's own search returns siblings'
/// scratch files as matches and spends its visit budget inside them. The
/// leading dot in `.worker-` does nothing here: this crate's walker skips
/// `.git` and nothing else.
#[tokio::test]
async fn a_parent_search_does_not_descend_into_worker_subtrees() {
    let root = tmp_root("fence-search");
    let parent = registry_at(&root);

    write_file(&parent, "project.txt", "needle in the project").await;
    let worker = parent
        .for_worker(&WorkerScope::bare("call-a"))
        .expect("a registry owns a workspace, so it must re-root")
        .expect("a bare scope requests no seeding, so nothing can be refused")
        .tools;
    write_file(&*worker, "scratch.txt", "needle in the worker scratch").await;

    let hits = parent
        .execute(
            "glob",
            &serde_json::json!({ "pattern": "**/*.txt" }).to_string(),
        )
        .await;
    assert!(
        hits.contains("project.txt"),
        "the parent must still find its own files: {hits}"
    );
    assert!(
        !hits.contains("scratch.txt"),
        "the parent walked into a worker's subtree: {hits}"
    );

    // The worker searching its OWN root is unaffected — its root IS the
    // subtree, so nothing beneath it carries the prefix.
    let own = worker
        .execute(
            "glob",
            &serde_json::json!({ "pattern": "**/*.txt" }).to_string(),
        )
        .await;
    assert!(
        own.contains("scratch.txt"),
        "a worker must still see its own scratch files: {own}"
    );

    std::fs::remove_dir_all(&root).ok();
}