Skip to main content

lean_ctx/tools/
ctx_share.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Serialize, Deserialize, Clone)]
5struct SharedContext {
6    from_agent: String,
7    to_agent: Option<String>,
8    files: Vec<SharedFile>,
9    message: Option<String>,
10    timestamp: String,
11}
12
13#[derive(Serialize, Deserialize, Clone)]
14struct SharedFile {
15    path: String,
16    content: String,
17    mode: String,
18    tokens: usize,
19}
20
21fn shared_dir(project_root: &str) -> PathBuf {
22    let hash = crate::core::project_hash::hash_project_root(project_root);
23    crate::core::data_dir::lean_ctx_data_dir()
24        .unwrap_or_else(|_| PathBuf::from("."))
25        .join("agents")
26        .join("shared")
27        .join(hash)
28}
29
30/// Filesystem-safe slug of an agent id for the share filename. Agent ids may
31/// carry characters that are reserved on NTFS — `team:alice` (the documented
32/// org format, enterprise#28) contains `:`, which Windows silently interprets
33/// as an Alternate Data Stream: the write "succeeds" but `read_dir` never
34/// lists a file, so the share is unpullable. Keep `[A-Za-z0-9._-]`, map
35/// everything else to `-`. The real agent id lives inside the JSON payload;
36/// the filename is only a directory-entry label.
37fn sanitize_for_filename(s: &str) -> String {
38    s.chars()
39        .map(|c| {
40            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
41                c
42            } else {
43                '-'
44            }
45        })
46        .collect()
47}
48
49/// Reads `path` (absolute or relative to `project_root`) only if it resolves
50/// inside the project root after symlink resolution — the jail that keeps a
51/// share from carrying files outside the workspace (enterprise#28).
52fn read_within_root(path: &str, project_root: &str) -> Option<(String, String, usize)> {
53    let root =
54        crate::core::pathutil::canonicalize_secure(std::path::Path::new(project_root)).ok()?;
55    let candidate = std::path::Path::new(path);
56    let absolute = if candidate.is_absolute() {
57        candidate.to_path_buf()
58    } else {
59        root.join(candidate)
60    };
61    let resolved = crate::core::pathutil::canonicalize_secure(&absolute).ok()?;
62    if !resolved.starts_with(&root) {
63        return None;
64    }
65    let resolved_str = resolved.to_string_lossy().to_string();
66    let content = crate::core::io_boundary::read_file_lossy(&resolved_str).ok()?;
67    let tokens = crate::core::tokens::count_tokens(&content);
68    Some((resolved_str, content, tokens))
69}
70
71pub fn handle(
72    action: &str,
73    from_agent: Option<&str>,
74    to_agent: Option<&str>,
75    paths: Option<&str>,
76    message: Option<&str>,
77    cache: &crate::core::cache::SessionCache,
78    project_root: &str,
79) -> String {
80    match action {
81        "push" => handle_push(from_agent, to_agent, paths, message, cache, project_root),
82        "pull" => handle_pull(from_agent, project_root),
83        "list" => handle_list(project_root),
84        "clear" => handle_clear(from_agent, project_root),
85        _ => format!("Unknown action: {action}. Use: push, pull, list, clear"),
86    }
87}
88
89fn handle_push(
90    from_agent: Option<&str>,
91    to_agent: Option<&str>,
92    paths: Option<&str>,
93    message: Option<&str>,
94    cache: &crate::core::cache::SessionCache,
95    project_root: &str,
96) -> String {
97    let Some(from) = from_agent else {
98        return "Error: from_agent is required (register first via ctx_agent)".to_string();
99    };
100
101    let path_list: Vec<&str> = match paths {
102        Some(p) => p.split(',').map(str::trim).collect(),
103        None => return "Error: paths is required (comma-separated file paths)".to_string(),
104    };
105
106    let mut shared_files = Vec::new();
107    let mut not_found = Vec::new();
108
109    for path in &path_list {
110        // Revalidate against disk before handing the file to another agent: a
111        // stale cached copy would silently pass an outdated handover file to the
112        // receiving agent. `current_full_content` re-reads when the cache is
113        // behind disk, so the receiver always gets the current content.
114        if let Some((content, tokens)) = cache.current_full_content(path) {
115            let canonical = cache
116                .get(path)
117                .map_or_else(|| (*path).to_string(), |entry| entry.path.clone());
118            shared_files.push(SharedFile {
119                path: canonical,
120                content,
121                mode: "full".to_string(),
122                tokens,
123            });
124            continue;
125        }
126        // Not in this instance's cache — org flows (team server, enterprise#28)
127        // run each call on a fresh instance, so fall back to a direct read,
128        // jailed to the project root: a share must never exfiltrate files
129        // outside the workspace.
130        if let Some((canonical, content, tokens)) = read_within_root(path, project_root) {
131            shared_files.push(SharedFile {
132                path: canonical,
133                content,
134                mode: "full".to_string(),
135                tokens,
136            });
137        } else {
138            not_found.push(*path);
139        }
140    }
141
142    if shared_files.is_empty() {
143        return format!(
144            "No shareable files found (not cached, and not readable inside the project root).\nNot found: {}",
145            not_found.join(", ")
146        );
147    }
148
149    let context = SharedContext {
150        from_agent: from.to_string(),
151        to_agent: to_agent.map(String::from),
152        files: shared_files.clone(),
153        message: message.map(String::from),
154        timestamp: chrono::Utc::now().to_rfc3339(),
155    };
156
157    let dir = shared_dir(project_root);
158    let _ = std::fs::create_dir_all(&dir);
159
160    let filename = format!(
161        "{}_{}.json",
162        sanitize_for_filename(from),
163        chrono::Utc::now().format("%Y%m%d_%H%M%S")
164    );
165    let path = dir.join(&filename);
166
167    match serde_json::to_string_pretty(&context) {
168        Ok(json) => {
169            if let Err(e) = std::fs::write(&path, json) {
170                return format!("Error writing shared context: {e}");
171            }
172        }
173        Err(e) => return format!("Error serializing shared context: {e}"),
174    }
175
176    let total_tokens: usize = shared_files.iter().map(|f| f.tokens).sum();
177    let mut result = format!(
178        "Shared {} files ({} tokens) from {from}",
179        shared_files.len(),
180        total_tokens
181    );
182
183    if let Some(target) = to_agent {
184        result.push_str(&format!(" → {target}"));
185    } else {
186        result.push_str(" → all agents (broadcast)");
187    }
188
189    if !not_found.is_empty() {
190        result.push_str(&format!(
191            "\nNot in cache (skipped): {}",
192            not_found.join(", ")
193        ));
194    }
195
196    result
197}
198
199fn handle_pull(agent_id: Option<&str>, project_root: &str) -> String {
200    let dir = shared_dir(project_root);
201    if !dir.exists() {
202        return "No shared contexts available.".to_string();
203    }
204
205    let my_id = agent_id.unwrap_or("anonymous");
206    let mut entries: Vec<SharedContext> = Vec::new();
207
208    if let Ok(readdir) = std::fs::read_dir(&dir) {
209        for entry in readdir.flatten() {
210            if let Ok(content) = std::fs::read_to_string(entry.path())
211                && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
212            {
213                let is_for_me = ctx.to_agent.is_none() || ctx.to_agent.as_deref() == Some(my_id);
214                let is_not_from_me = ctx.from_agent != my_id;
215
216                if is_for_me && is_not_from_me {
217                    entries.push(ctx);
218                }
219            }
220        }
221    }
222
223    if entries.is_empty() {
224        return "No shared contexts for you.".to_string();
225    }
226
227    entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
228
229    let mut out = format!("Shared contexts available ({}):\n", entries.len());
230    for ctx in &entries {
231        let file_list: Vec<&str> = ctx.files.iter().map(|f| f.path.as_str()).collect();
232        let total_tokens: usize = ctx.files.iter().map(|f| f.tokens).sum();
233        out.push_str(&format!(
234            "\n  From: {} ({})\n  Files: {} ({} tokens)\n  {}\n",
235            ctx.from_agent,
236            &ctx.timestamp[..19],
237            file_list.join(", "),
238            total_tokens,
239            ctx.message
240                .as_deref()
241                .map(|m| format!("Message: {m}"))
242                .unwrap_or_default(),
243        ));
244    }
245
246    let total_files: usize = entries.iter().map(|e| e.files.len()).sum();
247    out.push_str(&format!(
248        "\nTotal: {} contexts, {} files. Use ctx_read on pulled files to load them into your cache.",
249        entries.len(),
250        total_files
251    ));
252
253    out
254}
255
256fn handle_list(project_root: &str) -> String {
257    let dir = shared_dir(project_root);
258    if !dir.exists() {
259        return "No shared contexts.".to_string();
260    }
261
262    let mut count = 0;
263    let mut total_files = 0;
264    let mut out = String::from("Shared context store:\n");
265
266    if let Ok(readdir) = std::fs::read_dir(&dir) {
267        for entry in readdir.flatten() {
268            if let Ok(content) = std::fs::read_to_string(entry.path())
269                && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
270            {
271                count += 1;
272                total_files += ctx.files.len();
273                let target = ctx.to_agent.as_deref().unwrap_or("broadcast");
274                out.push_str(&format!(
275                    "  {} → {} ({} files, {})\n",
276                    ctx.from_agent,
277                    target,
278                    ctx.files.len(),
279                    &ctx.timestamp[..19]
280                ));
281            }
282        }
283    }
284
285    if count == 0 {
286        return "No shared contexts.".to_string();
287    }
288
289    out.push_str(&format!("\nTotal: {count} shares, {total_files} files"));
290    out
291}
292
293fn handle_clear(agent_id: Option<&str>, project_root: &str) -> String {
294    let dir = shared_dir(project_root);
295    if !dir.exists() {
296        return "Nothing to clear.".to_string();
297    }
298
299    let my_id = agent_id.unwrap_or("anonymous");
300    let mut removed = 0;
301
302    if let Ok(readdir) = std::fs::read_dir(&dir) {
303        for entry in readdir.flatten() {
304            if let Ok(content) = std::fs::read_to_string(entry.path())
305                && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
306                && ctx.from_agent == my_id
307            {
308                let _ = std::fs::remove_file(entry.path());
309                removed += 1;
310            }
311        }
312    }
313
314    format!("Cleared {removed} shared context(s) from {my_id}")
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::core::cache::SessionCache;
321
322    /// Concatenated JSON of every shared-context file for `project_root`. Lets a
323    /// test assert on exactly what content was *captured into the handover*.
324    fn shared_json(project_root: &str) -> String {
325        let dir = shared_dir(project_root);
326        let mut all = String::new();
327        if let Ok(rd) = std::fs::read_dir(&dir) {
328            for e in rd.flatten() {
329                all.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default());
330            }
331        }
332        all
333    }
334
335    #[test]
336    fn push_shares_fresh_content_and_pull_lists_it() {
337        let _lock = crate::core::data_dir::test_env_lock();
338        let data = tempfile::tempdir().unwrap();
339        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
340
341        let proj = tempfile::tempdir().unwrap();
342        let root = proj.path().to_str().unwrap();
343        let file = proj.path().join("handover.md");
344        std::fs::write(&file, "HANDOVER marker-AAA\n").unwrap();
345        let path = file.to_str().unwrap();
346
347        let mut cache = SessionCache::new();
348        cache.store(path, "HANDOVER marker-AAA\n");
349
350        let out = handle_push(
351            Some("agentA"),
352            Some("agentB"),
353            Some(path),
354            None,
355            &cache,
356            root,
357        );
358        assert!(out.contains("Shared 1 files"), "push result: {out}");
359        assert!(
360            shared_json(root).contains("marker-AAA"),
361            "content not captured"
362        );
363
364        // The receiver sees the handover listed.
365        let pulled = handle_pull(Some("agentB"), root);
366        assert!(
367            pulled.contains("handover.md"),
368            "pull missing file: {pulled}"
369        );
370    }
371
372    #[test]
373    fn push_shares_edited_content_not_stale_diff_mtime() {
374        // Carlos handover: a file edited *after* it was cached must be shared as
375        // the NEW content — the receiving agent must never get the pre-edit copy.
376        let _lock = crate::core::data_dir::test_env_lock();
377        let data = tempfile::tempdir().unwrap();
378        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
379
380        let proj = tempfile::tempdir().unwrap();
381        let root = proj.path().to_str().unwrap();
382        let file = proj.path().join("handover.md");
383        std::fs::write(&file, "V1 marker-AAA\n").unwrap();
384        let path = file.to_str().unwrap();
385
386        let mut cache = SessionCache::new();
387        cache.store(path, "V1 marker-AAA\n");
388
389        std::thread::sleep(std::time::Duration::from_millis(10));
390        std::fs::write(&file, "V2 marker-BBB\n").unwrap();
391
392        let out = handle_push(Some("a"), Some("b"), Some(path), None, &cache, root);
393        assert!(out.contains("Shared 1 files"), "push result: {out}");
394        let json = shared_json(root);
395        assert!(
396            json.contains("marker-BBB"),
397            "fresh content not shared: {json}"
398        );
399        assert!(
400            !json.contains("marker-AAA"),
401            "stale content leaked into handover: {json}"
402        );
403    }
404
405    #[test]
406    fn push_shares_edited_content_same_mtime_same_size() {
407        // Hash backstop: identical mtime + identical size, changed content.
408        let _lock = crate::core::data_dir::test_env_lock();
409        let data = tempfile::tempdir().unwrap();
410        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
411
412        let proj = tempfile::tempdir().unwrap();
413        let root = proj.path().to_str().unwrap();
414        let file = proj.path().join("h.md");
415        std::fs::write(&file, "AAA\n").unwrap();
416        let path = file.to_str().unwrap();
417        let mtime = std::fs::metadata(&file).unwrap().modified().unwrap();
418
419        let mut cache = SessionCache::new();
420        cache.store(path, "AAA\n");
421
422        // Same length (4 bytes), restore the original mtime → only the content hash differs.
423        std::fs::write(&file, "BBB\n").unwrap();
424        std::fs::OpenOptions::new()
425            .write(true)
426            .open(&file)
427            .unwrap()
428            .set_modified(mtime)
429            .unwrap();
430
431        let out = handle_push(Some("a"), Some("b"), Some(path), None, &cache, root);
432        assert!(out.contains("Shared 1 files"), "push result: {out}");
433        let json = shared_json(root);
434        assert!(
435            json.contains("BBB"),
436            "hash backstop failed, stale shared: {json}"
437        );
438        assert!(!json.contains("AAA"), "stale content leaked: {json}");
439    }
440
441    #[test]
442    fn push_skips_uncached_paths() {
443        let _lock = crate::core::data_dir::test_env_lock();
444        let data = tempfile::tempdir().unwrap();
445        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
446
447        let proj = tempfile::tempdir().unwrap();
448        let root = proj.path().to_str().unwrap();
449        let cache = SessionCache::new(); // empty
450
451        let out = handle_push(
452            Some("a"),
453            Some("b"),
454            Some("/no/such/file.md"),
455            None,
456            &cache,
457            root,
458        );
459        assert!(
460            out.contains("No shareable files found"),
461            "expected skip message: {out}"
462        );
463    }
464
465    #[test]
466    fn push_falls_back_to_disk_inside_root_without_cache() {
467        // Org flow (enterprise#28): the team server runs every call on a fresh
468        // instance — an empty cache must not block sharing a workspace file.
469        let _lock = crate::core::data_dir::test_env_lock();
470        let data = tempfile::tempdir().unwrap();
471        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
472
473        let proj = tempfile::tempdir().unwrap();
474        let root = proj.path().to_str().unwrap();
475        std::fs::write(proj.path().join("notes.md"), "ORG-SHARE marker-CCC\n").unwrap();
476
477        let cache = SessionCache::new(); // fresh instance, nothing cached
478        let out = handle_push(
479            Some("team:alice"),
480            None,
481            Some("notes.md"),
482            Some("handover"),
483            &cache,
484            root,
485        );
486        assert!(out.contains("Shared 1 files"), "push result: {out}");
487        assert!(
488            shared_json(root).contains("marker-CCC"),
489            "disk fallback content not captured"
490        );
491
492        // A different token (agent) pulls it — org-wide sharing.
493        let pulled = handle_pull(Some("team:bob"), root);
494        assert!(pulled.contains("notes.md"), "receiver pull: {pulled}");
495        // The sender does not see their own share on pull.
496        let own = handle_pull(Some("team:alice"), root);
497        assert!(own.contains("No shared contexts for you"), "own: {own}");
498    }
499
500    #[test]
501    fn push_disk_fallback_is_jailed_to_project_root() {
502        // A path outside the workspace root must never enter a share.
503        let _lock = crate::core::data_dir::test_env_lock();
504        let data = tempfile::tempdir().unwrap();
505        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
506
507        let outside = tempfile::tempdir().unwrap();
508        let secret = outside.path().join("secret.txt");
509        std::fs::write(&secret, "TOP-SECRET\n").unwrap();
510
511        let proj = tempfile::tempdir().unwrap();
512        let root = proj.path().to_str().unwrap();
513        let cache = SessionCache::new();
514
515        for evil in [
516            secret.to_str().unwrap().to_string(),
517            format!("../{}", secret.display()),
518            "../../etc/hosts".to_string(),
519        ] {
520            let out = handle_push(Some("a"), None, Some(&evil), None, &cache, root);
521            assert!(
522                out.contains("No shareable files found"),
523                "jail escape via {evil}: {out}"
524            );
525        }
526        assert!(
527            !shared_json(root).contains("TOP-SECRET"),
528            "outside content leaked into share store"
529        );
530    }
531
532    #[test]
533    fn share_store_is_isolated_per_workspace_root() {
534        // Two workspaces on the same host (team server) must not see each
535        // other's shares — the store is keyed by the workspace root.
536        let _lock = crate::core::data_dir::test_env_lock();
537        let data = tempfile::tempdir().unwrap();
538        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
539
540        let ws1 = tempfile::tempdir().unwrap();
541        let ws2 = tempfile::tempdir().unwrap();
542        let root1 = ws1.path().to_str().unwrap();
543        let root2 = ws2.path().to_str().unwrap();
544        std::fs::write(ws1.path().join("a.md"), "WS1-ONLY\n").unwrap();
545
546        let cache = SessionCache::new();
547        let out = handle_push(Some("team:t1"), None, Some("a.md"), None, &cache, root1);
548        assert!(out.contains("Shared 1 files"), "push: {out}");
549
550        // Same host, other workspace: nothing visible.
551        let other = handle_pull(Some("team:t2"), root2);
552        assert!(
553            other.contains("No shared contexts"),
554            "workspace isolation broken: {other}"
555        );
556        // Same workspace: visible.
557        let same = handle_pull(Some("team:t2"), root1);
558        assert!(same.contains("a.md"), "same-workspace pull: {same}");
559    }
560
561    #[test]
562    fn share_filename_is_ntfs_safe_for_org_agent_ids() {
563        // `team:alice` in the filename made NTFS treat `:` as an Alternate
564        // Data Stream — the share file never appeared in read_dir and the
565        // handover was silently unpullable on Windows. The slug keeps the
566        // filename portable; the true agent id lives in the JSON payload.
567        assert_eq!(sanitize_for_filename("team:alice"), "team-alice");
568        assert_eq!(
569            sanitize_for_filename("a/b\\c*d?e\"f<g>h|i"),
570            "a-b-c-d-e-f-g-h-i"
571        );
572        assert_eq!(sanitize_for_filename("agent_A.1-x"), "agent_A.1-x");
573    }
574
575    #[test]
576    fn push_falls_back_to_last_known_when_file_deleted() {
577        // Stale + unreadable (deleted between cache and handover): the last-known
578        // cached copy is shared rather than dropping the file silently.
579        let _lock = crate::core::data_dir::test_env_lock();
580        let data = tempfile::tempdir().unwrap();
581        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
582
583        let proj = tempfile::tempdir().unwrap();
584        // Canonicalize so the cache key stays stable after the file is removed.
585        let canon = proj.path().canonicalize().unwrap();
586        let root = canon.to_str().unwrap();
587        let file = canon.join("gone.md");
588        std::fs::write(&file, "LASTKNOWN-AAA\n").unwrap();
589        let path = file.to_str().unwrap();
590
591        let mut cache = SessionCache::new();
592        cache.store(path, "LASTKNOWN-AAA\n");
593        std::fs::remove_file(&file).unwrap();
594
595        let out = handle_push(Some("a"), Some("b"), Some(path), None, &cache, root);
596        assert!(out.contains("Shared 1 files"), "push result: {out}");
597        assert!(
598            shared_json(root).contains("LASTKNOWN-AAA"),
599            "last-known content not shared"
600        );
601    }
602}