use std::path::{Path, PathBuf};
use super::config::expand_path;
use super::error::{PodError, Result};
use super::journal::{self, Authority, CommitOutcome, StepState, TransitionState};
use super::tmux::{SessionHook, Tmux, current_exe, forge_hook};
pub const SHIM_PATH_ENV: &str = "FORGE_POD_SHIM_PATH";
pub const FAIL_AT_ENV: &str = "FORGE_POD_ADOPT_FAIL_AT";
const UNSET_SENTINEL: &str = "\u{0}UNSET";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookOwner {
Absent,
Cosmux,
Forge,
Foreign,
}
pub fn classify_hook(value: Option<&str>) -> HookOwner {
let Some(value) = value.map(str::trim).filter(|v| !v.is_empty()) else {
return HookOwner::Absent;
};
let is_recovery = value.contains("_pane-recover") || value.contains("_after-detach");
if !is_recovery {
return HookOwner::Foreign;
}
if value.contains(" pod _pane-recover") || value.contains(" pod _after-detach") {
HookOwner::Forge
} else {
HookOwner::Cosmux
}
}
pub fn adopt_preflight(cosmux_running: bool, mid_respawn_panes: bool) -> Result<()> {
if cosmux_running {
return Err(PodError::Other(anyhow::anyhow!(
"refusing to adopt while a `cosmux` process is running — it could write the store \
concurrently. Stop cosmux, then re-run `forge pod adopt`."
)));
}
if mid_respawn_panes {
return Err(PodError::Other(anyhow::anyhow!(
"refusing to adopt while a pane is mid-respawn — its recovery could write the store. \
Let it settle, then re-run `forge pod adopt`."
)));
}
Ok(())
}
fn injection_enabled() -> bool {
std::env::var_os(journal::JOURNAL_DIR_ENV).is_some_and(|v| !v.is_empty())
}
fn crash_if(marker: &str) -> Result<()> {
if !injection_enabled() {
return Ok(());
}
if std::env::var(FAIL_AT_ENV).ok().as_deref() == Some(marker) {
return Err(PodError::Other(anyhow::anyhow!(
"injected crash at {marker}"
)));
}
Ok(())
}
fn is_injected(error: &PodError) -> bool {
error.to_string().contains("injected crash at")
}
pub fn shim_path() -> PathBuf {
match std::env::var_os(SHIM_PATH_ENV) {
Some(p) if !p.is_empty() => PathBuf::from(p),
_ => expand_path("~/.local/bin/cosmux"),
}
}
fn shim_contents(exe: &Path) -> String {
format!(
"#!/usr/bin/env bash\n\
# forge pod adoption shim — installed by `forge pod adopt` (consolidation RFC §1.5).\n\
# Redirects `cosmux <args>` to `forge pod <args>` so exactly one binary writes the store.\n\
# Removed by `forge pod unadopt` / the standalone rollback script.\n\
exec '{}' pod \"$@\"\n",
exe.display()
)
}
#[cfg(test)]
fn assert_shim_write_allowed(path: &Path) {
let temp = std::env::temp_dir();
assert!(
path.starts_with(&temp),
"REFUSING to install the shim outside the temp dir during tests: {}\n\
Set {SHIM_PATH_ENV} to a temp path in this test.",
path.display()
);
}
#[cfg(not(test))]
fn assert_shim_write_allowed(_path: &Path) {}
fn install_shim(exe: &Path) -> Result<()> {
let path = shim_path();
assert_shim_write_allowed(&path);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, shim_contents(exe))?;
set_executable(&path)?;
Ok(())
}
fn remove_shim() -> Result<()> {
let path = shim_path();
assert_shim_write_allowed(&path);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
fn shim_installed() -> bool {
use std::os::unix::fs::PermissionsExt;
let path = shim_path();
match std::fs::metadata(&path) {
Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(perms.mode() | 0o755);
std::fs::set_permissions(path, perms)?;
Ok(())
}
pub fn hooks_backup_path() -> PathBuf {
journal::journal_dir().join("pod-adoption.hooks")
}
pub fn plan_backup(
current: &[(SessionHook, Option<String>)],
already: &std::collections::HashSet<String>,
) -> Vec<(SessionHook, String)> {
current
.iter()
.filter_map(|(kind, value)| {
if already.contains(kind.tmux_name()) {
return None;
}
match classify_hook(value.as_deref()) {
HookOwner::Cosmux => value.clone().map(|v| (*kind, v)),
_ => None,
}
})
.collect()
}
fn backed_up_hooks(session: &str) -> Result<std::collections::HashSet<String>> {
Ok(read_hook_backup()?
.into_iter()
.filter(|r| r.session == session)
.map(|r| r.hook)
.collect())
}
fn append_hook_backup(session: &str, originals: &[(SessionHook, String)]) -> Result<()> {
use std::io::Write as _;
let path = hooks_backup_path();
assert_shim_write_allowed_journal(&path);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
for (kind, value) in originals {
debug_assert!(!value.contains('\n') && !value.contains('\t'));
writeln!(file, "{}\t{}\t{}", session, kind.tmux_name(), value)?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRestore {
pub session: String,
pub hook: String,
pub value: Option<String>,
}
pub fn parse_hook_backup(text: &str) -> Vec<HookRestore> {
let mut out: Vec<HookRestore> = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let mut parts = line.splitn(3, '\t');
let (Some(session), Some(hook), Some(value)) = (parts.next(), parts.next(), parts.next())
else {
continue;
};
let value = if value == UNSET_SENTINEL {
None
} else {
Some(value.to_string())
};
out.push(HookRestore {
session: session.to_string(),
hook: hook.to_string(),
value,
});
}
out
}
fn read_hook_backup() -> Result<Vec<HookRestore>> {
match std::fs::read_to_string(hooks_backup_path()) {
Ok(text) => Ok(parse_hook_backup(&text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e.into()),
}
}
fn remove_hook_backup() -> Result<()> {
let path = hooks_backup_path();
assert_shim_write_allowed_journal(&path);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
fn assert_shim_write_allowed_journal(path: &Path) {
let temp = std::env::temp_dir();
assert!(
path.starts_with(&temp),
"REFUSING to write the hook backup outside the temp dir during tests: {}",
path.display()
);
}
#[cfg(not(test))]
fn assert_shim_write_allowed_journal(_path: &Path) {}
fn hook_name_to_kind(name: &str) -> Option<SessionHook> {
SessionHook::ALL.into_iter().find(|k| k.tmux_name() == name)
}
pub fn adopt() -> Result<i32> {
Tmux::ensure_installed()?;
match journal::authority() {
Authority::Authorized => {
println!("already adopted — forge is the production writer");
return Ok(0);
}
Authority::InTransition(TransitionState::Adopting) => {
println!("resuming an interrupted adoption…");
return drive_adopt_steps();
}
Authority::InTransition(_) => {
return Err(PodError::Other(anyhow::anyhow!(
"an unadoption is in progress — run `forge pod unadopt` or `forge pod adopt --abort` first"
)));
}
Authority::Recovery(why) => {
return Err(PodError::Other(anyhow::anyhow!(
"adoption journal is in recovery: {why}\nRun `forge pod adopt --repair`."
)));
}
Authority::Unadopted => {}
}
adopt_preflight(cosmux_processes_present(), mid_respawn_panes_present()?)?;
journal::with_lock(|lock| lock.write(&journal::Journal::new(TransitionState::Adopting)))?;
drive_adopt_steps()
}
fn drive_adopt_steps() -> Result<i32> {
let exe = current_exe();
if let Err(e) = reconcile_shim_step(&exe) {
return stop_or_propagate(e, "shim");
}
let sessions = Tmux::list_sessions()?;
for session in &sessions {
if let Err(e) = reconcile_hook_step(&exe, session) {
return stop_or_propagate(e, "hooks");
}
}
if let Err(e) = drain_recoveries(&exe) {
return stop_or_propagate(e, "drain");
}
loop {
crash_if("terminal/before")?;
match journal::commit_terminal()? {
CommitOutcome::Committed => {
crash_if("terminal/after")?;
println!("✓ adopted — forge is now the production writer");
return Ok(0);
}
CommitOutcome::PendingRecoveries(_) => {
drain_recoveries(&exe)?;
continue;
}
CommitOutcome::IncompleteSteps => {
return Err(PodError::Other(anyhow::anyhow!(
"cannot commit: a ledger step is still pending — re-run `forge pod adopt --resume`"
)));
}
CommitOutcome::NotAdopting => {
return Err(PodError::Other(anyhow::anyhow!(
"journal is not in `adopting` — nothing to commit"
)));
}
}
}
}
fn stop_or_propagate(error: PodError, step: &str) -> Result<i32> {
if is_injected(&error) {
eprintln!("adoption interrupted during `{step}` ({error}); state is durable — `--resume`");
return Err(error);
}
Err(error)
}
fn reconcile_shim_step(exe: &Path) -> Result<()> {
crash_if("shim/before-pending")?;
set_shim_state(StepState::Pending)?;
crash_if("shim/after-pending")?;
if !shim_installed() {
install_shim(exe)?;
}
if !shim_installed() {
return Err(PodError::Other(anyhow::anyhow!(
"shim install did not produce an executable at {}",
shim_path().display()
)));
}
crash_if("shim/after-side-effect")?;
set_shim_state(StepState::Complete)?;
crash_if("shim/after-complete")?;
Ok(())
}
fn reconcile_hook_step(exe: &Path, session: &str) -> Result<()> {
let current: Vec<(SessionHook, Option<String>)> = SessionHook::ALL
.into_iter()
.map(|k| (k, Tmux::show_hook(session, k)))
.collect();
let owns_cosmux = current
.iter()
.any(|(_, v)| classify_hook(v.as_deref()) == HookOwner::Cosmux);
let already_forge = current.iter().all(|(_, v)| {
matches!(
classify_hook(v.as_deref()),
HookOwner::Forge | HookOwner::Absent | HookOwner::Foreign
)
});
if !owns_cosmux && step_hook_state(session)?.is_none() {
return Ok(());
}
let marker = format!("hooks:{session}");
crash_if(&format!("{marker}/before-pending"))?;
set_hook_state(session, StepState::Pending)?;
crash_if(&format!("{marker}/after-pending"))?;
if owns_cosmux {
let already = backed_up_hooks(session)?;
let to_backup = plan_backup(¤t, &already);
if !to_backup.is_empty() {
append_hook_backup(session, &to_backup)?;
}
for (kind, _) in ¤t {
if classify_hook(Tmux::show_hook(session, *kind).as_deref()) == HookOwner::Cosmux {
Tmux::set_hook(session, *kind, &forge_hook(exe, *kind, session))?;
}
}
}
for kind in SessionHook::ALL {
if classify_hook(Tmux::show_hook(session, kind).as_deref()) == HookOwner::Cosmux {
return Err(PodError::Other(anyhow::anyhow!(
"session '{session}' hook {} still points at cosmux after rebind",
kind.tmux_name()
)));
}
}
let _ = already_forge;
crash_if(&format!("{marker}/after-side-effect"))?;
set_hook_state(session, StepState::Complete)?;
crash_if(&format!("{marker}/after-complete"))?;
Ok(())
}
fn drain_recoveries(exe: &Path) -> Result<()> {
let pending = journal::with_lock(|lock| {
Ok(lock
.read()?
.map(|j| {
j.pending_recoveries()
.into_iter()
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default())
})?;
for pane_id in pending {
let session = pane_id.split(':').next().unwrap_or(&pane_id).to_string();
let _ = exe; super::recover::pane_recover_inline(&session)?;
journal::complete_recovery(&pane_id)?;
}
Ok(())
}
pub fn unadopt() -> Result<i32> {
Tmux::ensure_installed()?;
match journal::authority() {
Authority::Unadopted => {
println!("not adopted — nothing to roll back");
return Ok(0);
}
Authority::Recovery(why) => {
return Err(PodError::Other(anyhow::anyhow!(
"journal is in recovery: {why}\nRun `forge pod adopt --repair`."
)));
}
_ => {}
}
rollback_surfaces()?;
println!("✓ unadopted — cosmux is the production writer again");
Ok(0)
}
pub fn repair() -> Result<i32> {
Tmux::ensure_installed()?;
rollback_surfaces()?;
println!("✓ repaired — rolled back to unadopted; cosmux is the production writer");
Ok(0)
}
pub fn abort() -> Result<i32> {
repair()
}
fn rollback_surfaces() -> Result<()> {
journal::with_lock(|lock| {
let mut journal = journal::Journal::new(TransitionState::Unadopting);
journal.touch();
lock.write(&journal)
})?;
let restores = read_hook_backup()?;
for restore in &restores {
let Some(kind) = hook_name_to_kind(&restore.hook) else {
continue;
};
match &restore.value {
Some(value) => Tmux::set_hook(&restore.session, kind, value)?,
None => Tmux::unset_hook(&restore.session, kind)?,
}
}
for restore in &restores {
let Some(kind) = hook_name_to_kind(&restore.hook) else {
continue;
};
let now = classify_hook(Tmux::show_hook(&restore.session, kind).as_deref());
if now == HookOwner::Forge {
return Err(PodError::Other(anyhow::anyhow!(
"rollback did not restore session '{}' hook {} off forge",
restore.session,
restore.hook
)));
}
}
remove_shim()?;
remove_hook_backup()?;
journal::with_lock(|lock| lock.remove())?;
Ok(())
}
fn set_shim_state(state: StepState) -> Result<()> {
journal::with_lock(|lock| {
if let Some(mut journal) = lock.read()? {
journal.steps.shim = state;
journal.touch();
lock.write(&journal)?;
}
Ok(())
})
}
fn set_hook_state(session: &str, state: StepState) -> Result<()> {
journal::with_lock(|lock| {
if let Some(mut journal) = lock.read()? {
journal.steps.hooks.insert(session.to_string(), state);
journal.touch();
lock.write(&journal)?;
}
Ok(())
})
}
fn step_hook_state(session: &str) -> Result<Option<StepState>> {
journal::with_lock(|lock| {
Ok(lock
.read()?
.and_then(|j| j.steps.hooks.get(session).copied()))
})
}
fn cosmux_processes_present() -> bool {
std::process::Command::new("pgrep")
.args(["-x", "cosmux"])
.output()
.map(|out| out.status.success() && !out.stdout.is_empty())
.unwrap_or(false)
}
fn mid_respawn_panes_present() -> Result<bool> {
for session in Tmux::list_sessions()? {
let out = Tmux::run(&["list-panes", "-t", &session, "-s", "-F", "#{pane_dead}"]);
if let Ok(out) = out
&& String::from_utf8_lossy(&out.stdout)
.lines()
.any(|l| l == "1")
{
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_the_three_hook_owners_and_foreign() {
assert_eq!(classify_hook(None), HookOwner::Absent);
assert_eq!(classify_hook(Some(" ")), HookOwner::Absent);
assert_eq!(
classify_hook(Some(
"run-shell '/usr/bin/cosmux _pane-recover demo >> /tmp/cosmux-demo.log 2>&1'"
)),
HookOwner::Cosmux
);
assert_eq!(
classify_hook(Some(
"run-shell '/home/x/.local/bin/forge pod _pane-recover demo >> /tmp/forge-pod-demo.log 2>&1'"
)),
HookOwner::Forge
);
assert_eq!(
classify_hook(Some("run-shell 'my-own-monitor.sh'")),
HookOwner::Foreign
);
}
#[test]
fn a_forge_hook_string_round_trips_to_forge_ownership() {
let hook = forge_hook(Path::new("/opt/forge"), SessionHook::PaneDied, "sess");
assert_eq!(classify_hook(Some(&hook)), HookOwner::Forge);
let hook = forge_hook(Path::new("/opt/forge"), SessionHook::ClientDetached, "sess");
assert_eq!(classify_hook(Some(&hook)), HookOwner::Forge);
}
#[test]
fn preflight_refuses_both_unsafe_conditions_and_passes_when_clear() {
assert!(adopt_preflight(false, false).is_ok());
assert!(
adopt_preflight(true, false)
.unwrap_err()
.to_string()
.contains("cosmux` process")
);
assert!(
adopt_preflight(false, true)
.unwrap_err()
.to_string()
.contains("mid-respawn")
);
}
#[test]
fn hook_backup_round_trips_including_the_unset_sentinel() {
let text =
format!("demo\tpane-died\trun-shell \"x\"\ndemo\tclient-detached\t{UNSET_SENTINEL}\n");
let restores = parse_hook_backup(&text);
assert_eq!(restores.len(), 2);
assert_eq!(restores[0].value.as_deref(), Some("run-shell \"x\""));
assert_eq!(
restores[1].value, None,
"the unset sentinel must parse back to a `set-hook -u`"
);
}
#[test]
fn backup_captures_only_cosmux_originals_never_an_already_rebound_forge_hook() {
let cosmux_pd =
"run-shell '/usr/bin/cosmux _pane-recover s >> /tmp/cosmux-s.log 2>&1'".to_string();
let forge_pd = forge_hook(Path::new("/opt/forge"), SessionHook::PaneDied, "s");
let cosmux_cd =
"run-shell '/usr/bin/cosmux _after-detach s >> /tmp/cosmux-s.log 2>&1'".to_string();
let current = vec![
(SessionHook::PaneDied, Some(cosmux_pd.clone())),
(SessionHook::ClientDetached, Some(cosmux_cd.clone())),
];
let plan = plan_backup(¤t, &std::collections::HashSet::new());
assert_eq!(plan.len(), 2);
let already: std::collections::HashSet<String> = ["pane-died".to_string()].into();
let current_partial = vec![
(SessionHook::PaneDied, Some(forge_pd)),
(SessionHook::ClientDetached, Some(cosmux_cd.clone())),
];
let plan = plan_backup(¤t_partial, &already);
assert_eq!(
plan.len(),
1,
"only the un-captured cosmux hook is backed up"
);
assert_eq!(plan[0].0, SessionHook::ClientDetached);
assert!(
plan[0].1.contains("/usr/bin/cosmux"),
"must capture the cosmux original, not forge: {}",
plan[0].1
);
}
#[test]
fn hook_backup_skips_blank_and_malformed_lines() {
let restores = parse_hook_backup("\n\ngarbage-no-tabs\ndemo\tpane-died\tv\n");
assert_eq!(restores.len(), 1);
assert_eq!(restores[0].session, "demo");
}
#[test]
fn injection_is_unreachable_without_a_redirected_journal() {
use crate::pod::ENV_LOCK;
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev_journal = std::env::var_os(journal::JOURNAL_DIR_ENV);
let prev_fail = std::env::var_os(FAIL_AT_ENV);
unsafe {
std::env::remove_var(journal::JOURNAL_DIR_ENV);
std::env::set_var(FAIL_AT_ENV, "shim/before-pending");
}
assert!(
crash_if("shim/before-pending").is_ok(),
"failure injection must be unreachable against the live journal"
);
unsafe {
match prev_journal {
Some(v) => std::env::set_var(journal::JOURNAL_DIR_ENV, v),
None => std::env::remove_var(journal::JOURNAL_DIR_ENV),
}
match prev_fail {
Some(v) => std::env::set_var(FAIL_AT_ENV, v),
None => std::env::remove_var(FAIL_AT_ENV),
}
}
}
#[test]
fn shim_contents_execs_forge_pod() {
let body = shim_contents(Path::new("/opt/forge"));
assert!(body.starts_with("#!/usr/bin/env bash"));
assert!(body.contains("exec '/opt/forge' pod \"$@\""));
}
#[test]
#[should_panic(expected = "REFUSING to install the shim outside the temp dir")]
fn installing_the_live_shim_panics_under_test() {
assert_shim_write_allowed(&expand_path("~/.local/bin/cosmux"));
}
}