Skip to main content

ocserve_tools/
lib.rs

1//! ocserve-tools: v1 tool names + permission evaluation (PLAN §11: v1 names
2//! kept; no v2 renames). Wildcard/evaluate ported from v1 util/wildcard.ts
3//! and core/permission.ts; tool params verified against v1 sources + live
4//! tool-part capture (testdata/m2).
5#![deny(unsafe_code)]
6
7use anyhow::{Context, Result, bail};
8pub mod shell_prompt;
9
10use serde_json::{Value, json};
11use std::path::{Path, PathBuf};
12
13pub mod bash_patterns;
14pub mod permission_asks;
15
16/// v1 Wildcard.match: glob → regex, `\`→`/`, trailing " *" tail optional.
17pub fn wildcard_match(s: &str, pattern: &str) -> bool {
18    let s = s.replace('\\', "/");
19    let pattern = pattern.replace('\\', "/");
20    let mut escaped = String::with_capacity(pattern.len() * 2);
21    for c in pattern.chars() {
22        match c {
23            '.' | '+' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\' => {
24                escaped.push('\\');
25                escaped.push(c);
26            }
27            '*' => escaped.push_str(".*"),
28            '?' => escaped.push('.'),
29            other => escaped.push(other),
30        }
31    }
32    // v1 quirk: trailing " *" (space+wildcard) → optional tail ("ls *" ≈ "ls")
33    if escaped.ends_with(" .*") {
34        escaped.truncate(escaped.len() - 3);
35        escaped.push_str("( .*)?");
36    }
37    let re = format!("^{escaped}$");
38    glob_to_regex_match(&s, &re)
39}
40
41/// Minimal glob-regex matcher (no regex crate — patterns are simple globs
42/// already converted above). Compiles the escaped pattern manually.
43fn glob_to_regex_match(s: &str, re_like: &str) -> bool {
44    // re_like is ^...$ with .* / . / \( .*)? and escaped literals.
45    regex_match(s.as_bytes(), re_like.as_bytes())
46}
47
48/// Tiny backtracking matcher supporting: ^ $ . .* . and ( .*)? group.
49fn regex_match(s: &[u8], p: &[u8]) -> bool {
50    fn helper(s: &[u8], p: &[u8]) -> bool {
51        if p.is_empty() {
52            return s.is_empty();
53        }
54        match p[0] {
55            b'^' => helper(s, &p[1..]),
56            b'$' => p.len() == 1 && s.is_empty(),
57            // ".*" (dot THEN star): any-length run with backtracking
58            b'.' if p.len() > 1 && p[1] == b'*' => {
59                for i in 0..=s.len() {
60                    if helper(&s[i..], &p[2..]) {
61                        return true;
62                    }
63                }
64                false
65            }
66            b'.' => !s.is_empty() && helper(&s[1..], &p[1..]),
67            b'(' if p.starts_with(b"( .*)?") => {
68                // optional " .*" group: either consume up to next ')' match or skip
69                let rest = &p[b"( .*)?".len()..];
70                // skip: group matches empty
71                if helper(s, rest) {
72                    return true;
73                }
74                // consume: any prefix (group = " .*" ≈ " <anything>")
75                if s.is_empty() {
76                    return false;
77                }
78                // must include at least the space per " .*"
79                if s[0] != b' ' {
80                    return false;
81                }
82                for i in 1..=s.len() {
83                    if helper(&s[i..], rest) {
84                        return true;
85                    }
86                }
87                false
88            }
89            b'\\' if p.len() > 1 => !s.is_empty() && s[0] == p[1] && helper(&s[1..], &p[2..]),
90            lit => !s.is_empty() && s[0] == lit && helper(&s[1..], &p[1..]),
91        }
92    }
93    // strip trailing lone $ handled in helper; strip leading ^ once
94    helper(s, p)
95}
96
97/// v1 permission evaluation: find LAST rule matching (permission, resource);
98/// default = ask (core/permission.ts evaluate()).
99#[derive(Clone, Debug)]
100pub struct Rule {
101    pub permission: String,
102    pub pattern: String,
103    pub action: String,
104}
105
106pub fn evaluate(permission: &str, resource: &str, rules: &[Rule]) -> String {
107    rules
108        .iter()
109        .rev()
110        .find(|r| wildcard_match(permission, &r.permission) && wildcard_match(resource, &r.pattern))
111        .map(|r| r.action.clone())
112        .unwrap_or_else(|| "ask".to_string())
113}
114
115/// Truncation (v1 shell: MAX_LINES/MAX_BYTES constants).
116pub const MAX_LINES: usize = 2000;
117pub const MAX_BYTES: usize = 50 * 1024;
118
119pub struct ToolResult {
120    pub output: String,
121    pub truncated: bool,
122    pub exit: Option<i32>,
123    pub title: String,
124    pub error: bool,
125    /// Custom part-state metadata (todowrite → {todos}); None → bash-style
126    /// {output, exit, truncated} template in the runner.
127    pub metadata: Option<serde_json::Value>,
128}
129
130/// todowrite: validate the list (persistence happens in the runner, which
131/// owns the writer + event bus; v1 todowrite also runs permission.assert —
132/// our loop-level gate covers that before execute).
133fn todowrite(input: &Value) -> Result<ToolResult> {
134    let todos = input
135        .get("todos")
136        .and_then(|t| t.as_array())
137        .ok_or_else(|| anyhow::anyhow!("todowrite.todos must be an array"))?;
138    let mut normalized = Vec::with_capacity(todos.len());
139    for t in todos {
140        let content = t
141            .get("content")
142            .and_then(|c| c.as_str())
143            .ok_or_else(|| anyhow::anyhow!("todo.content required"))?;
144        let status = t
145            .get("status")
146            .and_then(|x| x.as_str())
147            .unwrap_or("pending");
148        let priority = t.get("priority").and_then(|x| x.as_str()).unwrap_or("");
149        normalized.push(json!({
150            "content": content,
151            "status": status,
152            "priority": priority,
153        }));
154    }
155    let output = serde_json::to_string_pretty(&json!({"todos": normalized}))?;
156    Ok(ToolResult {
157        output,
158        truncated: false,
159        exit: None,
160        title: "todowrite".into(),
161        error: false,
162        metadata: Some(json!({"todos": normalized})),
163    })
164}
165
166/// Tools ocserve executes. Advertised tool list = the intersection of these
167/// names and the frozen provider-tools.json (D-TOOLS-TASK: `task` is advertised
168/// upstream but not yet executed here, so it is excluded until implemented —
169/// see bench/openapi/FIELD-CONTRACT.md).
170pub const IMPLEMENTED: &[&str] = &[
171    "bash",
172    "edit",
173    "glob",
174    "grep",
175    "question",
176    "read",
177    "skill",
178    "todowrite",
179    "webfetch",
180    "write",
181];
182
183/// Frozen provider tool schemas, captured verbatim from the live 1.18.31
184/// provider request (bench/capdiff/frozen/TOOLS.json →
185/// scripts/capture-provider-contract.sh). Never hand-edit: a version bump
186/// re-captures this file. Descriptions/schemas must be byte-identical to what
187/// freeze sends or a model trained on opencode behaves differently
188/// (K-CAPDIFF — capdiff diffs both arms' real provider requests).
189fn frozen_tools() -> Vec<Value> {
190    let raw: Value = serde_json::from_str(include_str!("../assets/provider-tools.json"))
191        .expect("vendored provider-tools.json is valid");
192    raw.as_array().cloned().unwrap_or_default()
193}
194
195/// Provider tool schemas, filtered to IMPLEMENTED (deny filtering happens in
196/// the caller). Order matches the frozen capture.
197pub fn schemas() -> Vec<Value> {
198    let (shell, tmp) = host_shell_and_tmp();
199    frozen_tools()
200        .into_iter()
201        .filter(|t| {
202            t.pointer("/function/name")
203                .and_then(|n| n.as_str())
204                .map(|n| IMPLEMENTED.contains(&n))
205                .unwrap_or(false)
206        })
207        .map(|mut t| {
208            if t.pointer("/function/name").and_then(|n| n.as_str()) == Some("bash") {
209                // the frozen capture baked THIS host's `${os}/${shell}/${tmp}`;
210                // re-render for the running host (K-CAPDIFF)
211                let desc = shell_prompt::bash_description(&shell, &tmp);
212                t["function"]["description"] = Value::String(desc);
213            }
214            t
215        })
216        .collect()
217}
218
219/// Host shell basename (upstream `Shell.name`) + model temp dir
220/// (upstream `Global.Path.tmp`, which is `<tmp>/opencode`).
221fn host_shell_and_tmp() -> (String, String) {
222    let shell = std::env::var("SHELL")
223        .ok()
224        .filter(|s| !s.is_empty())
225        .and_then(|s| {
226            std::path::Path::new(&s)
227                .file_name()
228                .map(|f| f.to_string_lossy().into_owned())
229        })
230        .unwrap_or_else(|| "sh".to_string());
231    let tmp = std::env::temp_dir()
232        .join("opencode")
233        .to_string_lossy()
234        .into_owned();
235    (shell, tmp)
236}
237
238/// v1 tool/question.txt — model-facing contract for the question tool
239/// (verbatim: prompt_loop routes `question` to the QuestionGate, never to
240/// `execute()`).
241pub const QUESTION_DESCRIPTION: &str = "Use this tool when you need to ask the user questions during execution. This allows you to:
2421. Gather user preferences or requirements
2432. Clarify ambiguous instructions
2443. Get decisions on implementation choices as you work
2454. Offer choices to the user about what direction to take.
246
247Usage notes:
248- When `custom` is enabled (default), a \"Type your own answer\" option is added automatically; don't include \"Other\" or catch-all options
249- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
250- If you recommend a specific option, make that the first option in the list and add \"(Recommended)\" at the end of the label";
251
252/// Read up to `quota` bytes, then KEEP DRAINING (discarding) until EOF so
253/// the child never blocks on a full pipe. Memory bound = quota + slack.
254/// Both streams get MAX_BYTES: byte-parity with full-capture+head-truncate
255/// (the final truncate cuts the concatenated head to MAX_BYTES anyway).
256fn drain_limited<R: std::io::Read + Send + 'static>(reader: Option<R>, quota: usize) -> Vec<u8> {
257    let Some(mut r) = reader else {
258        return Vec::new();
259    };
260    let mut out: Vec<u8> = Vec::with_capacity(quota.min(64 * 1024));
261    let mut total = 0usize;
262    let mut buf = [0u8; 16 * 1024];
263    loop {
264        match r.read(&mut buf) {
265            Ok(0) => break,
266            Ok(n) => {
267                if total < quota {
268                    let take = n.min(quota - total);
269                    out.extend_from_slice(&buf[..take]);
270                    total += take;
271                }
272                // past quota: these bytes are discarded on the next read
273            }
274            Err(_) => break,
275        }
276    }
277    out
278}
279
280fn truncate(mut output: String) -> (String, bool) {
281    let mut truncated = false;
282    if output.len() > MAX_BYTES {
283        // `String::truncate` PANICS when the index is not a UTF-8 char
284        // boundary (the old "repair loop" after it was dead code — the panic
285        // fires inside truncate). Find the floor char boundary <= MAX_BYTES
286        // first, then truncate. release = panic=abort, so this was an outage.
287        let mut cut = MAX_BYTES;
288        while cut > 0 && !output.is_char_boundary(cut) {
289            cut -= 1;
290        }
291        output.truncate(cut);
292        truncated = true;
293    }
294    let lines = output.split('\n').count();
295    if lines > MAX_LINES {
296        let keep: String = output
297            .split('\n')
298            .take(MAX_LINES)
299            .collect::<Vec<_>>()
300            .join("\n");
301        output = keep;
302        truncated = true;
303    }
304    (output, truncated)
305}
306
307/// Execute a tool by v1 name. `cwd` scopes relative paths.
308pub fn execute(name: &str, input: &Value, cwd: &Path) -> Result<ToolResult> {
309    match name {
310        "bash" => bash(input, cwd),
311        "read" => read(input, cwd),
312        "write" => write(input, cwd),
313        "edit" => edit(input, cwd),
314        "glob" => glob(input, cwd),
315        "grep" => grep(input, cwd),
316        "todowrite" => todowrite(input),
317        "webfetch" => webfetch(input),
318        other => bail!("unknown tool: {other}"),
319    }
320}
321
322fn resolve(cwd: &Path, p: &str) -> PathBuf {
323    let path = Path::new(p);
324    if path.is_absolute() {
325        path.to_path_buf()
326    } else {
327        cwd.join(path)
328    }
329}
330
331#[allow(unsafe_code)] // sole production unsafe: libc::killpg on timeout (below)
332fn bash(input: &Value, cwd: &Path) -> Result<ToolResult> {
333    let command = input["command"].as_str().context("bash.command")?;
334    let timeout_ms = input["timeout"].as_u64().unwrap_or(120_000).min(600_000);
335    let mut cmd = std::process::Command::new("sh");
336    cmd.arg("-c")
337        .arg(command)
338        .current_dir(cwd)
339        .stdin(std::process::Stdio::null())
340        .stdout(std::process::Stdio::piped())
341        .stderr(std::process::Stdio::piped());
342    // Own process group per invocation (CI 2026-10-08 caught the bug this
343    // fixes: killing only `sh` left `sleep`/pipeline grandchildren alive,
344    // holding the pipe write-ends, so the bounded-drain joins blocked for the
345    // child's full natural duration — and in production a timed-out command
346    // kept running invisibly). With the child as group leader, the timeout
347    // kill sweeps the whole tree below.
348    #[cfg(unix)]
349    {
350        use std::os::unix::process::CommandExt;
351        cmd.process_group(0);
352    }
353    let mut child = cmd.spawn().with_context(|| format!("spawn: {command}"))?;
354    // K-EFFICIENCY: start the bounded drain BEFORE waiting — otherwise a
355    // >pipe-buffer child stalls until the timeout kill (and full-capture
356    // ratcheted unbounded bytes into RSS — the OOM chain).
357    let so = child.stdout.take();
358    let se = child.stderr.take();
359    // +1 past MAX_BYTES so the post-concat head-truncate still fires and
360    // sets `truncated` exactly like full-capture did
361    let h_out = std::thread::spawn(move || drain_limited(so, MAX_BYTES + 1));
362    let h_err = std::thread::spawn(move || drain_limited(se, MAX_BYTES + 1));
363    // bounded wait (AGENTS §2.3): poll to deadline, kill on overrun
364    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
365    let mut timed_out = false;
366    let mut exit_status: Option<std::process::ExitStatus> = None;
367    loop {
368        match child.try_wait() {
369            Ok(Some(st)) => {
370                exit_status = Some(st);
371                break;
372            }
373            Ok(None) => {
374                if std::time::Instant::now() >= deadline {
375                    timed_out = true;
376                    let _ = child.kill();
377                    #[cfg(unix)]
378                    {
379                        // Safety: `killpg` takes no pointers and is
380                        // async-signal-safe. The pid IS the pgid: the spawn
381                        // above made the child its own group leader
382                        // (`process_group(0)`), so this signals exactly the
383                        // command tree we started and nothing else.
384                        unsafe {
385                            libc::killpg(child.id() as i32, libc::SIGKILL);
386                        }
387                    }
388                    let _ = child.wait();
389                    break;
390                }
391                std::thread::sleep(std::time::Duration::from_millis(25));
392            }
393            Err(e) => bail!("wait: {e}"),
394        }
395    }
396    let stdout_bytes = h_out.join().unwrap_or_default();
397    let stderr_bytes = h_err.join().unwrap_or_default();
398    let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
399    let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
400    if timed_out {
401        if !stdout.ends_with('\n') && !stdout.is_empty() {
402            stdout.push('\n');
403        }
404        stdout.push_str(&format!("[timeout after {timeout_ms}ms]"));
405    }
406    if !stderr.is_empty() {
407        if !stdout.is_empty() && !stdout.ends_with('\n') {
408            stdout.push('\n');
409        }
410        stdout.push_str(&stderr);
411    }
412    let (output, truncated) = truncate(stdout);
413    let code = match exit_status {
414        Some(st) => st.code().unwrap_or(-1),
415        None => 124, // killed on timeout
416    };
417    Ok(ToolResult {
418        output,
419        truncated,
420        exit: Some(code),
421        title: shell_title(command),
422        error: timed_out || code != 0,
423        metadata: None,
424    })
425}
426
427/// v1 bash title: first command's display name (capture: title == command).
428fn shell_title(command: &str) -> String {
429    command.lines().next().unwrap_or(command).to_string()
430}
431
432fn read(input: &Value, cwd: &Path) -> Result<ToolResult> {
433    let fp = input["filePath"].as_str().context("read.filePath")?;
434    let path = resolve(cwd, fp);
435    let content =
436        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
437    let offset = input["offset"].as_u64().unwrap_or(0) as usize;
438    let limit = input["limit"].as_u64().map(|l| l as usize);
439    let lines: Vec<&str> = content.split('\n').collect();
440    let slice = if offset >= lines.len() {
441        ""
442    } else {
443        let end = match limit {
444            // saturating: offset/limit come from the model as u64; a large
445            // offset near usize::MAX would overflow `offset + l` (debug panic /
446            // release wrap-to-huge then `.min(len)` — harmless but UB-adjacent).
447            Some(l) => offset.saturating_add(l).min(lines.len()),
448            None => lines.len(),
449        };
450        &lines[offset..end].join("\n")
451    };
452    let (output, truncated) = truncate(slice.to_string());
453    Ok(ToolResult {
454        output,
455        truncated,
456        exit: None,
457        title: fp.to_string(),
458        error: false,
459        metadata: None,
460    })
461}
462
463fn write(input: &Value, cwd: &Path) -> Result<ToolResult> {
464    let fp = input["filePath"].as_str().context("write.filePath")?;
465    let content = input["content"].as_str().context("write.content")?;
466    let path = resolve(cwd, fp);
467    if let Some(parent) = path.parent() {
468        std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
469    }
470    std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
471    Ok(ToolResult {
472        output: format!("Wrote {} bytes to {}", content.len(), fp),
473        truncated: false,
474        exit: None,
475        title: fp.to_string(),
476        error: false,
477        metadata: None,
478    })
479}
480
481fn edit(input: &Value, cwd: &Path) -> Result<ToolResult> {
482    let fp = input["filePath"].as_str().context("edit.filePath")?;
483    // v1 parity (K-CAPDIFF): the model-facing param names are oldString /
484    // newString / replaceAll, NOT oldText / newText. A model trained on
485    // opencode emits the upstream names, so the old schema silently failed
486    // every edit. Accept the upstream names; keep oldText/newText as
487    // tolerated aliases (forward-compat, older ocserve clients).
488    let old = input["oldString"]
489        .as_str()
490        .or_else(|| input["oldText"].as_str())
491        .context("edit.oldString")?;
492    let new = input["newString"]
493        .as_str()
494        .or_else(|| input["newText"].as_str())
495        .context("edit.newString")?;
496    let replace_all = input["replaceAll"].as_bool().unwrap_or(false);
497    let path = resolve(cwd, fp);
498    let content =
499        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
500    let hits = content.matches(old).count();
501    if hits == 0 {
502        bail!("oldString not found in {fp}");
503    }
504    if hits > 1 && !replace_all {
505        bail!("oldString matches {hits} times in {fp}; must be unique (or set replaceAll)");
506    }
507    let updated = if replace_all {
508        content.replace(old, new)
509    } else {
510        content.replacen(old, new, 1)
511    };
512    std::fs::write(&path, &updated).with_context(|| format!("write {}", path.display()))?;
513    Ok(ToolResult {
514        output: format!("Edited {fp}"),
515        truncated: false,
516        exit: None,
517        title: fp.to_string(),
518        error: false,
519        metadata: None,
520    })
521}
522
523fn glob(input: &Value, cwd: &Path) -> Result<ToolResult> {
524    let pattern = input["pattern"].as_str().context("glob.pattern")?;
525    let base = match input["path"].as_str() {
526        Some(p) => resolve(cwd, p),
527        None => cwd.to_path_buf(),
528    };
529    let mut matches = Vec::new();
530    walk(&base, &base, pattern, &mut matches, 0)?;
531    matches.sort();
532    let joined = matches.join("\n");
533    let (output, truncated) = truncate(joined);
534    Ok(ToolResult {
535        output,
536        truncated,
537        exit: None,
538        title: pattern.to_string(),
539        error: false,
540        metadata: None,
541    })
542}
543
544fn walk(base: &Path, dir: &Path, pattern: &str, out: &mut Vec<String>, depth: usize) -> Result<()> {
545    if depth > 32 || out.len() > 10_000 {
546        return Ok(());
547    }
548    let entries = match std::fs::read_dir(dir) {
549        Ok(e) => e,
550        Err(_) => return Ok(()),
551    };
552    for entry in entries.flatten() {
553        let path = entry.path();
554        let rel = path
555            .strip_prefix(base)
556            .unwrap_or(&path)
557            .to_string_lossy()
558            .to_string();
559        if path.is_dir() {
560            if entry.file_name() == ".git" {
561                continue;
562            }
563            walk(base, &path, pattern, out, depth + 1)?;
564        } else if wildcard_match(&rel, pattern) {
565            out.push(rel);
566        }
567    }
568    Ok(())
569}
570
571fn grep(input: &Value, cwd: &Path) -> Result<ToolResult> {
572    let pattern = input["pattern"].as_str().context("grep.pattern")?;
573    let base = match input["path"].as_str() {
574        Some(p) => resolve(cwd, p),
575        None => cwd.to_path_buf(),
576    };
577    let include = input["include"].as_str().map(String::from);
578    let re = grep_matcher(pattern)?;
579    let mut hits: Vec<String> = Vec::new();
580    grep_walk(&base, &base, &re, include.as_deref(), &mut hits, 0)?;
581    hits.sort();
582    let (output, truncated) = truncate(hits.join("\n"));
583    Ok(ToolResult {
584        output,
585        truncated,
586        exit: None,
587        title: pattern.to_string(),
588        error: false,
589        metadata: None,
590    })
591}
592
593/// webfetch: fetch a URL, return text/markdown/html (v1 tool/webfetch.txt).
594/// Bounded: response capped at MAX_BYTES; timeout default 30s (max 120, per
595/// the wire schema). markdown = a conservative HTML->text strip (upstream
596/// uses turndown; D-WEBFETCH-MD covers conversion differences — the fetch
597/// itself is parity).
598fn webfetch(input: &Value) -> Result<ToolResult> {
599    let url = input["url"].as_str().context("webfetch.url")?;
600    let format = input["format"].as_str().unwrap_or("markdown");
601    let timeout_s = input["timeout"].as_u64().unwrap_or(30).min(120);
602    // v1 webfetch.txt: HTTP URLs are automatically upgraded to HTTPS.
603    let url = match url.strip_prefix("http://") {
604        Some(rest) => format!("https://{rest}"),
605        None => url.to_string(),
606    };
607    let client = reqwest::blocking::Client::builder()
608        .timeout(std::time::Duration::from_secs(timeout_s))
609        .user_agent("ocserve/1.0")
610        .build()
611        .context("webfetch client")?;
612    let resp = client.get(&url).send().context("webfetch request")?;
613    let status = resp.status();
614    if !status.is_success() {
615        bail!("webfetch {url}: HTTP {status}");
616    }
617    let body = resp.text().context("webfetch body")?;
618    let out = match format {
619        "html" => body,
620        "text" | "markdown" => html_to_text(&body),
621        _ => html_to_text(&body),
622    };
623    let (output, truncated) = truncate(out);
624    Ok(ToolResult {
625        output,
626        truncated,
627        exit: None,
628        title: url,
629        error: false,
630        metadata: None,
631    })
632}
633
634/// Conservative HTML->text: drop script/style, strip tags, decode common
635/// entities, collapse blank lines.
636fn html_to_text(html: &str) -> String {
637    let no_script = strip_blocks(html, "script");
638    let no_style = strip_blocks(&no_script, "style");
639    let mut out = String::with_capacity(no_style.len());
640    let mut in_tag = false;
641    for c in no_style.chars() {
642        match c {
643            '<' => in_tag = true,
644            '>' => {
645                in_tag = false;
646                out.push(' ');
647            }
648            _ if !in_tag => out.push(c),
649            _ => {}
650        }
651    }
652    collapse_lines(&decode_entities(&out))
653}
654
655fn strip_blocks(html: &str, tag: &str) -> String {
656    let open = format!("<{tag}");
657    let close = format!("</{tag}>");
658    let mut out = String::with_capacity(html.len());
659    let mut rest = html;
660    loop {
661        match rest.find(&open) {
662            Some(i) => {
663                out.push_str(&rest[..i]);
664                match rest[i..].find(&close) {
665                    Some(j) => rest = &rest[i + j + close.len()..],
666                    None => break,
667                }
668            }
669            None => {
670                out.push_str(rest);
671                break;
672            }
673        }
674    }
675    out
676}
677
678fn decode_entities(s: &str) -> String {
679    s.replace("&amp;", "&")
680        .replace("&lt;", "<")
681        .replace("&gt;", ">")
682        .replace("&quot;", "\"")
683        .replace("&#39;", "'")
684        .replace("&nbsp;", " ")
685}
686
687fn collapse_lines(s: &str) -> String {
688    let mut out = String::with_capacity(s.len());
689    let mut blank = 0;
690    for line in s.lines() {
691        let t = line.trim();
692        if t.is_empty() {
693            blank += 1;
694            if blank > 1 {
695                continue;
696            }
697        } else {
698            blank = 0;
699        }
700        out.push_str(t);
701        out.push('\n');
702    }
703    out.trim_end().to_string()
704}
705
706/// regex via the `grep` binary (v1 parity: ripgrep semantics without the dep;
707/// spawn cost acceptable — M5 benches decide in-process alternative).
708fn grep_matcher(pattern: &str) -> Result<String> {
709    Ok(pattern.to_string())
710}
711
712fn grep_walk(
713    base: &Path,
714    dir: &Path,
715    pattern: &str,
716    include: Option<&str>,
717    out: &mut Vec<String>,
718    depth: usize,
719) -> Result<()> {
720    if depth > 32 || out.len() > 10_000 {
721        return Ok(());
722    }
723    let entries = match std::fs::read_dir(dir) {
724        Ok(e) => e,
725        Err(_) => return Ok(()),
726    };
727    for entry in entries.flatten() {
728        let path = entry.path();
729        if path.is_dir() {
730            if entry.file_name() == ".git" {
731                continue;
732            }
733            grep_walk(base, &path, pattern, include, out, depth + 1)?;
734            continue;
735        }
736        let rel = path
737            .strip_prefix(base)
738            .unwrap_or(&path)
739            .to_string_lossy()
740            .to_string();
741        if let Some(inc) = include
742            && !wildcard_match(&rel, inc)
743        {
744            continue;
745        }
746        let Ok(content) = std::fs::read_to_string(&path) else {
747            continue;
748        };
749        for (i, line) in content.split('\n').enumerate() {
750            if regex_search_line(line, pattern) {
751                out.push(format!("{}:{}:{}", rel, i + 1, line));
752                if out.len() > 10_000 {
753                    return Ok(());
754                }
755            }
756        }
757    }
758    Ok(())
759}
760
761/// Substring/regex-lite search: full regex via `grep` binary when available,
762/// fallback to substring. Kept honest: no fake regex.
763fn regex_search_line(line: &str, pattern: &str) -> bool {
764    use std::process::Command;
765    let out = Command::new("grep")
766        .arg("-E")
767        .arg("-q")
768        .arg(pattern)
769        .arg("-")
770        .stdin(std::process::Stdio::piped())
771        .stdout(std::process::Stdio::null())
772        .spawn();
773    match out {
774        Ok(mut child) => {
775            use std::io::Write;
776            if let Some(stdin) = child.stdin.as_mut() {
777                let _ = stdin.write_all(line.as_bytes());
778                let _ = stdin.flush();
779            }
780            drop(child.stdin.take());
781            child.wait().map(|s| s.success()).unwrap_or(false)
782        }
783        Err(_) => line.contains(pattern),
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    #[test]
792    fn wildcard_quirks_ported() {
793        // v1: trailing " *" optional tail
794        assert!(wildcard_match("ls", "ls *"));
795        assert!(wildcard_match("ls -la", "ls *"));
796        // basic globs
797        assert!(wildcard_match("*.env", "*.env"));
798        assert!(wildcard_match(".env", "*.env"));
799        assert!(wildcard_match("a/b.txt", "a/*.txt"));
800        assert!(!wildcard_match("a/b.txt", "a/*.md"));
801        // escape regex specials literally
802        assert!(wildcard_match("file(1).txt", "file(1).txt"));
803        // ? single char
804        assert!(wildcard_match("abc", "a?c"));
805        assert!(!wildcard_match("abbc", "a?c"));
806    }
807
808    #[test]
809    fn evaluate_last_match_wins_and_default_ask() {
810        let rules = vec![
811            Rule {
812                permission: "*".into(),
813                pattern: "*".into(),
814                action: "allow".into(),
815            },
816            Rule {
817                permission: "edit".into(),
818                pattern: "*.env".into(),
819                action: "deny".into(),
820            },
821        ];
822        assert_eq!(evaluate("bash", "rm -rf /", &rules), "allow");
823        assert_eq!(evaluate("edit", "/x/.env", &rules), "deny");
824        // no matching rule → ask
825        assert_eq!(evaluate("question", "x", &[]), "ask");
826    }
827
828    #[test]
829    fn bash_echo() {
830        let r = execute("bash", &json!({"command": "echo TOOL_OK"}), Path::new("/")).unwrap();
831        assert_eq!(r.output.trim(), "TOOL_OK");
832        assert_eq!(r.exit, Some(0));
833        assert!(!r.error);
834        assert_eq!(r.title, "echo TOOL_OK");
835    }
836
837    #[test]
838    fn bash_exit_code_and_stderr_merge() {
839        let r = execute(
840            "bash",
841            &json!({"command": "echo OOPS >&2; exit 3"}),
842            Path::new("/"),
843        )
844        .unwrap();
845        assert_eq!(r.exit, Some(3));
846        assert!(r.error);
847        assert!(r.output.contains("OOPS"));
848    }
849
850    #[test]
851    fn bash_timeout_enforced() {
852        let t0 = std::time::Instant::now();
853        let r = execute(
854            "bash",
855            &json!({"command": "sleep 10", "timeout": 300}),
856            Path::new("/"),
857        )
858        .unwrap();
859        assert_eq!(r.exit, Some(124));
860        assert!(r.error);
861        assert!(t0.elapsed().as_secs() < 5, "timeout must bound the wait");
862    }
863
864    #[test]
865    fn todowrite_normalizes_and_reports() {
866        let r = execute(
867            "todowrite",
868            &json!({"todos": [
869                {"content": "step one", "status": "in_progress"},
870                {"content": "step two", "status": "pending", "priority": "high"}
871            ]}),
872            Path::new("/"),
873        )
874        .unwrap();
875        let meta = r.metadata.expect("todos metadata for the part state");
876        assert_eq!(meta["todos"][0]["status"], "in_progress");
877        assert_eq!(meta["todos"][1]["priority"], "high");
878        assert_eq!(meta["todos"][0]["priority"], "", "priority defaults empty");
879        assert!(r.output.contains("step one"));
880        assert_eq!(r.title, "todowrite");
881        assert!(!r.error);
882        // missing todos → Err (model gets a tool failure, not a panic)
883        assert!(execute("todowrite", &json!({"nope": []}), Path::new("/")).is_err());
884    }
885
886    #[test]
887    fn truncation_bounds() {
888        let big = "x".repeat(MAX_BYTES + 100);
889        let (out, t) = truncate(big);
890        assert!(t);
891        assert!(out.len() <= MAX_BYTES);
892        let many = "\n".repeat(MAX_LINES + 50);
893        let (out2, t2) = truncate(many);
894        assert!(t2);
895        assert!(out2.split('\n').count() <= MAX_LINES + 1);
896    }
897
898    /// 2026-10-11 bug hunt: the old `truncate` called `String::truncate(MAX_BYTES)`
899    /// directly, which PANICS when MAX_BYTES is not a UTF-8 char boundary (the
900    /// "repair loop" after it was dead code). A file of ASCII + a multibyte char
901    /// straddling the cutoff aborted the process (panic=abort). Must now floor to
902    /// a boundary and never panic.
903    #[test]
904    fn truncate_never_panics_on_multibyte_boundary() {
905        // MAX_BYTES ASCII bytes then a 3-byte char whose boundary straddles the cap
906        let mut s = "a".repeat(MAX_BYTES - 1);
907        s.push('€'); // 3 bytes: bytes MAX_BYTES-1, MAX_BYTES, MAX_BYTES+1
908        s.push_str(&"b".repeat(100));
909        let (out, t) = truncate(s);
910        assert!(t);
911        assert!(out.len() <= MAX_BYTES);
912        assert!(out.is_char_boundary(out.len()));
913    }
914
915    #[test]
916    fn read_write_edit_roundtrip() {
917        let d = tempfile::tempdir().unwrap();
918        let fp = d.path().join("f.txt");
919        execute(
920            "write",
921            &json!({"filePath": fp.to_str().unwrap(), "content": "hello world"}),
922            d.path(),
923        )
924        .unwrap();
925        let r = execute("read", &json!({"filePath": fp.to_str().unwrap()}), d.path()).unwrap();
926        assert_eq!(r.output, "hello world");
927        execute(
928            "edit",
929            &json!({"filePath": fp.to_str().unwrap(), "oldText": "world", "newText": "ocserve"}),
930            d.path(),
931        )
932        .unwrap();
933        let r2 = execute("read", &json!({"filePath": fp.to_str().unwrap()}), d.path()).unwrap();
934        assert_eq!(r2.output, "hello ocserve");
935        // duplicate match must fail
936        let e = execute(
937            "edit",
938            &json!({"filePath": fp.to_str().unwrap(), "oldText": "e", "newText": "E"}),
939            d.path(),
940        );
941        assert!(e.is_err(), "non-unique oldText must fail");
942    }
943
944    #[test]
945    fn glob_and_grep() {
946        let d = tempfile::tempdir().unwrap();
947        std::fs::write(d.path().join("a.rs"), "fn main() {}\n").unwrap();
948        std::fs::create_dir(d.path().join("sub")).unwrap();
949        std::fs::write(d.path().join("sub/b.rs"), "fn helper() {}\n").unwrap();
950        let g = execute("glob", &json!({"pattern": "*.rs"}), d.path()).unwrap();
951        assert!(
952            g.output.contains("a.rs") && g.output.contains("sub/b.rs"),
953            "{}",
954            g.output
955        );
956        let gr = execute("grep", &json!({"pattern": "helper"}), d.path()).unwrap();
957        assert!(gr.output.contains("sub/b.rs:1"), "{}", gr.output);
958        let gr2 = execute(
959            "grep",
960            &json!({"pattern": "main", "include": "*.rs"}),
961            d.path(),
962        )
963        .unwrap();
964        assert!(gr2.output.contains("a.rs"), "{}", gr2.output);
965    }
966
967    #[test]
968    fn schemas_are_frozen_and_filtered_to_implemented() {
969        // K-CAPDIFF: the provider tool schemas must be byte-identical to what
970        // freeze sends for the tools ocserve executes. Guard: every emitted
971        // schema equals the vendored frozen entry, and the emitted name set is
972        // exactly IMPLEMENTED ∩ frozen (so a new frozen tool is either
973        // implemented or explicitly excluded, never silently dropped).
974        let s = schemas();
975        let frozen: Vec<Value> =
976            serde_json::from_str(include_str!("../assets/provider-tools.json")).unwrap();
977        let frozen_by: std::collections::BTreeMap<String, &Value> = frozen
978            .iter()
979            .map(|t| (t["function"]["name"].as_str().unwrap().to_string(), t))
980            .collect();
981        let mut emitted: Vec<String> = Vec::new();
982        for spec in &s {
983            let name = spec["function"]["name"].as_str().unwrap().to_string();
984            let frozen = frozen_by.get(&name).unwrap();
985            // params must be byte-identical to the capture
986            assert_eq!(
987                &spec["function"]["parameters"], &frozen["function"]["parameters"],
988                "{name} params diverged from the frozen capture"
989            );
990            if name == "bash" {
991                // description is HOST-templated upstream — re-rendered for the
992                // running host, so assert the render (not the capture)
993                let (shell, tmp) = host_shell_and_tmp();
994                let want = shell_prompt::bash_description(&shell, &tmp);
995                assert_eq!(spec["function"]["description"], serde_json::json!(want));
996            } else {
997                assert_eq!(
998                    &spec["function"]["description"], &frozen["function"]["description"],
999                    "{name} description diverged from the frozen capture"
1000                );
1001            }
1002            emitted.push(name);
1003        }
1004        let mut want: Vec<String> = IMPLEMENTED
1005            .iter()
1006            .filter(|n| frozen_by.contains_key(**n))
1007            .map(|n| n.to_string())
1008            .collect();
1009        want.sort();
1010        emitted.sort();
1011        assert_eq!(emitted, want);
1012        // question: frozen required set + description carry the v1 contract
1013        let q = frozen_by.get("question").unwrap();
1014        let params = &q["function"]["parameters"];
1015        assert_eq!(params["required"][0], "questions");
1016        assert!(
1017            q["function"]["description"]
1018                .as_str()
1019                .unwrap()
1020                .contains("Type your own answer")
1021        );
1022    }
1023}
1024
1025#[cfg(test)]
1026mod drain_tests {
1027    use super::*;
1028    use std::path::Path;
1029
1030    /// The OOM/timeout double-bug: the old code read the pipes only AFTER
1031    /// the child exited, so any output beyond the pipe buffer (~64KB)
1032    /// stalled the child until the timeout killed it — AND capturing
1033    /// unbounded bytes first (truncate ran after read_to_end) ratcheted
1034    /// the transient spike into RSS (16:0x OOM kills, anon 520MB).
1035    /// Inherent negative control: on the old code this command NEVER
1036    /// completes (timeout-kill → exit 124 + "[timeout after...]").
1037    #[test]
1038    fn huge_output_completes_unblocked_and_stays_bounded() {
1039        let input = json!({
1040            "command": "head -c 2000000 /dev/zero | tr '\\0' 'a'",
1041            "timeout": 5000,
1042        });
1043        let r = bash(&input, Path::new("/tmp")).expect("run");
1044        assert_eq!(
1045            r.exit,
1046            Some(0),
1047            "child must complete — pipes drained concurrently (old code: stall → timeout kill)"
1048        );
1049        assert!(
1050            !r.output.contains("[timeout"),
1051            "no timeout marker: {:?}",
1052            &r.output[..80.min(r.output.len())]
1053        );
1054        assert!(r.truncated, "output capped");
1055        assert!(r.output.len() <= MAX_BYTES, "bounded: {}", r.output.len());
1056    }
1057
1058    /// small outputs unaffected (byte-parity path)
1059    #[test]
1060    fn small_output_untouched() {
1061        let r = bash(&json!({"command": "printf 'hello\n'"}), Path::new("/tmp")).unwrap();
1062        assert_eq!(r.exit, Some(0));
1063        assert_eq!(r.output.trim(), "hello");
1064        assert!(!r.truncated);
1065    }
1066}