Skip to content

Commit 2e62ceb

Browse files
Review fixes: install paths, spending overflow, approval prompt, session records
2 parents cfac2d5 + 2aedee9 commit 2e62ceb

11 files changed

Lines changed: 370 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ With `learning: online`, gitagent learns from each task it completes. The model
565565

566566
### Output, records and events
567567

568-
- **Records:** every run writes `trajectory.json` (the ATIF trajectory format), `counters.json` and the full output of every command (`out/<n>.log`) to `--out-dir`. They are updated after every turn.
568+
- **Records:** every run writes `trajectory.json` (the ATIF trajectory format), `counters.json` and the full output of every command (`out/<n>.log`) to `--out-dir`. They are updated after every turn. The console gives each session its own folder, `console-<time>-<pid>/task-<n>/`, and prints its path at the start.
569569
- **Events:** `--events jsonl` prints one JSON object per line on stdout: `turn_start`, `model_text`, `tool_call`, `tool_result`, `stop_check`, `guard`, `turn_end`, `repo`, `run_end`.
570570
- **Telemetry:** set `OTEL_EXPORTER_OTLP_ENDPOINT` to export traces and metrics (OTLP over HTTP/JSON). `OTEL_EXPORTER_OTLP_HEADERS` and `OTEL_SERVICE_NAME` are honoured.
571571

‎crates/cli/src/approval.rs‎

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,14 @@ impl Approver for TtyApprover {
4545
while rx.try_recv().is_ok() {}
4646
let st = crate::console::Style::for_stderr();
4747
let question = format!(
48-
"\n{} {}\n {}\n {} ",
48+
"\n{} {}\n{} {}\n {} ",
4949
st.orange("!"),
5050
st.bold(&format!(
5151
"approval needed · {}",
52-
crate::console::tool_line(
53-
&request.tool_name,
54-
Some(&serde_json::Value::Object(request.arguments.clone()))
55-
)
52+
visible(&request.tool_name)
5653
)),
57-
st.dim(&request.reason),
54+
details(&request.arguments),
55+
st.dim(&visible(&request.reason)),
5856
st.orange("approve? [y/N]")
5957
);
6058
let shown = std::fs::OpenOptions::new()
@@ -79,3 +77,96 @@ impl Approver for TtyApprover {
7977
}
8078
}
8179
}
80+
81+
/// The longest argument shown whole; a longer one keeps its start and end.
82+
const SHOW_CHARS: usize = 4000;
83+
84+
/// Every argument of the call, in full, one per block: what the person is
85+
/// approving, not a one-line summary of it.
86+
fn details(args: &serde_json::Map<String, serde_json::Value>) -> String {
87+
let mut out = String::new();
88+
for (key, value) in args {
89+
let text = match value {
90+
serde_json::Value::String(s) => s.clone(),
91+
other => serde_json::to_string_pretty(other).unwrap_or_default(),
92+
};
93+
out.push_str(&format!(" {}:\n", visible(key)));
94+
for line in shorten(&visible(&text)).lines() {
95+
out.push_str(&format!(" {line}\n"));
96+
}
97+
if text.is_empty() {
98+
out.push_str(" (empty)\n");
99+
}
100+
}
101+
out
102+
}
103+
104+
/// `text` whole, or its start and end with the number of characters left
105+
/// out said plainly, so nothing is hidden without the person knowing.
106+
fn shorten(text: &str) -> String {
107+
let n = text.chars().count();
108+
if n <= SHOW_CHARS {
109+
return text.to_string();
110+
}
111+
let head: String = text.chars().take(SHOW_CHARS * 3 / 4).collect();
112+
let tail: String = text.chars().skip(n - SHOW_CHARS / 4).collect();
113+
format!(
114+
"{head}\n[… {} characters not shown; answer n if unsure …]\n{tail}",
115+
n - SHOW_CHARS
116+
)
117+
}
118+
119+
/// `text` with everything that could change how the terminal shows it made
120+
/// visible: escape sequences, carriage returns, other control characters
121+
/// and the Unicode marks that reorder text. Newlines and tabs stay.
122+
fn visible(text: &str) -> String {
123+
text.chars()
124+
.map(|c| match c {
125+
'\n' | '\t' => c.to_string(),
126+
'\x1b' => "␛".to_string(),
127+
'\r' => "␍".to_string(),
128+
c if c.is_control()
129+
|| matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') =>
130+
{
131+
format!("\\u{{{:04x}}}", c as u32)
132+
}
133+
c => c.to_string(),
134+
})
135+
.collect()
136+
}
137+
138+
#[cfg(test)]
139+
mod tests {
140+
use super::*;
141+
142+
#[test]
143+
fn the_prompt_shows_every_argument_in_full_and_makes_controls_visible() {
144+
let long_cmd = format!("echo {}; rm -rf ~/precious", "x".repeat(200));
145+
let args = serde_json::json!({"cmd": long_cmd})
146+
.as_object()
147+
.unwrap()
148+
.clone();
149+
let shown = details(&args);
150+
assert!(
151+
shown.contains("rm -rf ~/precious"),
152+
"nothing past 120 characters may be hidden"
153+
);
154+
155+
let args = serde_json::json!({"path": "a.sh", "content": "echo hi\nrm -rf /"})
156+
.as_object()
157+
.unwrap()
158+
.clone();
159+
let shown = details(&args);
160+
assert!(
161+
shown.contains("content:") && shown.contains("rm -rf /"),
162+
"{shown}"
163+
);
164+
165+
assert_eq!(visible("ok\x1b[2K\rsafe"), "ok␛[2K␍safe");
166+
assert_eq!(visible("a\u{202e}b"), "a\\u{202e}b");
167+
168+
let huge = "y".repeat(SHOW_CHARS * 2);
169+
let cut = shorten(&huge);
170+
assert!(cut.contains(&format!("{} characters not shown", SHOW_CHARS)));
171+
}
172+
}

‎crates/cli/src/install_cmd.rs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,12 @@ pub(crate) fn run(args: &[std::ffi::OsString]) -> ExitCode {
6666
report("parent", install_parent(&root, src, cli.force));
6767
}
6868
for dep in manifest["dependencies"].as_array().into_iter().flatten() {
69-
let Some((name, mount)) = dependency_mount(&root, dep) else {
70-
report("dependency", Err("has no `name`".into()));
71-
continue;
69+
let (name, mount) = match dependency_mount(&root, dep) {
70+
Ok(m) => m,
71+
Err(e) => {
72+
report("dependency", Err(e));
73+
continue;
74+
}
7275
};
7376
let source = dep["source"].as_str().map(str::trim).unwrap_or_default();
7477
let version = dep["version"].as_str().map(str::trim);
@@ -126,6 +129,13 @@ fn install_dependency(
126129
mount.display()
127130
));
128131
}
132+
// Something else is there: only `--force` may replace it.
133+
if mount.exists() && !force {
134+
return Err(format!(
135+
"{} exists and is not an installed agent; move it away or use --force to replace it",
136+
mount.display()
137+
));
138+
}
129139
if mount.exists() {
130140
std::fs::remove_dir_all(mount).map_err(|e| e.to_string())?;
131141
}

‎crates/cli/src/plugin_cmd.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ fn install(
104104
.trim_end_matches(".git")
105105
.to_string();
106106
let name = name.unwrap_or(&base);
107+
for n in [name, base.as_str()] {
108+
if !gitagent_loader::manifest::is_plain_name(n) {
109+
return Err(format!(
110+
"the plugin name {n:?} must not contain `/`, `\\` or be `.` or `..`; pass --name"
111+
));
112+
}
113+
}
107114
let mut fields = vec![("enabled", Value::Bool(enable))];
108115
let dest = if is_git_source(source) {
109116
let dest = root.join(".gitagent/plugins").join(&base);
@@ -200,6 +207,9 @@ fn list(root: &Path) -> Result<String, String> {
200207
}
201208

202209
fn remove(root: &Path, name: &str) -> Result<String, String> {
210+
if !gitagent_loader::manifest::is_plain_name(name) {
211+
return Err(format!("{name:?} is not a plugin name"));
212+
}
203213
let mut removed = None;
204214
for dir in [
205215
root.join("plugins").join(name),

‎crates/cli/src/repl.rs‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ pub(crate) async fn run(s: Setup, out_dir: &Path) -> Result<()> {
4848
let mut resume: Option<Resume> = None;
4949
let mut n = 0;
5050
let session_id = format!("gitagent-{}", std::process::id());
51+
// Each console session keeps its records in a folder of its own, so a
52+
// new session never overwrites an earlier one's.
53+
let session_dir = out_dir.join(format!(
54+
"console-{}-{}",
55+
gitagent_record::utc_now_rfc3339()
56+
.chars()
57+
.take(19)
58+
.collect::<String>()
59+
.replace(':', ""),
60+
std::process::id()
61+
));
62+
eprintln!("{}", st.dim(&format!("records: {}", session_dir.display())));
5163
let mcp = crate::connect_mcp(&s.spec).await;
5264
loop {
5365
let line = if editing {
@@ -94,7 +106,7 @@ pub(crate) async fn run(s: Setup, out_dir: &Path) -> Result<()> {
94106
let opts = RunOptions {
95107
instruction: String::new(),
96108
work_dir: s.work_dir.clone(),
97-
out_dir: out_dir.join(format!("task-{n}")),
109+
out_dir: session_dir.join(format!("task-{n}")),
98110
timeout: s.timeout_set,
99111
session_id: Some(session_id.clone()),
100112
events: s.events.clone(),
@@ -147,7 +159,7 @@ pub(crate) async fn run(s: Setup, out_dir: &Path) -> Result<()> {
147159
let opts = RunOptions {
148160
instruction: task,
149161
work_dir: s.work_dir.clone(),
150-
out_dir: out_dir.join(format!("task-{n}")),
162+
out_dir: session_dir.join(format!("task-{n}")),
151163
timeout: s.timeout_set,
152164
session_id: Some(session_id.clone()),
153165
events: events.clone(),
@@ -191,7 +203,7 @@ pub(crate) async fn run(s: Setup, out_dir: &Path) -> Result<()> {
191203
let opts = RunOptions {
192204
instruction: String::new(),
193205
work_dir: s.work_dir.clone(),
194-
out_dir: out_dir.join("teardown"),
206+
out_dir: session_dir.join("teardown"),
195207
timeout: s.timeout_set,
196208
session_id: Some(session_id.clone()),
197209
events: s.events.clone(),

‎crates/cli/tests/cli.rs‎

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,18 @@ fn interactive_session_continues_between_tasks() {
251251
std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
252252
"7\n"
253253
);
254-
assert!(dir.path().join("logs/task-2/trajectory.json").exists());
254+
let session = std::fs::read_dir(dir.path().join("logs"))
255+
.unwrap()
256+
.flatten()
257+
.map(|e| e.path())
258+
.find(|p| {
259+
p.file_name()
260+
.unwrap()
261+
.to_string_lossy()
262+
.starts_with("console-")
263+
})
264+
.expect("a session folder");
265+
assert!(session.join("task-2/trajectory.json").exists());
255266
}
256267

257268
#[test]
@@ -1703,7 +1714,23 @@ fn interactive_commands() {
17031714
&& stderr.contains("● completed · 1 model call"),
17041715
"{stderr}"
17051716
);
1706-
let t = std::fs::read_to_string(dir.path().join("out/task-1/trajectory.json")).unwrap();
1717+
// Each console session writes under its own `console-…` folder.
1718+
let session = std::fs::read_dir(dir.path().join("out"))
1719+
.unwrap()
1720+
.flatten()
1721+
.map(|e| e.path())
1722+
.find(|p| {
1723+
p.file_name()
1724+
.unwrap()
1725+
.to_string_lossy()
1726+
.starts_with("console-")
1727+
})
1728+
.expect("a session folder");
1729+
assert!(
1730+
stderr.contains(&format!("records: {}", session.display())),
1731+
"{stderr}"
1732+
);
1733+
let t = std::fs::read_to_string(session.join("task-1/trajectory.json")).unwrap();
17071734
assert!(
17081735
t.contains("Use the skill `release-notes` for this request")
17091736
&& t.contains("Group the changes.")
@@ -2294,3 +2321,59 @@ fn schedules_are_listed_run_and_fired_when_due() {
22942321
assert_eq!(code, Some(0));
22952322
assert!(out.contains("removed") && !agent.join("schedules/ticker.yaml").exists());
22962323
}
2324+
2325+
/// `gitagent install` never deletes outside the agent's folder, and inside
2326+
/// it replaces something that is not an installed agent only with --force.
2327+
#[test]
2328+
fn install_keeps_to_the_agent_folder() {
2329+
let dir = tempfile::tempdir().unwrap();
2330+
let precious = dir.path().join("precious");
2331+
std::fs::create_dir_all(&precious).unwrap();
2332+
std::fs::write(precious.join("data.txt"), "important").unwrap();
2333+
let agent = dir.path().join("agent");
2334+
std::fs::create_dir_all(agent.join("agents/local")).unwrap();
2335+
std::fs::write(agent.join("agents/local/notes.txt"), "mine").unwrap();
2336+
let absolute = precious.display().to_string();
2337+
for (mount, name) in [
2338+
("../precious", "up"),
2339+
(absolute.as_str(), "abs"),
2340+
("agents/local", "local"),
2341+
] {
2342+
std::fs::write(
2343+
agent.join("agent.yaml"),
2344+
format!("name: kid\nversion: 1.0.0\ndescription: Kid.\ndependencies:\n - {{name: {name}, source: ../nowhere, mount: \"{mount}\"}}\n"),
2345+
)
2346+
.unwrap();
2347+
let out = gitagent().arg("install").arg(&agent).output().unwrap();
2348+
let text = format!(
2349+
"{}{}",
2350+
String::from_utf8_lossy(&out.stdout),
2351+
String::from_utf8_lossy(&out.stderr)
2352+
);
2353+
assert!(!out.status.success(), "{mount}: {text}");
2354+
assert!(
2355+
text.contains("inside the agent's folder")
2356+
|| text.contains("leads outside")
2357+
|| text.contains("use --force"),
2358+
"{mount}: {text}"
2359+
);
2360+
}
2361+
assert!(
2362+
precious.join("data.txt").is_file(),
2363+
"outside the agent: untouched"
2364+
);
2365+
assert!(
2366+
agent.join("agents/local/notes.txt").is_file(),
2367+
"not an installed agent: kept without --force"
2368+
);
2369+
2370+
// A dependency name that is a path is refused too.
2371+
std::fs::write(
2372+
agent.join("agent.yaml"),
2373+
"name: kid\nversion: 1.0.0\ndescription: Kid.\ndependencies:\n - {name: \"..\", source: ../nowhere}\n",
2374+
)
2375+
.unwrap();
2376+
let out = gitagent().arg("install").arg(&agent).output().unwrap();
2377+
assert!(!out.status.success());
2378+
assert!(String::from_utf8_lossy(&out.stderr).contains("must not contain"));
2379+
}

0 commit comments

Comments
 (0)