1use anyhow::{bail, Context, Result};
2use fs2::FileExt;
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use sha2::{Digest, Sha256};
6use std::{
7 fs,
8 io::Write,
9 path::{Path, PathBuf},
10 process::Command,
11};
12
13use super::BUNDLE_NAME;
14
15pub trait Runner {
16 fn run(&mut self, program: &str, args: &[String]) -> Result<String>;
17 fn input(&mut self, _program: &str, _args: &[String], _body: &str) -> Result<String> {
18 bail!("runner cannot exchange stdio metadata");
19 }
20 fn report(&mut self, program: &str, args: &[String]) -> Result<String> {
21 self.run(program, args)
22 }
23}
24pub struct System;
25impl Runner for System {
26 fn run(&mut self, program: &str, args: &[String]) -> Result<String> {
27 let out = Command::new(program)
28 .args(args)
29 .output()
30 .with_context(|| format!("start {program}"))?;
31 if !out.status.success() {
32 bail!(
33 "{program} refused or failed (exit {:?}); no success claimed",
34 out.status.code()
35 );
36 }
37 String::from_utf8(out.stdout).context("command returned non-UTF8 output")
38 }
39 fn input(&mut self, program: &str, args: &[String], body: &str) -> Result<String> {
40 use std::{
41 io::Read,
42 process::Stdio,
43 time::{Duration, Instant},
44 };
45 let mut child = Command::new(program)
46 .args(args)
47 .stdin(Stdio::piped())
48 .stdout(Stdio::piped())
49 .stderr(Stdio::null())
50 .spawn()?;
51 if let Err(error) = child
52 .stdin
53 .take()
54 .context("no metadata stdin")?
55 .write_all(body.as_bytes())
56 {
57 let _ = child.kill();
58 let _ = child.wait();
59 return Err(error.into());
60 }
61 let stdout = child.stdout.take().context("no metadata stdout")?;
62 let reader = std::thread::spawn(move || {
63 let mut out = Vec::new();
64 stdout
65 .take(4 * 1024 * 1024 + 1)
66 .read_to_end(&mut out)
67 .map(|_| out)
68 });
69 let deadline = Instant::now() + Duration::from_secs(10);
70 let status = loop {
71 match child.try_wait() {
72 Ok(Some(status)) => break status,
73 Ok(None) if Instant::now() < deadline => {
74 std::thread::sleep(Duration::from_millis(20))
75 }
76 _ => {
77 let _ = child.kill();
78 let _ = child.wait();
79 let _ = reader.join();
80 bail!("stdio metadata probe failed or exceeded deadline");
81 }
82 }
83 };
84 let out = reader
85 .join()
86 .map_err(|_| anyhow::anyhow!("metadata reader failed"))??;
87 if !status.success() || out.len() > 4 * 1024 * 1024 {
88 bail!("stdio metadata probe refused or exceeded output bound");
89 }
90 String::from_utf8(out).context("metadata response is not UTF8")
91 }
92 fn report(&mut self, program: &str, args: &[String]) -> Result<String> {
93 let out = Command::new(program)
94 .args(args)
95 .output()
96 .context("read owner report")?;
97 if !matches!(out.status.code(), Some(0..=2)) {
98 bail!("owner check could not run");
99 }
100 String::from_utf8(out.stdout).context("owner returned non-UTF8 report")
101 }
102}
103
104#[derive(Clone, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct Change {
107 pub path: PathBuf,
108 pub before: Option<String>,
109 pub after: String,
110}
111#[derive(Clone, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct Plan {
114 pub schema: u32,
115 pub root: PathBuf,
116 pub source_revision: String,
117 pub redirect_mode: super::RedirectMode,
118 pub harness_configs: Vec<PathBuf>,
119 pub skill_directories: Vec<PathBuf>,
120 pub changes: Vec<Change>,
121 pub bundles: Vec<Value>,
122 pub expected_tools: Value,
123 pub id: String,
124}
125
126pub fn digest(bytes: &[u8]) -> String {
127 format!("{:x}", Sha256::digest(bytes))
128}
129fn plan_id(p: &Plan) -> Result<String> {
130 let mut clone = p.clone();
131 clone.id.clear();
132 Ok(digest(&serde_json::to_vec(&clone)?))
133}
134fn read(path: &Path) -> Result<Option<String>> {
135 match fs::symlink_metadata(path) {
136 Ok(m) if !m.is_file() || m.file_type().is_symlink() => bail!(
137 "configuration destination must be a regular file: {}",
138 path.display()
139 ),
140 Ok(m) if m.len() > 4 * 1024 * 1024 => bail!("configuration exceeds 4MiB bound"),
141 Ok(_) => Ok(Some(
142 fs::read_to_string(path).context("read configuration")?,
143 )),
144 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
145 Err(e) => Err(e).context("inspect configuration destination"),
146 }
147}
148fn absolute(path: &Path) -> Result<PathBuf> {
149 let full = if path.is_absolute() {
150 path.to_path_buf()
151 } else {
152 std::env::current_dir()?.join(path)
153 };
154 if full
155 .components()
156 .any(|c| matches!(c, std::path::Component::ParentDir))
157 {
158 bail!("paths must not contain parent traversals");
159 }
160 if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
161 bail!("refusing symlink destination");
162 }
163 let mut ancestor = full.clone();
164 let mut tail = Vec::new();
165 while fs::symlink_metadata(&ancestor).is_err() {
166 tail.push(
167 ancestor
168 .file_name()
169 .context("path has no existing ancestor")?
170 .to_os_string(),
171 );
172 ancestor = ancestor
173 .parent()
174 .context("path has no existing ancestor")?
175 .to_path_buf();
176 }
177 let mut canonical = ancestor.canonicalize()?;
178 for part in tail.into_iter().rev() {
179 canonical.push(part);
180 }
181 Ok(canonical)
182}
183pub fn private_write(path: &Path, body: &[u8]) -> Result<()> {
184 use std::os::unix::fs::PermissionsExt;
185 let parent = path.parent().context("file has no parent")?;
186 fs::create_dir_all(parent)?;
187 if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) {
188 bail!("refusing symlink destination");
189 }
190 let mut file = tempfile::NamedTempFile::new_in(parent)?;
191 let mode = fs::metadata(path)
192 .map(|m| m.permissions().mode() & 0o777)
193 .unwrap_or(0o600);
194 file.as_file()
195 .set_permissions(fs::Permissions::from_mode(mode))?;
196 file.write_all(body)?;
197 file.as_file().sync_all()?;
198 file.persist(path).map_err(|e| e.error)?;
199 fs::File::open(parent)?.sync_all()?;
200 Ok(())
201}
202
203pub struct Create<'a> {
204 pub root: &'a Path,
205 pub bobbin: &'a Path,
206 pub person: &'a str,
207 pub output: &'a Path,
208 pub source: &'a Path,
209 pub claude_home: &'a Path,
210 pub codex_home: &'a Path,
211 pub issuer_name: Option<&'a str>,
212 pub redirect_mode: super::RedirectMode,
213}
214pub fn create(options: Create<'_>, runner: &mut impl Runner) -> Result<()> {
215 let Create {
216 root,
217 bobbin,
218 person,
219 output,
220 source,
221 claude_home,
222 codex_home,
223 issuer_name,
224 redirect_mode,
225 } = options;
226 use std::os::unix::fs::DirBuilderExt;
227 let output = absolute(output)?;
228 if output.exists() {
229 bail!("plan output already exists; retain it for review/resume");
230 }
231 let root = absolute(root)?;
232 let harness_configs = vec![
233 absolute(&claude_home.join("settings.json"))?,
234 absolute(&codex_home.join("config.toml"))?,
235 ];
236 check_existing_redirect(&harness_configs)?;
237 let mut skill_directories = Vec::new();
238 let mut changes = Vec::new();
239 let crew_path = root.join("shantytown.toml");
240 let bobbin = absolute(bobbin)?;
241 let crew_before = read(&crew_path)?;
242 let bobbin_before = read(&bobbin)?;
243 let (mut crew, config) = super::render_with_mode(
244 crew_before.as_deref().unwrap_or(""),
245 bobbin_before.as_deref().unwrap_or(""),
246 person,
247 redirect_mode,
248 )?;
249 let capture_path = output.join("capture.json");
250 let mut document: toml_edit::DocumentMut = crew.parse()?;
251 document["env"]["CAMAYOC_CAPTURE_CONFIG"] =
252 toml_edit::value(capture_path.display().to_string());
253 crew = document.to_string();
254 let mut issuers = serde_json::Map::new();
255 if let Some(name) = issuer_name {
256 if name.trim().is_empty() || name.len() > 128 || name.chars().any(char::is_control) {
257 bail!("invalid issuer name");
258 }
259 issuers.insert(name.to_string(), json!(person));
260 }
261 changes.push(Change {
262 path: capture_path,
263 before: None,
264 after: serde_json::to_string_pretty(&json!({"prompt_author":person,"issuers":issuers}))?,
265 });
266 changes.push(Change {
267 path: crew_path,
268 before: crew_before,
269 after: crew,
270 });
271 changes.push(Change {
272 path: bobbin,
273 before: bobbin_before,
274 after: config,
275 });
276 let source = absolute(source)?;
279 let revision = runner
280 .run(
281 "git",
282 &[
283 "-C".into(),
284 source.display().to_string(),
285 "rev-parse".into(),
286 "HEAD".into(),
287 ],
288 )?
289 .trim()
290 .to_string();
291 if revision.len() != 40 || !revision.bytes().all(|b| b.is_ascii_hexdigit()) {
292 bail!("source has no full Git revision");
293 }
294 for (relative, destination) in [
295 (
296 "skills/camayoc-memory/SKILL.md",
297 claude_home.join("skills/camayoc-memory/SKILL.md"),
298 ),
299 (
300 ".agents/skills/camayoc-memory/SKILL.md",
301 codex_home.join("skills/camayoc-memory/SKILL.md"),
302 ),
303 ] {
304 let after = runner.run(
305 "git",
306 &[
307 "-C".into(),
308 source.display().to_string(),
309 "show".into(),
310 format!("{revision}:{relative}"),
311 ],
312 )?;
313 if after.len() > 1024 * 1024 || !after.contains("cy record") {
314 bail!("source does not contain the generated memory skill");
315 }
316 let path = absolute(&destination)?;
317 skill_directories.push(
318 path.parent()
319 .context("skill has no directory")?
320 .to_path_buf(),
321 );
322 changes.push(Change {
323 before: read(&path)?,
324 path,
325 after,
326 });
327 }
328 let catalogue = runner.run(
329 "git",
330 &[
331 "-C".into(),
332 source.display().to_string(),
333 "show".into(),
334 format!("{revision}:camayoc_cli/data/mcp-tools.json"),
335 ],
336 )?;
337 if catalogue.len() > 4 * 1024 * 1024 {
338 bail!("producer tool catalogue exceeds bound");
339 }
340 let catalogue: Value = serde_json::from_str(&catalogue)?;
341 if catalogue["schema"] != "camayoc-mcp-tools/1"
342 || catalogue["server"] != "camayoc-memory"
343 || !catalogue["tools"].as_array().is_some_and(|t| !t.is_empty())
344 {
345 bail!("unsupported producer catalogue");
346 }
347 let mut paths = std::collections::BTreeSet::new();
348 for change in &changes {
349 if !paths.insert(&change.path) {
350 bail!("memory destinations overlap");
351 }
352 }
353 let candidate = runner
354 .run("bobbin", &["hooks".into(), "bundle".into()])
355 .ok();
356 let bobbin_bundle =
357 crate::tool_bundles::resolve_with(&crate::hook_bundles::selected(["bobbin"]), |_| {
358 candidate.clone()
359 });
360 let mut plan = Plan {
361 schema: 1,
362 root,
363 source_revision: revision,
364 redirect_mode,
365 harness_configs,
366 skill_directories,
367 changes,
368 expected_tools: catalogue["tools"].clone(),
369 bundles: vec![
370 serde_json::from_str(&bobbin_bundle[0].1)?,
371 super::hooks::bundle(&output, redirect_mode),
372 ],
373 id: String::new(),
374 };
375 plan.id = plan_id(&plan)?;
376 fs::DirBuilder::new()
377 .recursive(true)
378 .mode(0o700)
379 .create(&output)?;
380 private_write(
381 &output.join("plan.json"),
382 &serde_json::to_vec_pretty(&plan)?,
383 )?;
384 for (i, bundle) in plan.bundles.iter().enumerate() {
385 private_write(
386 &output.join(format!("bundle-{i}.json")),
387 &serde_json::to_vec_pretty(bundle)?,
388 )?;
389 }
390 Ok(())
391}
392
393pub fn load(directory: &Path) -> Result<Plan> {
394 use std::os::unix::fs::{MetadataExt, PermissionsExt};
395 let path = directory.join("plan.json");
396 let meta = fs::symlink_metadata(&path)?;
397 let dir = fs::symlink_metadata(directory)?;
398 if meta.file_type().is_symlink()
399 || !meta.is_file()
400 || meta.len() > 16 * 1024 * 1024
401 || meta.permissions().mode() & 0o077 != 0
402 || dir.file_type().is_symlink()
403 || !dir.is_dir()
404 || dir.permissions().mode() & 0o077 != 0
405 || dir.uid() != meta.uid()
406 {
407 bail!("memory plan must be private regular data in its private directory");
408 }
409 let plan: Plan = serde_json::from_slice(&fs::read(path)?)?;
410 if plan.schema != 1 || plan.id != plan_id(&plan)? {
411 bail!("memory plan digest/schema does not match; regenerate for review");
412 }
413 Ok(plan)
414}
415fn st(plan: &Plan, args: &[&str], runner: &mut impl Runner) -> Result<String> {
416 let mut argv = vec!["--root".into(), plan.root.display().to_string()];
417 argv.extend(args.iter().map(|s| (*s).to_string()));
418 runner.run("st", &argv)
419}
420fn existing(plan: &Plan, runner: &mut impl Runner) -> Result<Vec<Value>> {
421 let v: Value = serde_json::from_str(&st(plan, &["ops", "hooks", "list", "--json"], runner)?)?;
422 if !v["errors"].as_array().is_some_and(Vec::is_empty) {
423 bail!("settings-owner registry unreadable");
424 }
425 Ok(v["bundles"]
426 .as_array()
427 .context("owner returned no bundles")?
428 .clone())
429}
430fn normalized(bundle: &Value) -> Value {
431 json!({"name":bundle["name"],"version":bundle["version"],"owner":bundle["owner"],"roles":bundle["roles"],
432 "hooks":bundle["hooks"].as_array().map(|rows| rows.iter().map(|r| json!({
433 "event":r["event"],"command":r["command"],"matcher":r["matcher"],"timeout":r["timeout"],"harnesses":r.get("harnesses").cloned().unwrap_or_else(|| json!(["claude","codex","opencode"]))})).collect::<Vec<_>>())})
434}
435fn journal(directory: &Path, phase: &str) -> Result<()> {
436 private_write(
437 &directory.join("state.json"),
438 &serde_json::to_vec(&json!({"schema":1,"phase":phase,
439 "at":std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs_f64()}))?,
440 )
441}
442fn lock(directory: &Path) -> Result<fs::File> {
443 use std::os::unix::fs::OpenOptionsExt;
444 let path = directory.join("apply.lock");
445 if fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink() || !m.is_file()) {
446 bail!("invalid memory lifecycle lock");
447 }
448 let file = fs::OpenOptions::new()
449 .read(true)
450 .write(true)
451 .create(true)
452 .truncate(false)
453 .mode(0o600)
454 .open(path)?;
455 file.try_lock_exclusive()
456 .context("another memory lifecycle operation is running")?;
457 Ok(file)
458}
459pub fn check_tools(plan: &Plan, runner: &mut impl Runner) -> Result<()> {
460 let requests=concat!("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"tambo-memory-verify\",\"version\":\"1\"}}}\n",
461 "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n",
462 "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n");
463 let response = runner.input("cy", &["mcp".into()], requests)?;
464 let rows: Vec<Value> = response
465 .lines()
466 .filter(|l| !l.trim().is_empty())
467 .map(serde_json::from_str)
468 .collect::<std::result::Result<_, _>>()?;
469 let init = rows
470 .iter()
471 .find(|r| r["id"] == 1)
472 .context("MCP initialization response missing")?;
473 let list = rows
474 .iter()
475 .find(|r| r["id"] == 2)
476 .context("MCP tools response missing")?;
477 if init["result"]["serverInfo"]["name"] != "camayoc-memory"
478 || list["result"]["tools"] != plan.expected_tools
479 {
480 bail!("installed Camayoc tool schemas differ from reviewed producer revision");
481 }
482 Ok(())
483}
484fn exact_bundles(plan: &Plan, directory: &Path, current: &[Value], prior: &[Value]) -> Result<()> {
485 for (i, wanted) in plan.bundles.iter().enumerate() {
486 let name = wanted["name"].as_str().context("bundle name missing")?;
487 let old = prior.iter().find(|r| r["name"] == name);
488 if let Some(old) = old {
489 let backup: Value = serde_json::from_str(
490 &read(&directory.join(format!("prior-{i}.json")))?
491 .context("prior bundle backup missing")?,
492 )?;
493 if &backup != old {
494 bail!("prior bundle backup changed; preserve it for inspection");
495 }
496 }
497 if current.iter().any(|r| r["name"] == name) {
498 let raw: Value = serde_json::from_str(
499 &read(&plan.root.join("hook-bundles").join(format!("{name}.json")))?
500 .context("owner bundle bytes unavailable")?,
501 )?;
502 if raw != *wanted && old != Some(&raw) {
503 bail!("registered bundle bytes changed; preserve operator edits");
504 }
505 }
506 }
507 Ok(())
508}
509pub fn apply(directory: &Path, runner: &mut impl Runner) -> Result<()> {
510 let plan = load(directory)?;
511 let _lock = lock(directory)?;
512 check_existing_redirect(&plan.harness_configs)?;
513 let saved = directory.join("registry-before.json");
514 let current = existing(&plan, runner)?;
515 if !saved.exists() {
516 if current.iter().any(|r| r["name"] == BUNDLE_NAME) {
517 bail!("memory bundle already owned by another plan; restore it first");
518 }
519 let mut snapshot = current.clone();
520 for (i, wanted) in plan.bundles.iter().enumerate() {
521 if let Some(prior) = current.iter().find(|r| r["name"] == wanted["name"]) {
522 let name = prior["name"]
523 .as_str()
524 .context("owner bundle name missing")?;
525 if !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
526 bail!("invalid bundle name");
527 }
528 let raw = read(&plan.root.join("hook-bundles").join(format!("{name}.json")))?
529 .context("exact prior bundle unavailable; no lossless rollback")?;
530 let value: Value = serde_json::from_str(&raw)?;
531 if normalized(&value) != normalized(prior) {
532 bail!("owner registry changed during snapshot");
533 }
534 if let Some(slot) = snapshot.iter_mut().find(|r| r["name"] == wanted["name"]) {
535 *slot = value;
536 }
537 private_write(&directory.join(format!("prior-{i}.json")), raw.as_bytes())?;
538 }
539 }
540 private_write(&saved, &serde_json::to_vec(&snapshot)?)?;
541 }
542 let prior: Vec<Value> = serde_json::from_slice(&fs::read(&saved)?)?;
543 for (i, wanted) in plan.bundles.iter().enumerate() {
544 let path = directory.join(format!("bundle-{i}.json"));
545 if serde_json::from_slice::<Value>(&fs::read(&path)?)? != *wanted {
546 bail!("staged bundle differs from reviewed plan");
547 }
548 if let Some(now) = current.iter().find(|r| r["name"] == wanted["name"]) {
549 if normalized(now) != normalized(wanted)
550 && prior
551 .iter()
552 .find(|r| r["name"] == wanted["name"])
553 .map(normalized)
554 .as_ref()
555 != Some(&normalized(now))
556 {
557 bail!("registered bundle changed since plan application");
558 }
559 }
560 }
561 exact_bundles(&plan, directory, ¤t, &prior)?;
562 for c in &plan.changes {
564 let now = read(&c.path)?;
565 if now != c.before && now.as_deref() != Some(c.after.as_str()) {
566 bail!(
567 "configuration changed since plan review: {}",
568 c.path.display()
569 );
570 }
571 }
572 for args in [
573 vec!["--version"],
574 vec!["record", "decision", "--help"],
575 vec!["record", "directive", "--help"],
576 vec!["record", "preference", "--help"],
577 vec!["record", "handoff", "--help"],
578 vec!["record", "reference", "--help"],
579 vec!["hook", "memory-redirect", "--help"],
580 vec!["flush", "--help"],
581 ] {
582 runner.run("cy", &args.iter().map(|s| (*s).to_string()).collect::<Vec<_>>())
583 .context("install a current Camayoc CLI with its generated memory tools before applying this plan")?;
584 }
585 let help = runner.run("cy", &["flush".into(), "--help".into()])?;
586 if !help.contains("--hook") {
587 bail!("Camayoc lacks bounded hook replay; update it before applying");
588 }
589 runner.run(
590 "cy",
591 &["hook".into(), "session-end".into(), "--help".into()],
592 )?;
593 runner.run(
594 "bobbin",
595 &["hook".into(), "memory-refresh".into(), "--help".into()],
596 )?;
597 check_tools(&plan, runner)?;
598 let created = directory.join("created-skill-directories.json");
599 let restored = read(&directory.join("state.json"))?
600 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
601 .is_some_and(|state| state["phase"] == "restored");
602 if !created.exists() || restored {
603 let missing: Vec<_> = plan
604 .skill_directories
605 .iter()
606 .filter(|p| !p.exists())
607 .collect();
608 private_write(&created, &serde_json::to_vec(&missing)?)?;
609 }
610 journal(directory, "applying")?;
611 for c in &plan.changes {
612 if read(&c.path)?.as_deref() != Some(c.after.as_str()) {
613 private_write(&c.path, c.after.as_bytes())?;
614 }
615 }
616 for (i, bundle) in plan.bundles.iter().enumerate() {
617 let path = directory.join(format!("bundle-{i}.json"));
618 let _ = bundle;
619 st(
620 &plan,
621 &[
622 "ops",
623 "hooks",
624 "register",
625 path.to_str().context("non-UTF8 plan path")?,
626 ],
627 runner,
628 )?;
629 }
630 let receipt: Value =
631 serde_json::from_str(&st(&plan, &["ops", "provision", "--json"], runner)?)?;
632 if receipt["version"] != 1
633 || receipt["owner"] != "shantytown"
634 || !receipt["agents"].as_array().is_some_and(|a| !a.is_empty())
635 {
636 bail!("settings owner returned no verified nonempty registration receipt");
637 }
638 for c in &plan.changes {
639 if read(&c.path)?.as_deref() != Some(c.after.as_str()) {
640 bail!("configuration read-back differs");
641 }
642 }
643 for wanted in &plan.bundles {
644 let name = wanted["name"].as_str().context("bundle has no name")?;
645 let raw = read(&plan.root.join("hook-bundles").join(format!("{name}.json")))?
646 .context("registered bundle missing on read-back")?;
647 if serde_json::from_str::<Value>(&raw)? != *wanted {
648 bail!("registered bundle read-back differs");
649 }
650 }
651 journal(directory, "applied")
652}
653
654pub fn restore(directory: &Path, runner: &mut impl Runner) -> Result<()> {
655 let plan = load(directory)?;
656 let _lock = lock(directory)?;
657 let created_dirs: Vec<PathBuf> = match read(&directory.join("created-skill-directories.json"))?
658 {
659 Some(text) => serde_json::from_str(&text)?,
660 None => Vec::new(),
661 };
662 if created_dirs
663 .iter()
664 .any(|p| !plan.skill_directories.contains(p))
665 {
666 bail!("created skill directory is outside this plan");
667 }
668 let saved: Vec<Value> = serde_json::from_slice(
669 &fs::read(directory.join("registry-before.json")).context("plan was never applied")?,
670 )?;
671 let current = existing(&plan, runner)?;
672 for c in &plan.changes {
673 let now = read(&c.path)?;
674 if now != c.before && now.as_deref() != Some(c.after.as_str()) {
675 bail!(
676 "preserve edited configuration; restore refused: {}",
677 c.path.display()
678 );
679 }
680 }
681 for wanted in &plan.bundles {
682 if let Some(now) = current.iter().find(|r| r["name"] == wanted["name"]) {
683 let prior = saved.iter().find(|r| r["name"] == wanted["name"]);
684 if normalized(now) != normalized(wanted)
685 && prior.map(normalized).as_ref() != Some(&normalized(now))
686 {
687 bail!("registered bundle changed; restore refused");
688 }
689 }
690 }
691 exact_bundles(&plan, directory, ¤t, &saved)?;
692 journal(directory, "restoring")?;
693 for (i, wanted) in plan.bundles.iter().enumerate() {
694 let name = wanted["name"].as_str().context("bundle has no name")?;
695 if let Some(old) = saved.iter().find(|r| r["name"] == name) {
696 let path = directory.join(format!("prior-{i}.json"));
699 let _ = old;
700 if !path.exists() {
701 bail!("exact prior bundle required for lossless restore");
702 }
703 st(
704 &plan,
705 &[
706 "ops",
707 "hooks",
708 "register",
709 path.to_str().context("non-UTF8 path")?,
710 ],
711 runner,
712 )?;
713 } else if current.iter().any(|r| r["name"] == name) {
714 st(&plan, &["ops", "hooks", "unregister", name], runner)?;
715 }
716 }
717 for c in &plan.changes {
718 if read(&c.path)? == c.before {
719 continue;
720 }
721 if let Some(before) = &c.before {
722 private_write(&c.path, before.as_bytes())?;
723 } else if c.path.exists() {
724 fs::remove_file(&c.path)?;
725 }
726 }
727 for path in created_dirs {
728 match fs::symlink_metadata(&path) {
729 Ok(meta) if meta.is_dir() && fs::read_dir(&path)?.next().is_none() => {
730 fs::remove_dir(&path)?
731 }
732 Ok(_) => {}
733 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
734 Err(error) => return Err(error.into()),
735 }
736 }
737 st(&plan, &["ops", "provision", "--json"], runner)?;
738 journal(directory, "restored")
739}
740
741pub fn check_existing_redirect(paths: &[PathBuf]) -> Result<()> {
744 fn command(value: &Value) -> bool {
745 match value {
746 Value::Object(fields) => fields.iter().any(|(key, val)| {
747 (key == "command"
748 && val
749 .as_str()
750 .is_some_and(|s| s.contains("hook memory-redirect")))
751 || command(val)
752 }),
753 Value::Array(values) => values.iter().any(command),
754 _ => false,
755 }
756 }
757 for path in paths {
758 let Some(text) = read(path)? else {
759 continue;
760 };
761 if text.contains("# >>> tambo: camayoc memory-redirect hook") {
762 bail!("standalone memory redirect installed in {}; uninstall it with scripts/memory-redirect-hook.py before planning/applying", path.display());
763 }
764 let value: Value = if path.extension().is_some_and(|s| s == "json") {
765 serde_json::from_str(&text).context("parse existing Claude settings")?
766 } else {
767 serde_json::to_value(
768 text.parse::<toml::Value>()
769 .context("parse existing Codex settings")?,
770 )?
771 };
772 if command(&value) {
773 bail!("standalone memory redirect installed in {}; uninstall it with scripts/memory-redirect-hook.py before planning/applying", path.display());
774 }
775 }
776 Ok(())
777}