newt_coder/coder.rs
1//! The Coder orchestrator — prompt -> infer -> normalize -> apply.
2//!
3//! One method, [`Coder::run`], wires the four pieces together:
4//!
5//! 1. [`build_prompt`](crate::prompt::build_prompt) scans the workspace
6//! for relevant files and composes a `(system, user)` pair around
7//! the S5 whole-file directive.
8//! 2. The injected [`InferenceBackend`] runs one `complete` turn.
9//! 3. [`normalize_emission`](crate::emission::normalize_emission)
10//! classifies the raw reply as `WholeFiles` / `UnifiedDiff` /
11//! `Prose`.
12//! 4. The classified emission is applied to the workspace:
13//! `apply_whole_files` for the directive's happy path,
14//! `apply_patch` for the diff fallback, no-op + warn for prose.
15//!
16//! The caller (newt-acp-worker) then runs `git diff` to capture the
17//! real workspace diff — the foreman's empty-diff signal is computed
18//! from `git diff`, not from anything in this struct.
19
20use std::path::Path;
21use std::sync::Arc;
22
23use newt_core::{Caveats, CaveatsExt, CountBoundExt};
24use newt_identity::AgentKey;
25use newt_inference::{ChatRequest, InferenceBackend};
26
27use crate::emission::{normalize_emission, Emission};
28use crate::error::{CoderError, Result};
29use crate::prompt::{build_prompt, build_reprompt, CoderPrompt};
30
31/// The coder. Holds the inference backend the orchestrator uses for
32/// each `run` call; the backend is `Arc<dyn …>` so callers can share
33/// one backend across coder + non-coder paths.
34///
35/// # Issue #93 — operator-rooted parent key
36///
37/// `Coder` optionally holds the worker's operator-rooted parent
38/// [`AgentKey`] (from `WorkerIdentity::Operator { root }`). When a
39/// dispatch needs to spawn a subprocess plugin (today: future
40/// `ProviderPluginBackend::complete`), it derives a delegated child
41/// via [`AgentKey::delegate`] so the child's cert chain roots back to
42/// the operator's `UserKey` from `~/.newt/identity.pem` — never a
43/// synthetic key.
44pub struct Coder {
45 backend: Arc<dyn InferenceBackend>,
46 /// Operator-rooted parent key (issue #93). The acp-worker plumbs
47 /// this from `WorkerIdentity::Operator { root }`; when present, any
48 /// subprocess plugin the dispatch spawns inherits a delegated child
49 /// from this parent so the cert chain walks back to the operator.
50 /// `None` for the `WorkerIdentity::AllowNoKey` debug fallback and
51 /// for legacy tests that don't yet plumb identity.
52 parent_key: Option<Arc<AgentKey>>,
53}
54
55/// Outcome of one `Coder::run` turn. Surfaced via the ACP worker's
56/// `TaskReply.emission_shape` so the foreman's scorecard can
57/// distinguish T0a / T0b / T0c instead of lumping them as "empty
58/// diff".
59#[derive(Debug, Clone)]
60pub struct CoderRun {
61 /// Wire-stable shape label: "whole_files", "unified_diff", "prose".
62 pub emission_shape: String,
63 /// Model id the inference backend returned.
64 pub model_id: String,
65 /// Relative paths of files the run wrote (empty for prose / diff
66 /// — the diff path doesn't tell us which files it touched without
67 /// re-parsing).
68 pub files_written: Vec<String>,
69 /// The raw model reply. Useful for audit logs and post-mortem.
70 /// NOTE: when the whole-file re-prompt fallback fires this becomes a
71 /// composite first+retry transcript — use [`Self::first_emission`]
72 /// when you need just the model's initial output.
73 pub raw_reply: String,
74 /// The model's *first* raw emission, before any re-prompt fallback.
75 /// Always the initial reply (never a composite), so the eval
76 /// scorecard can judge it with `git apply --check` (#30B) to tell a
77 /// clean diff from a sloppy one the fuzzy worker merely rescued.
78 pub first_emission: String,
79}
80
81impl Coder {
82 /// Build a coder bound to `backend`.
83 pub fn new(backend: Arc<dyn InferenceBackend>) -> Self {
84 Self {
85 backend,
86 parent_key: None,
87 }
88 }
89
90 /// Builder: attach the operator-rooted parent [`AgentKey`] the coder
91 /// will use to delegate per-spawn children for subprocess plugins
92 /// (issue #93).
93 ///
94 /// The acp-worker plumbs this in from
95 /// `WorkerIdentity::Operator { root }` via
96 /// [`WorkerIdentity::parent_key`]; the `AllowNoKey` debug fallback
97 /// leaves it `None`. When present, [`Self::plugin_envelope_for`]
98 /// can be called by future dispatch paths that need to spawn a
99 /// provider plugin without first synthesizing a key.
100 #[must_use]
101 pub fn with_parent_key(mut self, parent: Arc<AgentKey>) -> Self {
102 self.parent_key = Some(parent);
103 self
104 }
105
106 /// Borrow the parent [`AgentKey`], if one is configured. Tests use
107 /// this to assert the operator-rooted threading; future dispatch
108 /// paths that spawn provider plugins use it to delegate per-spawn
109 /// children.
110 #[must_use]
111 pub fn parent_key(&self) -> Option<&Arc<AgentKey>> {
112 self.parent_key.as_ref()
113 }
114
115 /// Mint a plugin-side envelope for a subprocess running under
116 /// `role` with `child_caveats`, by delegating from the coder's
117 /// operator-rooted parent key.
118 ///
119 /// Returns:
120 /// - `Some(Ok(envelope))` on the happy path — the envelope's cert
121 /// chain roots back to the operator's `UserKey` (issue #93).
122 /// - `Some(Err(_))` if delegation refused (`child_caveats` would
123 /// amplify the parent's authority, etc.).
124 /// - `None` when no parent key is configured (the
125 /// `WorkerIdentity::AllowNoKey` debug path or a legacy test that
126 /// didn't thread identity).
127 ///
128 /// The returned envelope is the same shape `newt-mesh`'s
129 /// `plugin_envelope::serialize_for_plugin` produces — base64'd JSON
130 /// of an `agent_mesh_protocol::CertChain` — so plugins decode it
131 /// identically.
132 pub fn plugin_envelope_for(
133 &self,
134 role: &str,
135 child_caveats: Caveats,
136 ) -> Option<std::result::Result<String, newt_identity::EnvelopeError>> {
137 let parent = self.parent_key.as_ref()?;
138 Some(newt_identity::delegate_for_plugin(
139 parent.as_ref(),
140 role,
141 child_caveats,
142 ))
143 }
144
145 /// Run one turn against `workspace` under the authority carried by
146 /// `caveats`.
147 ///
148 /// `caveats` is the peer's signed, verified attenuated authority — see
149 /// `docs/decisions/agentic_object_capability_security.md` and the
150 /// 35a [`caveats_for_peer`] extractor in `newt-mesh`. Every tool
151 /// dispatch this method makes (`fs_read` for the prompt scan,
152 /// `net` for the inference call, `fs_write` for the apply, plus the
153 /// `max_calls` budget for inference turns) goes through the
154 /// enforcement helpers below — no path bypasses the check, even when
155 /// `caveats == Caveats::top()`. That symmetry is load-bearing: 35c
156 /// will tighten authority per peer, and a "skip checks if top"
157 /// shortcut would silently break that tightening.
158 ///
159 /// On any caveat refusal we return [`CoderError::CapabilityDenied`]
160 /// carrying the axis name and the concrete target the dispatch tried
161 /// to touch — enough context for the arbiter scorecard to count this
162 /// as a scrubbed sortie rather than a model failure.
163 ///
164 /// Happy path: build prompt -> infer -> normalize -> apply.
165 ///
166 /// Weak-model fallback: when the model emits a [`Emission::UnifiedDiff`]
167 /// (even under the whole-file directive) and that diff fails to apply
168 /// — its line numbers / context are too far off even for the fuzzy
169 /// matcher in `newt-tools::apply_patch` — we issue exactly ONE
170 /// re-prompt asking for the COMPLETE file(s) in `FILE:`/`END-FILE`
171 /// form, then apply via the hardened `apply_whole_files` path. The
172 /// retry counts as a *second* inference call against the
173 /// `max_calls` budget; if that budget would be exhausted we return
174 /// the original apply error rather than escalating to a denial.
175 ///
176 /// [`caveats_for_peer`]: https://docs.rs/newt-mesh/latest/newt_mesh/caveats/fn.caveats_for_peer.html
177 pub async fn run(&self, workspace: &Path, task: &str, caveats: &Caveats) -> Result<CoderRun> {
178 // 1. Build the prompt. `build_prompt` is what *reads* the
179 // workspace, so the fs_read check is gated on the files the
180 // prompt actually injected, not on the candidate set the
181 // scanner considered.
182 let prompt = build_prompt(workspace, task)?;
183 check_fs_read(caveats, &prompt)?;
184 tracing::info!(
185 files_included = prompt.included_files.len(),
186 user_chars = prompt.user.len(),
187 "newt-coder prompt built"
188 );
189
190 // 2. First inference call — guarded by the net + max_calls axes.
191 let mut calls_used: u64 = 0;
192 check_call_budget(caveats, calls_used)?;
193 check_net(caveats, self.backend.as_ref())?;
194 let req = ChatRequest::new().system(prompt.system).user(prompt.user);
195 let reply = self
196 .backend
197 .complete(req)
198 .await
199 .map_err(|e| CoderError::Inference(e.to_string()))?;
200 calls_used += 1;
201 let raw = reply.content.clone();
202 let model_id = reply.model_id.clone();
203
204 let emission = normalize_emission(&raw)?;
205 let shape_label = emission.shape_label().to_string();
206
207 // 3. Try to apply the first emission — `apply` consults the
208 // fs_write axis before each write.
209 match self.apply(&emission, workspace, caveats) {
210 Ok(files_written) => {
211 tracing::info!(
212 emission_shape = %shape_label,
213 files_written = files_written.len(),
214 "newt-coder run complete"
215 );
216 Ok(CoderRun {
217 emission_shape: shape_label,
218 model_id,
219 files_written,
220 first_emission: raw.clone(),
221 raw_reply: raw,
222 })
223 }
224 // The first emission was diff-shaped and did not apply: either a
225 // unified diff whose context was too far off even for the fuzzy
226 // matcher, or diff content the model wrapped in FILE:/END-FILE
227 // markers (classified as whole-files but rejected by the
228 // diff-shape guard). Both are recoverable with a single re-prompt
229 // for proper whole-file output.
230 Err(first_err)
231 if matches!(emission, Emission::UnifiedDiff(_))
232 || matches!(first_err, CoderError::LooksLikeDiff { .. }) =>
233 {
234 tracing::warn!(
235 error = %first_err,
236 "newt-coder: diff-shaped emission did not apply, re-prompting for whole files"
237 );
238 self.reprompt_whole_files(workspace, task, raw, first_err, calls_used, caveats)
239 .await
240 }
241 Err(other) => Err(other),
242 }
243 }
244
245 /// Single-retry fallback: re-prompt the model for the complete
246 /// file(s) and apply via `apply_whole_files`.
247 ///
248 /// Bounded to ONE additional inference call — there is no loop. The
249 /// retry counts as a *second* tool call against
250 /// [`Caveats::max_calls`], and if the budget would be exhausted by
251 /// that second call we fall through to `original_err` (the apply
252 /// failure from the first attempt). On any failure of the retry
253 /// (inference error, the model returning yet another diff / prose,
254 /// or the whole-file apply failing the shape guards or fs_write
255 /// caveat) we return `original_err`, so the caller sees the root
256 /// cause rather than a confusing second-order failure.
257 async fn reprompt_whole_files(
258 &self,
259 workspace: &Path,
260 task: &str,
261 first_raw: String,
262 original_err: CoderError,
263 calls_used: u64,
264 caveats: &Caveats,
265 ) -> Result<CoderRun> {
266 // The retry would be the (calls_used + 1)-th call; if the
267 // budget can't cover it, don't degrade the diagnostic by
268 // surfacing a fresh capability denial — keep the original
269 // apply failure, which is more actionable.
270 if !caveats.max_calls.permits_one_more(calls_used) {
271 tracing::warn!(
272 calls_used,
273 "newt-coder: re-prompt skipped, max_calls budget exhausted"
274 );
275 return Err(original_err);
276 }
277
278 let prompt = match build_reprompt(workspace, task) {
279 Ok(p) => p,
280 Err(e) => {
281 tracing::warn!(error = %e, "newt-coder: re-prompt build failed");
282 return Err(original_err);
283 }
284 };
285 // The re-prompt re-reads the same workspace; fs_read scope must
286 // still permit every file the second pass would inject.
287 if let Err(e) = check_fs_read(caveats, &prompt) {
288 tracing::warn!(error = %e, "newt-coder: re-prompt fs_read denied");
289 return Err(original_err);
290 }
291
292 let req = ChatRequest::new().system(prompt.system).user(prompt.user);
293 let reply = match self.backend.complete(req).await {
294 Ok(r) => r,
295 Err(e) => {
296 tracing::warn!(error = %e, "newt-coder: re-prompt inference failed");
297 return Err(original_err);
298 }
299 };
300 let retry_raw = reply.content.clone();
301 let model_id = reply.model_id.clone();
302
303 // The retry must yield whole files; anything else (another diff,
304 // prose) is not usable for this fallback.
305 let emission = match normalize_emission(&retry_raw) {
306 Ok(em @ Emission::WholeFiles(_)) => em,
307 Ok(other) => {
308 tracing::warn!(
309 emission_shape = %other.shape_label(),
310 "newt-coder: re-prompt did not return whole files"
311 );
312 return Err(original_err);
313 }
314 Err(e) => {
315 tracing::warn!(error = %e, "newt-coder: re-prompt emission malformed");
316 return Err(original_err);
317 }
318 };
319
320 let shape_label = emission.shape_label().to_string();
321 match self.apply(&emission, workspace, caveats) {
322 Ok(files_written) => {
323 tracing::info!(
324 emission_shape = %shape_label,
325 files_written = files_written.len(),
326 "newt-coder: re-prompt whole-file fallback applied"
327 );
328 Ok(CoderRun {
329 // Reflect what *actually* applied: the whole-file retry,
330 // not the original diff.
331 emission_shape: shape_label,
332 model_id,
333 files_written,
334 // The first emission is the diff the model actually
335 // produced for the task; the scorecard judges *that*,
336 // not the rescued retry.
337 first_emission: first_raw.clone(),
338 // Keep an audit trail of both turns: the first
339 // (rejected) diff and the retry that landed.
340 raw_reply: format!(
341 "[diff-apply failed, re-prompted for whole files]\n\
342 --- first reply ---\n{first_raw}\n\
343 --- retry reply ---\n{retry_raw}"
344 ),
345 })
346 }
347 Err(e) => {
348 tracing::warn!(
349 error = %e,
350 "newt-coder: re-prompt whole-file apply failed"
351 );
352 Err(original_err)
353 }
354 }
355 }
356
357 /// Apply one classified emission to `workspace`, under `caveats`.
358 /// Returns the list of relative paths written, where known.
359 ///
360 /// Every filesystem write goes through the `fs_write` axis first.
361 /// For a [`Emission::WholeFiles`] emission we know every target
362 /// path up front, so the check happens before any write touches
363 /// disk — partial-apply is never possible under a denied caveat.
364 /// For a [`Emission::UnifiedDiff`] we cannot enumerate paths
365 /// without re-parsing, so we require `fs_write` to be
366 /// [`Scope::All`](newt_core::Scope::All) — bounded fs_write +
367 /// diff emission is a denial. This is conservative on purpose:
368 /// 35c will swap diff dispatch for a parser that knows the paths,
369 /// and the conservative rule is easier to weaken later than to
370 /// retrofit a "we already wrote half the diff" rollback.
371 fn apply(
372 &self,
373 emission: &Emission,
374 workspace: &Path,
375 caveats: &Caveats,
376 ) -> Result<Vec<String>> {
377 match emission {
378 Emission::WholeFiles(files) => {
379 // Shape guards before writing. A whole-file emission
380 // legitimately rewrites every line (renames, signature
381 // changes, new doc comments), so we do NOT compare the
382 // body against what's on disk. We reject only bodies
383 // whose *shape* is wrong; the real correctness gate is
384 // the downstream `git diff` capture plus the eval
385 // compile/test evaluators.
386 for (path, contents) in files {
387 reject_bad_shape(path, contents)?;
388 }
389 // Caveat check: every target path must be permitted on
390 // the fs_write axis. We loop *all* paths before
391 // committing any write so a denial on the second file
392 // can't leave the first file half-written.
393 for path in files.keys() {
394 if !caveats.permits_fs_write(path) {
395 return Err(CoderError::CapabilityDenied {
396 kind: "fs_write",
397 target: path.clone(),
398 });
399 }
400 }
401 // `apply_whole_files` wants `(String, String)` tuples;
402 // collect to give it owned values without leaking the
403 // BTreeMap iterator's lifetime into the call.
404 let pairs: Vec<(String, String)> =
405 files.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
406 let written = newt_tools::apply_whole_files(workspace, pairs)
407 .map_err(|e| CoderError::FileWrite(e.to_string()))?;
408 Ok(written)
409 }
410 Emission::UnifiedDiff(diff) => {
411 // We can't enumerate the touched paths without
412 // re-parsing the diff. Be conservative: require
413 // `fs_write = All`. Anything narrower denies the
414 // dispatch up front. Target the diff blob itself so
415 // the error message points the reader at the
416 // can't-enumerate-paths reason.
417 if !matches!(caveats.fs_write, newt_core::Scope::All) {
418 return Err(CoderError::CapabilityDenied {
419 kind: "fs_write",
420 target: "<unified_diff: paths not enumerable>".to_string(),
421 });
422 }
423 // Legacy path: model emitted a real diff. We don't
424 // know which files it touched without re-parsing, so
425 // return an empty `files_written` — the caller's
426 // `git diff` capture is the source of truth.
427 newt_tools::apply_patch(workspace, diff)
428 .map_err(|e| CoderError::FileWrite(e.to_string()))?;
429 Ok(Vec::new())
430 }
431 Emission::Prose(prose) => {
432 tracing::warn!(
433 prose_len = prose.len(),
434 "newt-coder: prose-only emission, no edits"
435 );
436 Ok(Vec::new())
437 }
438 }
439 }
440}
441
442// ── Enforcement helpers ────────────────────────────────────────────────────
443//
444// One helper per axis the dispatch sites consult. Every helper goes through
445// `Caveats::permits_*` even when the caveat is `top` — there is no fast-path
446// bypass, by design. See the module/`Coder::run` doc comments.
447
448/// Check whether `caveats.max_calls` permits one more inference call
449/// given `used_so_far` calls already counted against this run.
450fn check_call_budget(caveats: &Caveats, used_so_far: u64) -> Result<()> {
451 if caveats.max_calls.permits_one_more(used_so_far) {
452 Ok(())
453 } else {
454 Err(CoderError::CapabilityDenied {
455 kind: "max_calls",
456 target: format!("turn #{}", used_so_far + 1),
457 })
458 }
459}
460
461/// Check whether `caveats.net` permits the network call the backend
462/// would make on `complete()`. Backends with no endpoint (mocks,
463/// in-process plugins) skip the check vacuously — there is no host to
464/// consult.
465fn check_net(caveats: &Caveats, backend: &dyn InferenceBackend) -> Result<()> {
466 let endpoint = match backend.endpoint() {
467 Some(e) => e,
468 None => return Ok(()),
469 };
470 let host = host_from_endpoint(endpoint);
471 if caveats.permits_net(host) {
472 Ok(())
473 } else {
474 Err(CoderError::CapabilityDenied {
475 kind: "net",
476 target: host.to_string(),
477 })
478 }
479}
480
481/// Check whether `caveats.fs_read` permits every file the prompt
482/// actually injected. We gate on `included_files` (what was read), not
483/// on the wider candidate set the scanner considered, so the denial
484/// fires only when the model would have *seen* a forbidden path.
485fn check_fs_read(caveats: &Caveats, prompt: &CoderPrompt) -> Result<()> {
486 for path in &prompt.included_files {
487 let s = path.to_string_lossy();
488 if !caveats.permits_fs_read(&s) {
489 return Err(CoderError::CapabilityDenied {
490 kind: "fs_read",
491 target: s.into_owned(),
492 });
493 }
494 }
495 Ok(())
496}
497
498/// Extract the host portion of an HTTP(S) URL — enough for the
499/// `caveats.net` exact-match check, without dragging in a `url` crate
500/// dependency. Strips `scheme://`, then takes everything up to the
501/// first `/`, `?`, or port `:`. Returns the input unchanged if no
502/// scheme prefix is present (treating it as already a bare host).
503fn host_from_endpoint(endpoint: &str) -> &str {
504 let after_scheme = endpoint
505 .find("://")
506 .map(|i| &endpoint[i + 3..])
507 .unwrap_or(endpoint);
508 let end = after_scheme
509 .find(['/', ':', '?'])
510 .unwrap_or(after_scheme.len());
511 &after_scheme[..end]
512}
513
514/// Reject a whole-file emission whose body has the wrong *shape*.
515///
516/// This replaces the old "first non-blank line must equal the file's
517/// existing anchor line" check, which wrongly rejected correct output
518/// whenever a rename or signature change altered line 1. Instead we
519/// only refuse bodies that are:
520///
521/// - empty / whitespace-only ([`CoderError::EmptyEmission`]),
522/// - diff-shaped — first non-blank line starts with `--- `, `+++ `, or
523/// `@@` ([`CoderError::LooksLikeDiff`]), or
524/// - still prefixed with a leaked `FILE:` marker as their first
525/// non-blank line ([`CoderError::LeakedMarker`]) — defense in depth
526/// in case [`crate::emission`] did not strip it.
527fn reject_bad_shape(path: &str, contents: &str) -> Result<()> {
528 let first_non_blank = contents.lines().find(|l| !l.trim().is_empty());
529 match first_non_blank {
530 None => Err(CoderError::EmptyEmission {
531 path: path.to_string(),
532 }),
533 Some(first) => {
534 let trimmed = first.trim_start();
535 if trimmed.starts_with("--- ")
536 || trimmed.starts_with("+++ ")
537 || trimmed.starts_with("@@")
538 {
539 return Err(CoderError::LooksLikeDiff {
540 path: path.to_string(),
541 });
542 }
543 if trimmed.starts_with("FILE:") {
544 return Err(CoderError::LeakedMarker {
545 path: path.to_string(),
546 });
547 }
548 Ok(())
549 }
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use std::collections::BTreeMap;
557 use std::fs;
558 use tempfile::TempDir;
559
560 // Apply-only tests; the end-to-end smoke (build_prompt -> backend
561 // -> normalize -> apply) lives in tests/coder_smoke.rs.
562
563 fn coder_with_no_backend_used() -> Coder {
564 // The `apply` method does not call the backend, so we can use
565 // any backend here. We construct one only so the type checks.
566 // Tests in tests/ use a real MockBackend for the run() path.
567 struct Stub;
568 #[async_trait::async_trait]
569 impl InferenceBackend for Stub {
570 fn name(&self) -> &str {
571 "stub"
572 }
573 fn model_id(&self) -> &str {
574 "stub-model"
575 }
576 fn supports_tier(&self, _t: newt_core::router::Tier) -> bool {
577 false
578 }
579 async fn complete(
580 &self,
581 _req: ChatRequest,
582 ) -> anyhow::Result<newt_inference::ChatReply> {
583 unreachable!("apply tests do not call the backend")
584 }
585 }
586 Coder::new(Arc::new(Stub))
587 }
588
589 #[test]
590 fn apply_whole_files_writes_to_workspace() {
591 let tmp = TempDir::new().unwrap();
592 let coder = coder_with_no_backend_used();
593
594 let mut files = BTreeMap::new();
595 files.insert("src/lib.rs".to_string(), "pub fn hello() {}\n".to_string());
596
597 let written = coder
598 .apply(&Emission::WholeFiles(files), tmp.path(), &Caveats::top())
599 .unwrap();
600 assert_eq!(written, vec!["src/lib.rs".to_string()]);
601 let content = fs::read_to_string(tmp.path().join("src/lib.rs")).unwrap();
602 assert_eq!(content, "pub fn hello() {}\n");
603 }
604
605 #[test]
606 fn apply_prose_writes_nothing() {
607 let tmp = TempDir::new().unwrap();
608 let coder = coder_with_no_backend_used();
609 let written = coder
610 .apply(
611 &Emission::Prose("I've updated it.".to_string()),
612 tmp.path(),
613 &Caveats::top(),
614 )
615 .unwrap();
616 assert!(written.is_empty());
617 }
618
619 #[test]
620 fn apply_unified_diff_returns_empty_files_written() {
621 let tmp = TempDir::new().unwrap();
622 // Seed a file so the diff actually applies.
623 fs::write(tmp.path().join("a.txt"), "old\n").unwrap();
624 let diff = "\
625--- a/a.txt
626+++ b/a.txt
627@@ -1 +1 @@
628-old
629+new
630";
631 let coder = coder_with_no_backend_used();
632 let written = coder
633 .apply(
634 &Emission::UnifiedDiff(diff.to_string()),
635 tmp.path(),
636 &Caveats::top(),
637 )
638 .unwrap();
639 assert!(written.is_empty(), "diff path returns empty files_written");
640 let content = fs::read_to_string(tmp.path().join("a.txt")).unwrap();
641 assert_eq!(content, "new\n");
642 }
643
644 #[test]
645 fn apply_bad_diff_surfaces_filewrite_error() {
646 let tmp = TempDir::new().unwrap();
647 let coder = coder_with_no_backend_used();
648 let bad = Emission::UnifiedDiff("not a real diff".to_string());
649 let err = coder.apply(&bad, tmp.path(), &Caveats::top()).unwrap_err();
650 assert!(matches!(err, CoderError::FileWrite(_)));
651 }
652
653 fn whole_files(path: &str, contents: &str) -> Emission {
654 let mut m = BTreeMap::new();
655 m.insert(path.to_string(), contents.to_string());
656 Emission::WholeFiles(m)
657 }
658
659 #[test]
660 fn apply_whole_files_accepts_line_one_change() {
661 // Regression for failures 1 & 2 (rename / signature change):
662 // the emitted first line differs from the existing first line,
663 // which the old anchor check wrongly rejected. It must now apply.
664 let tmp = TempDir::new().unwrap();
665 let coder = coder_with_no_backend_used();
666 fs::create_dir_all(tmp.path().join("src")).unwrap();
667 fs::write(
668 tmp.path().join("src/lib.rs"),
669 "pub fn hello(name: &str) -> String {\n format!(\"hi {name}\")\n}\n",
670 )
671 .unwrap();
672
673 let new_body = "pub fn greet(name: &str) -> String {\n format!(\"hi {name}\")\n}\n";
674 let written = coder
675 .apply(
676 &whole_files("src/lib.rs", new_body),
677 tmp.path(),
678 &Caveats::top(),
679 )
680 .unwrap();
681 assert_eq!(written, vec!["src/lib.rs".to_string()]);
682 assert_eq!(
683 fs::read_to_string(tmp.path().join("src/lib.rs")).unwrap(),
684 new_body
685 );
686 }
687
688 #[test]
689 fn apply_whole_files_rejects_diff_shaped_contents() {
690 let tmp = TempDir::new().unwrap();
691 let coder = coder_with_no_backend_used();
692 fs::write(tmp.path().join("a.txt"), "old\n").unwrap();
693 let diff = "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n";
694 let err = coder
695 .apply(&whole_files("a.txt", diff), tmp.path(), &Caveats::top())
696 .unwrap_err();
697 assert!(matches!(err, CoderError::LooksLikeDiff { ref path } if path == "a.txt"));
698 // The file must not have been overwritten.
699 assert_eq!(
700 fs::read_to_string(tmp.path().join("a.txt")).unwrap(),
701 "old\n"
702 );
703 }
704
705 #[test]
706 fn apply_whole_files_rejects_hunk_only_contents() {
707 let tmp = TempDir::new().unwrap();
708 let coder = coder_with_no_backend_used();
709 let hunk = "@@ -1,2 +1,2 @@\n-old\n+new\n";
710 let err = coder
711 .apply(&whole_files("a.txt", hunk), tmp.path(), &Caveats::top())
712 .unwrap_err();
713 assert!(matches!(err, CoderError::LooksLikeDiff { .. }));
714 }
715
716 #[test]
717 fn apply_whole_files_rejects_empty_contents() {
718 let tmp = TempDir::new().unwrap();
719 let coder = coder_with_no_backend_used();
720 let err = coder
721 .apply(&whole_files("a.txt", ""), tmp.path(), &Caveats::top())
722 .unwrap_err();
723 assert!(matches!(err, CoderError::EmptyEmission { ref path } if path == "a.txt"));
724 }
725
726 #[test]
727 fn apply_whole_files_rejects_whitespace_only_contents() {
728 let tmp = TempDir::new().unwrap();
729 let coder = coder_with_no_backend_used();
730 let err = coder
731 .apply(
732 &whole_files("a.txt", " \n\t\n"),
733 tmp.path(),
734 &Caveats::top(),
735 )
736 .unwrap_err();
737 assert!(matches!(err, CoderError::EmptyEmission { .. }));
738 }
739
740 #[test]
741 fn apply_whole_files_rejects_leaked_file_marker() {
742 // Defense in depth (failures 3 & 4): even if a leaked FILE:
743 // marker slipped past the parser, the writer must refuse it.
744 let tmp = TempDir::new().unwrap();
745 let coder = coder_with_no_backend_used();
746 let body = "FILE: src/lib.rs\npub fn add(a: i32, b: i32) -> i32 { a + b }\n";
747 let err = coder
748 .apply(
749 &whole_files("src/lib.rs", body),
750 tmp.path(),
751 &Caveats::top(),
752 )
753 .unwrap_err();
754 assert!(matches!(err, CoderError::LeakedMarker { ref path } if path == "src/lib.rs"));
755 }
756
757 #[test]
758 fn reject_bad_shape_messages_start_with_file_write_failed() {
759 for err in [
760 super::reject_bad_shape("p", "").unwrap_err(),
761 super::reject_bad_shape("p", "--- a/p\n").unwrap_err(),
762 super::reject_bad_shape("p", "FILE: p\n").unwrap_err(),
763 ] {
764 assert!(
765 err.to_string().starts_with("file write failed:"),
766 "message did not start with prefix: {err}"
767 );
768 }
769 }
770
771 // ── Caveat enforcement at the apply boundary ─────────────────────────
772
773 #[test]
774 fn apply_whole_files_denies_path_outside_fs_write_scope() {
775 let tmp = TempDir::new().unwrap();
776 let coder = coder_with_no_backend_used();
777 let caveats = Caveats {
778 fs_write: newt_core::Scope::only(["allowed.rs".to_string()]),
779 ..Caveats::top()
780 };
781
782 // Allowed write succeeds.
783 let allowed = coder
784 .apply(
785 &whole_files("allowed.rs", "fn ok() {}\n"),
786 tmp.path(),
787 &caveats,
788 )
789 .expect("permitted write must succeed");
790 assert_eq!(allowed, vec!["allowed.rs".to_string()]);
791
792 // Forbidden write returns CapabilityDenied.
793 let err = coder
794 .apply(
795 &whole_files("forbidden.rs", "fn evil() {}\n"),
796 tmp.path(),
797 &caveats,
798 )
799 .unwrap_err();
800 match err {
801 CoderError::CapabilityDenied { kind, target } => {
802 assert_eq!(kind, "fs_write");
803 assert_eq!(target, "forbidden.rs");
804 }
805 other => panic!("expected CapabilityDenied, got {other:?}"),
806 }
807 // And the file was never created.
808 assert!(!tmp.path().join("forbidden.rs").exists());
809 }
810
811 #[test]
812 fn apply_whole_files_denies_atomically_on_partial_scope() {
813 // A multi-file emission where one path is denied must write
814 // NOTHING — the check loops every path before committing any
815 // write. Regression for the "wrote half the emission then
816 // refused" failure mode.
817 let tmp = TempDir::new().unwrap();
818 let coder = coder_with_no_backend_used();
819 let caveats = Caveats {
820 fs_write: newt_core::Scope::only(["a.rs".to_string()]),
821 ..Caveats::top()
822 };
823 let mut files = BTreeMap::new();
824 files.insert("a.rs".to_string(), "fn a() {}\n".to_string());
825 files.insert("b.rs".to_string(), "fn b() {}\n".to_string());
826
827 let err = coder
828 .apply(&Emission::WholeFiles(files), tmp.path(), &caveats)
829 .unwrap_err();
830 assert!(matches!(
831 err,
832 CoderError::CapabilityDenied {
833 kind: "fs_write",
834 ..
835 }
836 ));
837 // Neither file landed.
838 assert!(!tmp.path().join("a.rs").exists());
839 assert!(!tmp.path().join("b.rs").exists());
840 }
841
842 #[test]
843 fn apply_unified_diff_denied_under_bounded_fs_write() {
844 // We can't enumerate diff paths up front, so any non-`All`
845 // fs_write scope conservatively denies the dispatch.
846 let tmp = TempDir::new().unwrap();
847 let coder = coder_with_no_backend_used();
848 let caveats = Caveats {
849 fs_write: newt_core::Scope::only(["whatever.rs".to_string()]),
850 ..Caveats::top()
851 };
852 let diff = Emission::UnifiedDiff(
853 "--- a/whatever.rs\n+++ b/whatever.rs\n@@ -1 +1 @@\n-x\n+y\n".to_string(),
854 );
855 let err = coder.apply(&diff, tmp.path(), &caveats).unwrap_err();
856 assert!(matches!(
857 err,
858 CoderError::CapabilityDenied {
859 kind: "fs_write",
860 ..
861 }
862 ));
863 }
864
865 // ── host_from_endpoint ───────────────────────────────────────────────
866
867 #[test]
868 fn host_from_endpoint_strips_scheme_and_path() {
869 assert_eq!(
870 super::host_from_endpoint("http://localhost:11434/api/chat"),
871 "localhost"
872 );
873 assert_eq!(
874 super::host_from_endpoint("https://allowed.example.com/v1/chat"),
875 "allowed.example.com"
876 );
877 // No scheme — treated as a bare host.
878 assert_eq!(
879 super::host_from_endpoint("bare.host.local"),
880 "bare.host.local"
881 );
882 // No path, just host:port.
883 assert_eq!(super::host_from_endpoint("http://h:8080"), "h");
884 // Empty path component.
885 assert_eq!(super::host_from_endpoint("https://only.host/"), "only.host");
886 }
887
888 // ── check_call_budget ────────────────────────────────────────────────
889
890 #[test]
891 fn check_call_budget_passes_under_unlimited() {
892 super::check_call_budget(&Caveats::top(), 0).unwrap();
893 super::check_call_budget(&Caveats::top(), 999_999).unwrap();
894 }
895
896 #[test]
897 fn check_call_budget_passes_within_bound() {
898 let caveats = Caveats {
899 max_calls: newt_core::CountBound::AtMost(3),
900 ..Caveats::top()
901 };
902 super::check_call_budget(&caveats, 0).unwrap();
903 super::check_call_budget(&caveats, 2).unwrap();
904 }
905
906 #[test]
907 fn check_call_budget_denies_at_bound() {
908 let caveats = Caveats {
909 max_calls: newt_core::CountBound::AtMost(2),
910 ..Caveats::top()
911 };
912 let err = super::check_call_budget(&caveats, 2).unwrap_err();
913 match err {
914 CoderError::CapabilityDenied { kind, target } => {
915 assert_eq!(kind, "max_calls");
916 assert!(target.contains("#3"));
917 }
918 other => panic!("expected CapabilityDenied, got {other:?}"),
919 }
920 }
921}