aethershell 7.3.0

The world's first multi-agent shell with typed functional pipelines and multi-modal AI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! Reversible sessions: let the agent work, and be able to rewind it.
//!
//! 6.0.0 bought safety with friction — 166 builtins now stop and ask. That is
//! the expensive currency: every approval is a round-trip, a stall, and a
//! chance the agent gives up or routes around it. Reversibility buys the same
//! safety with a cheaper one. If a write can be undone, it does not need to be
//! prevented, and the user's question changes from *"should I allow this?"* to
//! *"do I keep it?"* — asked once, at the end, with the results in front of them.
//!
//! Before a `WriteLocal` or `Destructive` builtin runs, the prior contents of
//! every file it might touch are recorded here. [`undo`] puts them back.
//!
//! # What this cannot do, stated plainly
//!
//! Undo covers **local file contents and nothing else**. A `Network` call
//! cannot be unsent, an `Exec`'d process cannot be un-run, a killed process
//! cannot be revived, and a directory tree is not snapshotted. The journal
//! therefore records *irreversible* steps too, as explicit entries, so that a
//! rewind reports what it could not restore instead of quietly restoring part
//! of the world and reporting success.
//!
//! That last point is the whole design. A partial undo that claims completeness
//! is worse than no undo, because it converts a recoverable situation into one
//! where the user believes they have already recovered.

use crate::safety::Effect;
use crate::value::Value;
use std::path::Path;
use std::sync::Mutex;

/// Largest single file captured. Beyond this the step is recorded as
/// irreversible rather than silently skipped.
const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;

/// Ceiling on everything held at once, so a long session cannot exhaust memory.
const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;

/// What was there before a step ran.
#[derive(Clone, Debug, PartialEq)]
pub enum Before {
    /// The file existed with these bytes.
    Contents(Vec<u8>),
    /// The path did not exist, so undoing means removing whatever was created.
    Absent,
    /// Seen, but not recoverable — with the reason, which is reported verbatim
    /// on rewind rather than being flattened into a failure count.
    Irreversible(String),
}

#[derive(Clone, Debug)]
pub struct Entry {
    pub seq: usize,
    pub builtin: String,
    pub effect: String,
    pub path: String,
    pub before: Before,
}

impl Entry {
    pub fn reversible(&self) -> bool {
        !matches!(self.before, Before::Irreversible(_))
    }
}

lazy_static::lazy_static! {
    static ref JOURNAL: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
}

/// Where this session's journal lives.
///
/// Persistence is not a nicety here. The Python SDK runs `ae -c <code>` per
/// call, so an in-memory journal would be empty in the process that runs
/// `undo` — the feature would appear to work and reverse nothing, which is the
/// exact failure this module is written to avoid.
fn session_dir() -> Option<std::path::PathBuf> {
    if let Ok(d) = std::env::var("AETHER_SESSION_DIR") {
        return Some(std::path::PathBuf::from(d).join("journal"));
    }
    let key = std::env::var("AETHER_SESSION").ok().unwrap_or_else(|| {
        let cwd = std::env::current_dir()
            .map(|p| p.to_string_lossy().into_owned())
            .unwrap_or_default();
        format!("{:x}", md5::compute(cwd.as_bytes()))
    });
    dirs::cache_dir().map(|c| c.join("aethershell").join("journal").join(key))
}

/// Serialisable form of an entry. The captured bytes travel with it: a journal
/// that persisted only the metadata could list what it would restore and then
/// restore nothing.
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredEntry {
    seq: usize,
    builtin: String,
    effect: String,
    path: String,
    /// `None` = the path was absent; `Some(bytes)` = it held these.
    contents: Option<Vec<u8>>,
    /// Set when the step could not be captured, with the reason.
    irreversible: Option<String>,
}

impl From<&Entry> for StoredEntry {
    fn from(e: &Entry) -> Self {
        let (contents, irreversible) = match &e.before {
            Before::Contents(b) => (Some(b.clone()), None),
            Before::Absent => (None, None),
            Before::Irreversible(why) => (None, Some(why.clone())),
        };
        StoredEntry {
            seq: e.seq,
            builtin: e.builtin.clone(),
            effect: e.effect.clone(),
            path: e.path.clone(),
            contents,
            irreversible,
        }
    }
}

impl From<StoredEntry> for Entry {
    fn from(s: StoredEntry) -> Self {
        let before = match (s.contents, s.irreversible) {
            (_, Some(why)) => Before::Irreversible(why),
            (Some(b), None) => Before::Contents(b),
            (None, None) => Before::Absent,
        };
        Entry {
            seq: s.seq,
            builtin: s.builtin,
            effect: s.effect,
            path: s.path,
            before,
        }
    }
}

/// Read the persisted journal, oldest first.
fn load_persisted() -> Vec<Entry> {
    let Some(dir) = session_dir() else {
        return Vec::new();
    };
    let Ok(entries) = std::fs::read_dir(&dir) else {
        return Vec::new();
    };
    let mut files: Vec<(usize, std::path::PathBuf)> = entries
        .filter_map(|e| e.ok())
        .filter_map(|e| {
            let p = e.path();
            let stem = p.file_stem()?.to_string_lossy().into_owned();
            stem.parse::<usize>().ok().map(|n| (n, p))
        })
        .collect();
    files.sort_by_key(|(n, _)| *n);
    files
        .into_iter()
        .filter_map(|(_, p)| {
            let raw = std::fs::read(&p).ok()?;
            serde_json::from_slice::<StoredEntry>(&raw)
                .ok()
                .map(Entry::from)
        })
        .collect()
}

fn persist(e: &Entry) {
    let Some(dir) = session_dir() else { return };
    if std::fs::create_dir_all(&dir).is_err() {
        return;
    }
    if let Ok(json) = serde_json::to_vec(&StoredEntry::from(e)) {
        let _ = std::fs::write(dir.join(format!("{:08}.json", e.seq)), json);
    }
}

fn forget_persisted(seq: usize) {
    if let Some(dir) = session_dir() {
        let _ = std::fs::remove_file(dir.join(format!("{seq:08}.json")));
    }
}

/// Merge the persisted journal into memory so this process sees steps recorded
/// by earlier ones.
fn hydrate(j: &mut Vec<Entry>) {
    if !j.is_empty() {
        return;
    }
    *j = load_persisted();
}

/// Journalling is on in agent mode and off for humans, matching the rest of the
/// dual-surface split: the agent surface pays a little I/O for recoverability,
/// the human REPL behaves exactly as before. `AETHER_JOURNAL=on`/`off` forces it.
pub fn enabled() -> bool {
    match std::env::var("AETHER_JOURNAL").ok().as_deref() {
        Some("on") | Some("1") | Some("true") => true,
        Some("off") | Some("0") | Some("false") => false,
        _ => crate::safety::current_mode() == crate::safety::Mode::Agent,
    }
}

/// Builtins that operate on the journal itself. Recording their writes would
/// make `undo` undo its own restoration.
fn is_journal_builtin(name: &str) -> bool {
    matches!(name, "undo" | "journal" | "journal_clear" | "rewind")
}

fn total_bytes(entries: &[Entry]) -> usize {
    entries
        .iter()
        .map(|e| match &e.before {
            Before::Contents(b) => b.len(),
            _ => 0,
        })
        .sum()
}

/// A string argument that plausibly names a file this call could write.
///
/// Deliberately generous: capturing a path the builtin never touches costs one
/// file read and restores identical bytes on undo, which is harmless. Missing
/// one loses the ability to recover it, which is not. The asymmetry decides the
/// bias.
fn candidate_paths(args: &[Value]) -> Vec<String> {
    let mut out = Vec::new();
    for a in args {
        if let Value::Str(s) = a {
            if s.is_empty() || s.len() > 4096 || s.contains('\n') || s.contains('\0') {
                continue;
            }
            let p = Path::new(s);
            let plausible = p.is_file()
                || p.parent()
                    .map(|d| !d.as_os_str().is_empty() && d.is_dir())
                    .unwrap_or(false);
            if plausible && !out.contains(s) {
                out.push(s.clone());
            }
        }
    }
    out
}

/// Record the pre-state of anything `builtin` might overwrite. Call immediately
/// before dispatch.
pub fn record_before(builtin: &str, effect: Effect, args: &[Value]) {
    if !enabled() || is_journal_builtin(builtin) {
        return;
    }
    if !matches!(effect, Effect::WriteLocal | Effect::Destructive) {
        return;
    }
    let paths = candidate_paths(args);
    if paths.is_empty() {
        return;
    }
    let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
    hydrate(&mut j);
    let used = total_bytes(&j);
    for path in paths {
        let p = Path::new(&path);
        let before = if p.is_file() {
            match std::fs::metadata(p).map(|m| m.len() as usize) {
                Ok(sz) if sz > MAX_FILE_BYTES => Before::Irreversible(format!(
                    "{sz} bytes exceeds the {MAX_FILE_BYTES}-byte capture limit"
                )),
                Ok(sz) if used + sz > MAX_TOTAL_BYTES => {
                    Before::Irreversible("session capture budget exhausted".into())
                }
                Ok(_) => match std::fs::read(p) {
                    Ok(bytes) => Before::Contents(bytes),
                    Err(e) => Before::Irreversible(format!("unreadable: {e}")),
                },
                Err(e) => Before::Irreversible(format!("unstattable: {e}")),
            }
        } else if p.exists() {
            // A directory: restoring a tree is out of scope, and pretending
            // otherwise is the failure this module exists to avoid.
            Before::Irreversible("path is a directory; trees are not captured".into())
        } else {
            Before::Absent
        };
        let seq = j.len() + 1;
        let entry = Entry {
            seq,
            builtin: builtin.to_string(),
            effect: effect.as_str().to_string(),
            path,
            before,
        };
        persist(&entry);
        j.push(entry);
    }
}

/// Note a step that changed the world in a way no snapshot can capture, so a
/// later rewind can say so.
pub fn record_irreversible(builtin: &str, effect: Effect, why: &str) {
    if !enabled() || is_journal_builtin(builtin) {
        return;
    }
    let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
    hydrate(&mut j);
    let seq = j.len() + 1;
    let entry = Entry {
        seq,
        builtin: builtin.to_string(),
        effect: effect.as_str().to_string(),
        path: String::new(),
        before: Before::Irreversible(why.to_string()),
    };
    persist(&entry);
    j.push(entry);
}

/// The outcome of rewinding one entry.
#[derive(Clone, Debug, PartialEq)]
pub enum Outcome {
    Restored(String),
    Removed(String),
    Skipped { what: String, why: String },
    Failed { what: String, why: String },
}

/// Undo the last `n` recorded steps, most recent first.
///
/// Returns an outcome per entry — including the ones it could not reverse.
/// Callers must surface those: a rewind that reports only its successes is how
/// a partial restore gets mistaken for a complete one.
pub fn undo(n: usize) -> Vec<Outcome> {
    let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
    hydrate(&mut j);
    let mut out = Vec::new();
    for _ in 0..n {
        let Some(entry) = j.pop() else { break };
        forget_persisted(entry.seq);
        let what = if entry.path.is_empty() {
            entry.builtin.clone()
        } else {
            entry.path.clone()
        };
        match entry.before {
            Before::Contents(bytes) => match std::fs::write(&entry.path, &bytes) {
                Ok(()) => out.push(Outcome::Restored(what)),
                Err(e) => out.push(Outcome::Failed {
                    what,
                    why: e.to_string(),
                }),
            },
            Before::Absent => {
                let p = Path::new(&entry.path);
                if p.is_file() {
                    match std::fs::remove_file(p) {
                        Ok(()) => out.push(Outcome::Removed(what)),
                        Err(e) => out.push(Outcome::Failed {
                            what,
                            why: e.to_string(),
                        }),
                    }
                } else {
                    // Never created, so there is nothing to take back.
                    out.push(Outcome::Skipped {
                        what,
                        why: "was absent before and is absent now".into(),
                    });
                }
            }
            Before::Irreversible(why) => out.push(Outcome::Skipped { what, why }),
        }
    }
    out
}

/// Every recorded step, oldest first.
pub fn entries() -> Vec<Entry> {
    let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
    hydrate(&mut j);
    j.iter()
        .map(|e| Entry {
            // The captured bytes are never handed out; they would be a second
            // copy of the data in the agent's context for no benefit.
            before: match &e.before {
                Before::Contents(b) => Before::Contents(vec![0; b.len().min(1)]),
                other => other.clone(),
            },
            ..e.clone()
        })
        .collect()
}

/// How many recorded steps can actually be reversed.
pub fn reversible_count() -> usize {
    let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
    hydrate(&mut j);
    j.iter().filter(|e| e.reversible()).count()
}

/// The current end of the journal, for [`rollback_to`].
pub fn mark() -> usize {
    let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
    hydrate(&mut j);
    j.len()
}

/// Discard entries recorded after `mark`, because the call they were recorded
/// for did not happen.
///
/// Found by using the shell as an agent: a `file_write` refused by the
/// workspace jail still left a journal entry, so `undo()` answered
/// `complete: false, skipped: 1` about an operation that never ran. The report
/// was honest but the entry was fiction, and an agent reading `complete: false`
/// would reasonably conclude something was left unreversed.
pub fn rollback_to(mark: usize) {
    let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
    while j.len() > mark {
        if let Some(e) = j.pop() {
            forget_persisted(e.seq);
        }
    }
}

/// Forget everything recorded. Does not touch the filesystem.
pub fn clear() {
    JOURNAL.lock().unwrap_or_else(|e| e.into_inner()).clear();
    if let Some(dir) = session_dir() {
        let _ = std::fs::remove_dir_all(dir);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_oversized_file_is_recorded_as_irreversible_not_skipped() {
        // Silently declining to capture would leave the user believing the step
        // was covered.
        let e = Entry {
            seq: 1,
            builtin: "file_write".into(),
            effect: "write_local".into(),
            path: "big.bin".into(),
            before: Before::Irreversible("too big".into()),
        };
        assert!(!e.reversible());
    }

    #[test]
    fn a_directory_target_is_refused_rather_than_half_captured() {
        let e = Entry {
            seq: 1,
            builtin: "rm".into(),
            effect: "destructive".into(),
            path: ".".into(),
            before: Before::Irreversible("path is a directory; trees are not captured".into()),
        };
        assert!(!e.reversible());
    }

    #[test]
    fn journal_builtins_are_never_recorded() {
        // Otherwise undo would journal its own restoration and a second undo
        // would put the damage back.
        assert!(is_journal_builtin("undo"));
        assert!(is_journal_builtin("journal"));
        assert!(!is_journal_builtin("file_write"));
    }
}