1use std::collections::BTreeMap;
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::time::{Duration, Instant};
15
16use serde::Serialize;
17use serde_json::{json, Value};
18
19use crate::error::{Error, Result};
20
21pub const DEFAULT_ALLOW: &[&str] = &["hlock", "git log", "git show", "git diff", "git status"];
23pub const DEFAULT_MAX_STEPS: u32 = 60;
24pub const DEFAULT_TIMEOUT_SECONDS: u64 = 300;
25const MAX_RESULT: usize = 24_000;
27
28pub const PROVIDERS: &[(&str, &str, &str)] = &[
30 ("anthropic", "https://api.anthropic.com/v1", "ANTHROPIC_API_KEY"),
31 ("openai", "https://api.openai.com/v1", "OPENAI_API_KEY"),
32 ("google", "https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY"),
33 ("openrouter", "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"),
34];
35
36#[derive(Debug, Clone, Default)]
38pub struct AgentConfig {
39 pub provider: String,
41 pub model: String,
42 pub base_url: String,
43 pub api_key: Option<String>,
44 pub max_steps: u32,
45 pub timeout_seconds: u64,
46 pub allow: Vec<String>,
48 pub interactive: bool,
50 pub project: PathBuf,
51}
52
53pub fn provider_base_url(provider: &str) -> Option<&'static str> {
55 PROVIDERS.iter().find(|(p, _, _)| *p == provider).map(|(_, u, _)| *u)
56}
57
58pub fn provider_key_env(provider: &str) -> Option<&'static str> {
60 PROVIDERS.iter().find(|(p, _, _)| *p == provider).map(|(_, _, k)| *k)
61}
62
63pub type Say<'a> = dyn FnMut(&str, &str) -> bool + 'a;
67pub type Ask<'a> = dyn FnMut(&str) -> String + 'a;
69
70#[derive(Debug, Clone, Serialize, Default)]
71pub struct Outcome {
72 pub status: String,
74 pub steps: u32,
75 pub summary: String,
77 pub error: Option<String>,
78 pub input_tokens: u64,
79 pub output_tokens: u64,
80}
81
82#[derive(Debug, Clone)]
87struct Call {
88 id: String,
89 name: String,
90 args: Value,
91}
92
93#[derive(Debug, Clone)]
94enum Msg {
95 User(String),
96 Assistant { text: String, calls: Vec<Call> },
97 Results(Vec<(String, String)>),
98}
99
100fn tools() -> Vec<(&'static str, &'static str, Value)> {
101 let s = |props: Value, req: &[&str]| json!({"type": "object", "properties": props, "required": req});
102 vec![
103 ("list_dir", "List a directory's entries (directories end with /).", s(json!({"path": {"type": "string", "description": "Directory, relative to the project (default: the project)"}}), &[])),
104 ("read_file", "Read a text file, optionally a range of lines.", s(json!({
105 "path": {"type": "string"},
106 "offset": {"type": "integer", "description": "First line to read, from 1"},
107 "limit": {"type": "integer", "description": "How many lines to read"}
108 }), &["path"])),
109 ("search", "Find lines containing a string in the files under a directory.", s(json!({
110 "pattern": {"type": "string", "description": "Text to look for (case-sensitive)"},
111 "path": {"type": "string", "description": "Directory or file to search (default: the project)"}
112 }), &["pattern"])),
113 ("write_file", "Create or overwrite a file inside the project.", s(json!({"path": {"type": "string"}, "content": {"type": "string"}}), &["path", "content"])),
114 ("edit_file", "Replace one exact, unique occurrence of a string in a file inside the project.", s(json!({
115 "path": {"type": "string"}, "old": {"type": "string"}, "new": {"type": "string"}
116 }), &["path", "old", "new"])),
117 ("run", "Run a shell command in the project directory and return its exit code and output.", s(json!({"command": {"type": "string"}}), &["command"])),
118 ]
119}
120
121fn system_prompt(cfg: &AgentConfig) -> String {
122 format!(
123 "You are the built-in agent of hlock (harness-lock), working in the project at {}. \
124 You act through tools: read and search files, write and edit files inside the project, and run shell commands. \
125 Work step by step, check what you did, and keep going until the job is done. \
126 {} When you're finished, reply with a short summary of what you did and what's left, and call no tools.",
127 cfg.project.display(),
128 if cfg.interactive {
129 "Commands that aren't on the allowlist are shown to the user, who approves or refuses each one."
130 } else {
131 "This run is unattended: only allowlisted commands run, and others are refused; when you need one, say which and why in your summary."
132 }
133 )
134}
135
136struct Reply {
141 text: String,
142 calls: Vec<Call>,
143 input_tokens: u64,
144 output_tokens: u64,
145}
146
147fn http() -> ureq::Agent {
148 ureq::Agent::config_builder()
149 .http_status_as_error(false)
150 .timeout_global(Some(Duration::from_secs(600)))
151 .proxy(ureq::Proxy::try_from_env())
152 .build()
153 .into()
154}
155
156fn post(cfg: &AgentConfig, path: &str, headers: &[(&str, String)], body: &Value) -> Result<Value> {
157 let url = format!("{}/{path}", cfg.base_url.trim_end_matches('/'));
158 let mut req = http().post(&url).header("content-type", "application/json");
159 for (k, v) in headers {
160 req = req.header(*k, v);
161 }
162 let mut resp = req.send(body.to_string()).map_err(|e| Error::Profile(format!("cannot reach {url}: {e}")))?;
163 let status = resp.status().as_u16();
164 let text = resp.body_mut().read_to_string().map_err(|e| Error::Profile(format!("bad response from {url}: {e}")))?;
165 if !(200..300).contains(&status) {
166 let detail: String = serde_json::from_str::<Value>(&text)
167 .ok()
168 .and_then(|v| v.pointer("/error/message").and_then(Value::as_str).map(str::to_string))
169 .unwrap_or_else(|| text.chars().take(300).collect());
170 return Err(Error::Profile(format!("{url} returned {status}: {detail}")));
171 }
172 serde_json::from_str(&text).map_err(|e| Error::Profile(format!("{url} returned invalid JSON: {e}")))
173}
174
175fn ask_anthropic(cfg: &AgentConfig, history: &[Msg]) -> Result<Reply> {
176 let mut messages = Vec::new();
177 for m in history {
178 messages.push(match m {
179 Msg::User(t) => json!({"role": "user", "content": t}),
180 Msg::Assistant { text, calls } => {
181 let mut blocks = Vec::new();
182 if !text.is_empty() {
183 blocks.push(json!({"type": "text", "text": text}));
184 }
185 blocks.extend(calls.iter().map(|c| json!({"type": "tool_use", "id": c.id, "name": c.name, "input": c.args})));
186 json!({"role": "assistant", "content": blocks})
187 }
188 Msg::Results(rs) => json!({"role": "user", "content": rs.iter().map(|(id, out)| json!({"type": "tool_result", "tool_use_id": id, "content": out})).collect::<Vec<_>>()}),
189 });
190 }
191 let body = json!({
192 "model": cfg.model,
193 "max_tokens": 8192,
194 "system": system_prompt(cfg),
195 "tools": tools().into_iter().map(|(n, d, s)| json!({"name": n, "description": d, "input_schema": s})).collect::<Vec<_>>(),
196 "messages": messages,
197 });
198 let mut headers = vec![("anthropic-version", "2023-06-01".to_string())];
199 if let Some(k) = &cfg.api_key {
200 headers.push(("x-api-key", k.clone()));
201 }
202 let v = post(cfg, "messages", &headers, &body)?;
203 let mut text = String::new();
204 let mut calls = Vec::new();
205 for b in v["content"].as_array().into_iter().flatten() {
206 match b["type"].as_str() {
207 Some("text") => text.push_str(b["text"].as_str().unwrap_or_default()),
208 Some("tool_use") => calls.push(Call { id: b["id"].as_str().unwrap_or_default().into(), name: b["name"].as_str().unwrap_or_default().into(), args: b["input"].clone() }),
209 _ => {}
210 }
211 }
212 Ok(Reply { text, calls, input_tokens: v["usage"]["input_tokens"].as_u64().unwrap_or(0), output_tokens: v["usage"]["output_tokens"].as_u64().unwrap_or(0) })
213}
214
215fn ask_openai(cfg: &AgentConfig, history: &[Msg]) -> Result<Reply> {
216 let mut messages = vec![json!({"role": "system", "content": system_prompt(cfg)})];
217 for m in history {
218 match m {
219 Msg::User(t) => messages.push(json!({"role": "user", "content": t})),
220 Msg::Assistant { text, calls } => {
221 let mut msg = json!({"role": "assistant", "content": text});
222 if !calls.is_empty() {
223 msg["tool_calls"] = calls.iter().map(|c| json!({"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.args.to_string()}})).collect();
224 }
225 messages.push(msg);
226 }
227 Msg::Results(rs) => messages.extend(rs.iter().map(|(id, out)| json!({"role": "tool", "tool_call_id": id, "content": out}))),
228 }
229 }
230 let body = json!({
231 "model": cfg.model,
232 "messages": messages,
233 "tools": tools().into_iter().map(|(n, d, s)| json!({"type": "function", "function": {"name": n, "description": d, "parameters": s}})).collect::<Vec<_>>(),
234 });
235 let headers: Vec<(&str, String)> = cfg.api_key.iter().map(|k| ("authorization", format!("Bearer {k}"))).collect();
236 let v = post(cfg, "chat/completions", &headers, &body)?;
237 let msg = &v["choices"][0]["message"];
238 let calls = msg["tool_calls"]
239 .as_array()
240 .into_iter()
241 .flatten()
242 .enumerate()
243 .map(|(i, c)| {
244 let raw = &c["function"]["arguments"];
245 let args = match raw {
246 Value::String(s) => serde_json::from_str(s).unwrap_or_else(|_| json!({"_unparsed": s})),
247 other => other.clone(),
248 };
249 Call { id: c["id"].as_str().map(str::to_string).unwrap_or_else(|| format!("call_{i}")), name: c["function"]["name"].as_str().unwrap_or_default().into(), args }
250 })
251 .collect();
252 let mut text = msg["content"].as_str().unwrap_or_default().to_string();
253 let mut calls: Vec<Call> = calls;
254 if calls.is_empty() {
255 if let Some(c) = text_call(&text) {
256 calls.push(c);
257 text.clear();
258 }
259 }
260 Ok(Reply {
261 text,
262 calls,
263 input_tokens: v["usage"]["prompt_tokens"].as_u64().unwrap_or(0),
264 output_tokens: v["usage"]["completion_tokens"].as_u64().unwrap_or(0),
265 })
266}
267
268fn text_call(text: &str) -> Option<Call> {
271 let t = text.trim().trim_start_matches("```json").trim_start_matches("```").trim_end_matches("```").trim();
272 let v: Value = serde_json::from_str(t).ok()?;
273 let name = v.get("name")?.as_str()?;
274 if !tools().iter().any(|(n, _, _)| *n == name) {
275 return None;
276 }
277 let args = v.get("parameters").or_else(|| v.get("arguments")).cloned().unwrap_or_else(|| json!({}));
278 let args = match args {
279 Value::String(s) => serde_json::from_str(&s).ok()?,
280 other => other,
281 };
282 Some(Call { id: "text_call".into(), name: name.into(), args })
283}
284
285fn resolve(cfg: &AgentConfig, p: &str) -> PathBuf {
290 let p = p.trim();
291 if p.is_empty() || p == "." {
292 return cfg.project.clone();
293 }
294 if let Some(rest) = p.strip_prefix("~/") {
295 if let Some(home) = std::env::var_os("HOME") {
296 return PathBuf::from(home).join(rest);
297 }
298 }
299 let path = PathBuf::from(p);
300 if path.is_absolute() {
301 path
302 } else {
303 cfg.project.join(path)
304 }
305}
306
307fn normalize(p: &Path) -> PathBuf {
309 let mut out = PathBuf::new();
310 for c in p.components() {
311 match c {
312 std::path::Component::ParentDir => {
313 out.pop();
314 }
315 std::path::Component::CurDir => {}
316 other => out.push(other),
317 }
318 }
319 out
320}
321
322fn inside_project(cfg: &AgentConfig, p: &Path) -> bool {
323 normalize(p).starts_with(normalize(&cfg.project))
324}
325
326fn truncate(s: String) -> String {
327 if s.len() <= MAX_RESULT {
328 return s;
329 }
330 let mut cut = MAX_RESULT;
331 while !s.is_char_boundary(cut) {
332 cut -= 1;
333 }
334 format!("{}\n… ({} more bytes not shown)", &s[..cut], s.len() - cut)
335}
336
337const SKIP_DIRS: &[&str] = &[".git", "node_modules", "target", ".venv", "__pycache__", ".hlock"];
338
339fn search(dir: &Path, pattern: &str, out: &mut Vec<String>, base: &Path) {
340 if out.len() >= 200 {
341 return;
342 }
343 if dir.is_file() {
344 if let Ok(text) = std::fs::read_to_string(dir) {
345 for (i, line) in text.lines().enumerate().filter(|(_, l)| l.contains(pattern)) {
346 let rel = dir.strip_prefix(base).unwrap_or(dir).display();
347 out.push(format!("{rel}:{}: {}", i + 1, line.trim_end().chars().take(200).collect::<String>()));
348 if out.len() >= 200 {
349 return;
350 }
351 }
352 }
353 return;
354 }
355 let Ok(rd) = std::fs::read_dir(dir) else { return };
356 let mut entries: Vec<_> = rd.flatten().collect();
357 entries.sort_by_key(|e| e.file_name());
358 for e in entries {
359 let name = e.file_name().to_string_lossy().into_owned();
360 if SKIP_DIRS.contains(&name.as_str()) {
361 continue;
362 }
363 search(&e.path(), pattern, out, base);
364 }
365}
366
367pub fn command_words(cmd: &str) -> Option<Vec<String>> {
370 if cmd.chars().any(|c| matches!(c, ';' | '|' | '&' | '>' | '<' | '$' | '`' | '\n' | '(' | ')')) {
371 return None;
372 }
373 let mut words = Vec::new();
374 let mut cur = String::new();
375 let mut quote: Option<char> = None;
376 let mut any = false;
377 for c in cmd.chars() {
378 match (quote, c) {
379 (Some(q), c) if c == q => quote = None,
380 (Some(_), c) => cur.push(c),
381 (None, '\'' | '"') => {
382 quote = Some(c);
383 any = true;
384 }
385 (None, c) if c.is_whitespace() => {
386 if !cur.is_empty() || any {
387 words.push(std::mem::take(&mut cur));
388 any = false;
389 }
390 }
391 (None, c) => cur.push(c),
392 }
393 }
394 if quote.is_some() {
395 return None;
396 }
397 if !cur.is_empty() || any {
398 words.push(cur);
399 }
400 Some(words)
401}
402
403pub fn allowed(cmd: &str, allow: &[String]) -> bool {
406 let Some(words) = command_words(cmd) else { return false };
407 let Some(first) = words.first() else { return false };
408 let prog = Path::new(first).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
409 allow.iter().any(|a| {
410 let want: Vec<&str> = a.split_whitespace().collect();
411 !want.is_empty() && want.len() <= words.len() && want[0] == prog && want[1..].iter().zip(&words[1..]).all(|(w, x)| w == x)
412 })
413}
414
415fn always_prefix(cmd: &str) -> Option<String> {
417 let words = command_words(cmd)?;
418 let prog = Path::new(words.first()?).file_name()?.to_string_lossy().into_owned();
419 Some(match words.get(1) {
420 Some(sub) if !sub.starts_with('-') && !sub.contains('/') && !sub.contains('.') => format!("{prog} {sub}"),
421 _ => prog,
422 })
423}
424
425fn run_command(cfg: &AgentConfig, cmd: &str) -> String {
426 let mut c = if cfg!(windows) {
427 let mut c = Command::new("cmd");
428 c.args(["/C", cmd]);
429 c
430 } else {
431 let mut c = Command::new("sh");
432 c.args(["-c", cmd]);
433 c
434 };
435 c.current_dir(&cfg.project).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
436 let mut child = match c.spawn() {
437 Ok(ch) => ch,
438 Err(e) => return format!("cannot run it: {e}"),
439 };
440 let (mut so, mut se) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
441 let t_out = std::thread::spawn(move || {
442 let mut b = Vec::new();
443 let _ = so.read_to_end(&mut b);
444 b
445 });
446 let t_err = std::thread::spawn(move || {
447 let mut b = Vec::new();
448 let _ = se.read_to_end(&mut b);
449 b
450 });
451 let start = Instant::now();
452 let limit = Duration::from_secs(cfg.timeout_seconds.max(1));
453 let status = loop {
454 match child.try_wait() {
455 Ok(Some(s)) => break Some(s),
456 Ok(None) if start.elapsed() > limit => {
457 let _ = child.kill();
458 let _ = child.wait();
459 break None;
460 }
461 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
462 Err(_) => break None,
463 }
464 };
465 let out = String::from_utf8_lossy(&t_out.join().unwrap_or_default()).into_owned();
466 let err = String::from_utf8_lossy(&t_err.join().unwrap_or_default()).into_owned();
467 let head = match status {
468 Some(s) => format!("exit code {}", s.code().unwrap_or(-1)),
469 None => format!("timed out after {}s and was stopped", cfg.timeout_seconds),
470 };
471 truncate(format!("{head}\n{out}{}{err}", if err.is_empty() || out.ends_with('\n') || out.is_empty() { "" } else { "\n" }))
472}
473
474fn arg<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
475 args.get(key).and_then(Value::as_str)
476}
477
478fn call_tool(cfg: &AgentConfig, allow: &mut Vec<String>, call: &Call, say: &mut Say, ask: &mut Ask) -> (String, String) {
480 let a = &call.args;
481 let need = |k: &str| arg(a, k).ok_or_else(|| format!("missing `{k}`"));
482 let result: std::result::Result<(String, String), String> = (|| match call.name.as_str() {
483 "list_dir" => {
484 let dir = resolve(cfg, arg(a, "path").unwrap_or("."));
485 let mut names: Vec<String> = std::fs::read_dir(&dir)
486 .map_err(|e| format!("cannot list {}: {e}", dir.display()))?
487 .flatten()
488 .map(|e| format!("{}{}", e.file_name().to_string_lossy(), if e.path().is_dir() { "/" } else { "" }))
489 .collect();
490 names.sort();
491 let n = names.len();
492 names.truncate(500);
493 Ok((names.join("\n"), format!("{n} entries")))
494 }
495 "read_file" => {
496 let path = resolve(cfg, need("path")?);
497 let text = std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
498 let offset = a.get("offset").and_then(Value::as_u64).unwrap_or(1).max(1) as usize;
499 let limit = a.get("limit").and_then(Value::as_u64).filter(|n| *n > 0).unwrap_or(2000) as usize;
501 let lines: Vec<&str> = text.lines().skip(offset - 1).take(limit).collect();
502 Ok((truncate(lines.join("\n")), format!("{} lines", lines.len())))
503 }
504 "search" => {
505 let pattern = need("pattern")?;
506 let base = resolve(cfg, arg(a, "path").unwrap_or("."));
507 let mut hits = Vec::new();
508 search(&base, pattern, &mut hits, &cfg.project);
509 let n = hits.len();
510 Ok((if hits.is_empty() { "no matches".into() } else { hits.join("\n") }, format!("{n} matches")))
511 }
512 "write_file" | "edit_file" => {
513 let path = resolve(cfg, need("path")?);
514 if !inside_project(cfg, &path) {
515 return Err(format!("{} is outside the project; write files there with `run`, which the user approves", path.display()));
516 }
517 let body = if call.name == "write_file" {
518 need("content")?.to_string()
519 } else {
520 let (old, new) = (need("old")?, need("new")?);
521 let text = std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
522 match text.matches(old).count() {
523 1 => text.replacen(old, new, 1),
524 0 => return Err("`old` doesn't appear in the file".into()),
525 n => return Err(format!("`old` appears {n} times; include more context so it's unique")),
526 }
527 };
528 if let Some(parent) = path.parent() {
529 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
530 }
531 std::fs::write(&path, &body).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
532 let rel = path.strip_prefix(&cfg.project).unwrap_or(&path).display().to_string();
533 Ok((format!("wrote {rel} ({} bytes)", body.len()), format!("wrote {rel}")))
534 }
535 "run" => {
536 let cmd = need("command")?.trim().to_string();
537 if !allowed(&cmd, allow) {
538 if !cfg.interactive {
539 say("denied", &cmd);
540 return Err(format!(
541 "refused: `{cmd}` isn't on the allowlist ({}), and this run is unattended. Do without it, or say in your summary that the user should run it",
542 allow.join(", ")
543 ));
544 }
545 match ask(&cmd).trim().to_ascii_lowercase().as_str() {
546 "y" | "yes" => {}
547 "a" | "always" => {
548 if let Some(p) = always_prefix(&cmd) {
549 allow.push(p);
550 }
551 }
552 _ => {
553 say("denied", &cmd);
554 return Err(format!("the user refused `{cmd}`; don't run it again, and ask what they'd prefer"));
555 }
556 }
557 }
558 let out = run_command(cfg, &cmd);
559 let first = out.lines().next().unwrap_or_default().to_string();
560 Ok((out, first))
561 }
562 other => Err(format!("unknown tool {other:?}")),
563 })();
564 match result {
565 Ok((full, summary)) => (full, summary),
566 Err(e) => (format!("error: {e}"), format!("error: {e}")),
567 }
568}
569
570fn describe(call: &Call) -> String {
571 let a = &call.args;
572 match call.name.as_str() {
573 "run" => format!("$ {}", arg(a, "command").unwrap_or_default()),
574 "search" => format!("search {:?} in {}", arg(a, "pattern").unwrap_or_default(), arg(a, "path").unwrap_or(".")),
575 "list_dir" => format!("list {}", arg(a, "path").unwrap_or(".")),
576 name => format!("{} {}", name.replace('_', " "), arg(a, "path").unwrap_or_default()),
577 }
578}
579
580pub fn run(cfg: &AgentConfig, prompt: &str, say: &mut Say, ask: &mut Ask) -> Outcome {
586 let mut history = vec![Msg::User(prompt.to_string())];
587 let mut allow = cfg.allow.clone();
588 let mut out = Outcome { status: "max-steps".into(), ..Default::default() };
589 while out.steps < cfg.max_steps.max(1) {
590 out.steps += 1;
591 let reply = if cfg.provider == "anthropic" { ask_anthropic(cfg, &history) } else { ask_openai(cfg, &history) };
592 let reply = match reply {
593 Ok(r) => r,
594 Err(e) => {
595 let msg = e.to_string();
596 say("error", &msg);
597 out.status = "error".into();
598 out.error = Some(msg);
599 return out;
600 }
601 };
602 out.input_tokens += reply.input_tokens;
603 out.output_tokens += reply.output_tokens;
604 if !reply.text.trim().is_empty() && !say("text", reply.text.trim()) {
605 out.status = "stopped".into();
606 return out;
607 }
608 if reply.calls.is_empty() {
609 out.status = "done".into();
610 out.summary = reply.text.trim().to_string();
611 return out;
612 }
613 let mut results = Vec::new();
614 for c in &reply.calls {
615 if !say("tool", &describe(c)) {
616 out.status = "stopped".into();
617 return out;
618 }
619 let (full, summary) = call_tool(cfg, &mut allow, c, say, ask);
620 say("result", &summary);
621 results.push((c.id.clone(), full));
622 }
623 history.push(Msg::Assistant { text: reply.text, calls: reply.calls });
624 history.push(Msg::Results(results));
625 }
626 say("error", &format!("stopped after {} steps", out.steps));
627 out
628}
629
630pub fn config_from_lock(lock: &crate::lockfile::Lockfile, project: &Path, interactive: bool, env: &BTreeMap<String, String>) -> Result<AgentConfig> {
632 let llm = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first()).ok_or_else(|| {
633 Error::Lockfile("the meta lock has no model; set [model] in harness.meta.toml and run `hlock meta pin`".into())
634 })?;
635 let base_url = llm
636 .endpoint
637 .clone()
638 .or_else(|| provider_base_url(&llm.provider).map(str::to_string))
639 .ok_or_else(|| Error::Lockfile(format!("no endpoint for provider {:?}; set [model] base-url", llm.provider)))?;
640 let key_env = llm.params.get("key-env").and_then(Value::as_str).map(str::to_string).or_else(|| provider_key_env(&llm.provider).map(str::to_string));
641 let api_key = key_env.as_ref().and_then(|k| env.get(k)).filter(|v| !v.is_empty()).cloned();
642 if llm.runtime.is_none() && api_key.is_none() {
643 return Err(Error::Profile(format!("{} isn't set; the built-in agent reads its {} API key from it", key_env.unwrap_or_default(), llm.provider)));
644 }
645 Ok(AgentConfig {
646 provider: llm.provider.clone(),
647 model: llm.model.clone(),
648 base_url,
649 api_key,
650 max_steps: if lock.policy.max_steps > 0 { lock.policy.max_steps } else { DEFAULT_MAX_STEPS },
651 timeout_seconds: lock.policy.tool_timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS),
652 allow: if lock.policy.tool_allowlist.is_empty() { DEFAULT_ALLOW.iter().map(|s| s.to_string()).collect() } else { lock.policy.tool_allowlist.clone() },
653 interactive,
654 project: project.to_path_buf(),
655 })
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 fn allow(xs: &[&str]) -> Vec<String> {
663 xs.iter().map(|s| s.to_string()).collect()
664 }
665
666 #[test]
667 fn allowlist_matching() {
668 let a = allow(DEFAULT_ALLOW);
669 assert!(allowed("hlock meta verify install", &a));
670 assert!(allowed("git log -5 --oneline", &a));
671 assert!(allowed("/usr/bin/git show HEAD", &a));
672 assert!(!allowed("git push", &a));
673 assert!(!allowed("git log; rm -rf /", &a));
674 assert!(!allowed("hlock sync > out.txt", &a));
675 assert!(!allowed("git log $(whoami)", &a));
676 assert!(!allowed("rm -rf tasks", &a));
677 assert!(allowed("brew install pi", &allow(&["brew install"])));
678 assert!(!allowed("brew uninstall pi", &allow(&["brew install"])));
679 assert_eq!(command_words("git commit -m 'a b'").unwrap(), ["git", "commit", "-m", "a b"]);
680 assert_eq!(always_prefix("npm install -g x").as_deref(), Some("npm install"));
681 assert_eq!(always_prefix("ls -la").as_deref(), Some("ls"));
682 }
683
684 #[test]
685 fn tool_calls_written_as_text() {
686 let c = text_call("```json\n{\"name\": \"write_file\", \"parameters\": {\"path\": \"a.md\", \"content\": \"x\"}}\n```").unwrap();
687 assert_eq!((c.name.as_str(), c.args["path"].as_str()), ("write_file", Some("a.md")));
688 assert!(text_call("{\"name\": \"touch\", \"parameters\": {}}").is_none());
689 assert!(text_call("All done: wrote the notes.").is_none());
690 }
691
692 #[test]
693 fn writes_stay_in_the_project() {
694 let cfg = AgentConfig { project: PathBuf::from("/p/proj"), ..Default::default() };
695 assert!(inside_project(&cfg, &resolve(&cfg, "evals/pack.toml")));
696 assert!(!inside_project(&cfg, &resolve(&cfg, "../other/x")));
697 assert!(!inside_project(&cfg, &resolve(&cfg, "/etc/hosts")));
698 }
699}