Port the foreign-session resume picker, and refuse what the file does not hold

2d0c10979fb4 · AtlantisPleb · · parent 28704f72ff57

Port the foreign-session resume picker, and refuse what the file does not hold

`coder-foreign-resume.ts` was the last unported piece of #82. It is the picker
half of foreign-session resume; the scanner half is the `packet-v0` guest at
`plugins/foreign-sessions`, which now has a wasm host to run under (a9cbb4e819)
and a slash dispatcher to hang from (43c92585c6).

`/resume` lists recent Claude Code and Codex sessions from `~/.claude` and
`~/.codex`, and `/resume <n>` prints the command that resumes one in the tool
that owns it. Discovery is the shipped digest-pinned artifact under read-only
mounts, so this crate reads those stores through the same sandbox the
capability tool does.

The port is not one to one, in four places that matter.

**The session has to be on disk.** The TypeScript renders a resume command out
of the scan alone. Here the reported path is resolved against the declared
mount roots, the file is opened by the host, and the reported session id is
looked for in that file's own leading records. Absent, unreadable, or holding a
different id is refused by name with the path. A file over the scanner's read
bound is the one case where the id is the file name, and the output says so
rather than implying a read that did not happen. This crate has shipped a
hardcoded identity seed, an invented forum board, and two fabricated trace
sessions; all three reached users.

**A reported path stays inside its mount.** The relative path comes out of a
file this process does not own, so `..`, absolute, and empty components refuse
the join outright.

**Everything surfaced is redacted.** These are other agents' session histories.
Session id, working directory, and file path all pass through
`trace::redact_text` before reaching the transcript, and the test reads
`fixtures/redaction/planted-secrets.json` rather than restating the patterns.

**A printed command is exact or absent.** The TypeScript interpolates
`cd "${cwd}" && claude --resume ${id}` straight from foreign file content, so a
session recording a cwd of `"; rm -rf ~; #` yields a line that does that when
pasted. Both fields are shape-checked first. A cwd whose only redaction is the
home rewrite is rebuilt as `"$HOME/…"`, which expands back character for
character; anything else removed means no `cd` and the categories are named.
The recorded value is still shown, as an escaped literal.

Also counted rather than silently dropped: rows from a source this picker
cannot resume. The guest knows four stores, the manifest mounts two, and a row
that vanishes with nothing said is a listing that quietly disagrees with the
store it scanned.

Verified adversarially. Stubbing `confirm_on_disk` to always confirm fails 7
tests, including all three that run against this machine's real stores. Three
of the real-store tests load the shipped artifact, invoke it for real, reopen
the session file it named, and compare field by field; one of them alters the
id on that same real packet and asserts the refusal.

541 tests pass in `cargo test -p openagents-cli`, 0 fail.

Refs OpenAgentsInc/openagents#82

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • added crates/openagents-cli/src/foreign_resume.rs
  • modified crates/openagents-cli/src/interactive.rs
  • modified crates/openagents-cli/src/lib.rs
  • modified crates/openagents-cli/tests/coder_tui_test.rs
  • added crates/openagents-cli/tests/foreign_resume_test.rs

Diff

6 files changed, +2069 -1

INVARIANTS.md modified +51

@@ -2602,3 +2602,54 @@ ported from grok-build (`crates/coder-lite/src/markdown/`, Apache-2.0, see

2602 2602
  `transcript::WrapStats` exist so the saving is asserted as cost, not assumed
2603 2603
  from output that happens to look right. Held by
2604 2604
  `crates/coder-lite/tests/streaming.rs`. Issue OpenAgentsInc/openagents#104.
2605
2606
## Foreign Session Resume
2607
2608
`oa coder`'s `/resume` lists Claude Code and Codex sessions from their own
2609
local stores and prints the command that resumes one in the tool that owns it
2610
(`crates/openagents-cli/src/foreign_resume.rs`, the port of
2611
`packages/openagents-cli/src/coder-foreign-resume.ts`). Discovery is the
2612
`packet-v0` WebAssembly guest at `plugins/foreign-sessions`, run under the
2613
wasm host in `crates/openagents-cli/src/plugins.rs` over read-only mounts of
2614
`~/.claude` and `~/.codex`. Four things about that surface are fixed.
2615
2616
- **What is offered is the session on disk.** Before a resume command is
2617
  printed, the scanner's reported path is resolved against the declared mount
2618
  roots and the file is opened by the host, and the reported session id is
2619
  found in that file's own leading records. A session whose file is absent,
2620
  unreadable, or holding a different id is refused by name with the path.
2621
  Nothing is rendered from the scan alone, and no empty or invented session
2622
  stands in for one that could not be read. The single exception is a file over
2623
  the host's per-file read bound, where the id is the file name; the output
2624
  says so on the line it prints rather than implying a read that did not
2625
  happen. Held by
2626
  `crates/openagents-cli/tests/foreign_resume_test.rs::a_session_whose_file_is_gone_is_refused_by_name_with_the_path`,
2627
  `a_file_that_holds_a_different_session_does_not_become_a_resume_command`,
2628
  `resuming_a_real_session_prints_what_that_session_file_actually_holds`, and
2629
  `a_real_session_with_the_wrong_id_on_it_is_refused_against_the_file` — the
2630
  last two against this machine's real stores and the shipped artifact.
2631
- **A reported path stays inside its mount.** The relative path comes out of a
2632
  file this process does not own, so it is joined component by component and an
2633
  empty, `.`, `..`, or absolute component refuses the join outright. Held by
2634
  `a_reported_path_cannot_climb_out_of_the_mounted_store`.
2635
- **Everything surfaced is redacted.** A foreign store holds someone else's
2636
  session history, so every string taken from one — session id, working
2637
  directory, file path — passes through `crate::trace::redact_text`, the rules
2638
  this CLI shares with `packages/atif/src/redaction.ts`, before it reaches the
2639
  transcript. Held by
2640
  `no_planted_credential_survives_into_the_listing_or_the_selection`, which
2641
  reads `fixtures/redaction/planted-secrets.json` rather than restating the
2642
  patterns.
2643
- **A printed command is exact or absent.** A working directory whose only
2644
  redaction is the home rewrite is rebuilt as `"$HOME/…"`, which expands back
2645
  to the recorded path character for character; anything else the rules removed
2646
  means no `cd` is printed and the categories are named. A value that cannot be
2647
  safely quoted, or a session id that is not shaped like one, never reaches a
2648
  command line — the recorded value is still shown, as an escaped literal. A
2649
  `cd` to somewhere other than where the session ran is worse than no `cd`.
2650
  Held by
2651
  `a_working_directory_becomes_a_command_only_when_it_round_trips` in
2652
  `foreign_resume.rs` and
2653
  `a_working_directory_that_would_run_a_command_never_reaches_the_command_line`.
2654
2655
Issue OpenAgentsInc/openagents#82.
crates/openagents-cli/src/foreign_resume.rs added +978

@@ -0,0 +1,978 @@

1
//! `/resume`: recent Claude Code and Codex sessions from their own local stores.
2
//!
3
//! This is the Rust port of `packages/openagents-cli/src/coder-foreign-resume.ts`,
4
//! the picker half of the foreign-session feature. The scanner half is the
5
//! `packet-v0` WebAssembly guest at `plugins/foreign-sessions`, run under the
6
//! host in [`crate::plugins`]: read-only mounts over `~/.claude` and `~/.codex`,
7
//! a pinned digest, a memory ceiling and a deadline. This module builds the
8
//! bounded scan packet, interprets the metadata-only answer, renders a numbered
9
//! list, and prints the command that resumes one session in the tool that owns
10
//! it.
11
//!
12
//! # What is surfaced is the session on disk
13
//!
14
//! The scanner reports metadata, not a transcript, and this module never
15
//! reconstructs one. Before a resume command is printed, the reported path is
16
//! resolved against the declared mount roots and the file is opened here, in
17
//! the host, and the session id the scanner reported is looked for in the
18
//! file's own leading records. A session whose file is not there, cannot be
19
//! read, or does not carry the reported id is **refused by name with the
20
//! path** — not rendered from the scan alone, and never replaced by an empty or
21
//! invented session. That is the whole point: this crate has previously shipped
22
//! a hardcoded identity seed, an invented forum board, and two fabricated trace
23
//! sessions, and every one of them reached a user.
24
//!
25
//! The one case where the id does not come from the records is a file over the
26
//! host's per-file read bound. There the scanner takes the id from the file
27
//! name, and this module says so on the line it prints rather than implying a
28
//! read that did not happen.
29
//!
30
//! # What is surfaced is redacted
31
//!
32
//! These are other agents' session stores, and a working directory or a file
33
//! path read out of one is untrusted content. Every foreign-derived string goes
34
//! through [`crate::trace::redact_text`] — the rules this CLI already shares
35
//! with `packages/atif/src/redaction.ts` — before it reaches the transcript.
36
//!
37
//! Redaction and a runnable command pull in opposite directions, so the rule is
38
//! explicit rather than split the difference: a working directory whose only
39
//! redaction is the home-path rewrite is rebuilt as `"$HOME/..."`, which a shell
40
//! expands back to exactly the directory the file recorded. Anything else
41
//! removed from the path means no command is printed at all, with the categories
42
//! named. A command that would `cd` somewhere other than where the session ran
43
//! is worse than no command.
44
//!
45
//! # Shell safety
46
//!
47
//! The TypeScript renders `cd "${cwd}" && claude --resume ${id}` from fields
48
//! read straight out of a foreign file. A session file that records a cwd of
49
//! `"; rm -rf ~; #` produces a line that does that when pasted. Both fields are
50
//! checked here before they are interpolated, and a value that is not safe to
51
//! quote is refused by name.
52
53
use crate::plugins::{self, Approval, CatalogEntry, LoadedPlugin};
54
use crate::trace::redact_text;
55
use serde_json::{json, Value};
56
use std::collections::BTreeMap;
57
use std::path::{Path, PathBuf};
58
59
const DAY_MS: i64 = 86_400_000;
60
const HOUR_MS: i64 = 3_600_000;
61
62
/// How far back the picker looks, in days, unless the caller says otherwise.
63
pub const DEFAULT_MAX_AGE_DAYS: f64 = 30.0;
64
/// How many sessions the picker asks for, unless the caller says otherwise.
65
pub const DEFAULT_PICKER_LIMIT: usize = 10;
66
/// The catalog name of the scanner this picker drives.
67
pub const SCANNER_NAME: &str = "foreign_sessions";
68
69
/// Leading records inspected when confirming a scanner-reported session id.
70
///
71
/// The same bound the guest uses (`META_SCAN_LINES`), so a file whose id the
72
/// scanner found is a file whose id this confirms.
73
const VERIFY_SCAN_LINES: usize = 20;
74
75
/// Most bytes read back when confirming a session id. The guest's own per-file
76
/// bound is 1 MiB and it gives up past that; there is no reason to read more
77
/// here than the side being checked could have seen.
78
const VERIFY_READ_BYTES: u64 = 1024 * 1024;
79
80
// ───────────────────────────────────────────────────────────── the scan answer
81
82
/// Which foreign tool owns a session. Only the two the manifest mounts.
83
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84
pub enum ForeignSource {
85
    Claude,
86
    Codex,
87
}
88
89
impl ForeignSource {
90
    pub fn as_str(self) -> &'static str {
91
        match self {
92
            Self::Claude => "claude",
93
            Self::Codex => "codex",
94
        }
95
    }
96
97
    fn parse(value: &str) -> Option<Self> {
98
        match value {
99
            "claude" => Some(Self::Claude),
100
            "codex" => Some(Self::Codex),
101
            _ => None,
102
        }
103
    }
104
105
    /// The binary and verb that resume a session in this tool.
106
    pub fn resume_verb(self) -> &'static str {
107
        match self {
108
            Self::Claude => "claude --resume",
109
            Self::Codex => "codex resume",
110
        }
111
    }
112
}
113
114
/// One session the scanner reported. Metadata only; no transcript.
115
#[derive(Debug, Clone, PartialEq, Eq)]
116
pub struct ForeignSession {
117
    pub source: ForeignSource,
118
    pub session_id: String,
119
    /// Relative to the store's mount root, as the guest reports it.
120
    pub path: String,
121
    pub cwd: Option<String>,
122
    pub project_dir: Option<String>,
123
    pub mtime_ms: i64,
124
    pub size_bytes: u64,
125
    pub record_count: Option<usize>,
126
    /// The file was over the host's read bound, so only its listing metadata
127
    /// is known — including the session id, which came from the file name.
128
    pub metadata_truncated: bool,
129
}
130
131
#[derive(Debug, Clone, Default, PartialEq, Eq)]
132
pub struct Skipped {
133
    pub malformed: usize,
134
    pub unreadable: usize,
135
    pub symlinked: usize,
136
}
137
138
#[derive(Debug, Clone, Default, PartialEq, Eq)]
139
pub struct ForeignScanOutput {
140
    pub sessions: Vec<ForeignSession>,
141
    pub scanned_dirs: usize,
142
    pub scanned_files: usize,
143
    pub skipped: Skipped,
144
    pub oversized: usize,
145
    pub missing_sources: Vec<String>,
146
    pub scan_truncated: bool,
147
    pub read_budget_exhausted: bool,
148
    /// Rows whose `source` this picker cannot resume, counted by name.
149
    ///
150
    /// The TypeScript drops these silently. A dropped row that nothing accounts
151
    /// for is a listing that quietly disagrees with the store it scanned, so
152
    /// they are counted and reported instead.
153
    pub unsupported_sources: BTreeMap<String, usize>,
154
}
155
156
#[derive(Debug, Clone, PartialEq, Eq)]
157
pub struct ForeignScanRefusal {
158
    pub code: String,
159
    pub reason: String,
160
}
161
162
/// What one `/resume` turn is answered against.
163
#[derive(Debug, Clone)]
164
pub struct ForeignResumeDeps {
165
    pub now_ms: i64,
166
    /// The session's working directory, used as the scanner's cwd filter.
167
    pub cwd: String,
168
    /// `None` lists; `Some(n)` describes the nth listed session.
169
    pub selection: Option<usize>,
170
    /// The invoking user's home, for the redaction rules.
171
    pub home: String,
172
    /// The store roots the scanner was mounted on, in declaration order. A
173
    /// reported relative path is resolved against these and nothing else.
174
    pub mount_roots: Vec<PathBuf>,
175
}
176
177
#[derive(Debug, Clone, Default)]
178
pub struct ForeignResumeOptions {
179
    pub max_age_days: Option<f64>,
180
    pub limit: Option<usize>,
181
}
182
183
// ───────────────────────────────────────────────────────────────── the parsing
184
185
fn as_str(value: Option<&Value>) -> String {
186
    value
187
        .and_then(Value::as_str)
188
        .unwrap_or_default()
189
        .to_string()
190
}
191
192
fn as_i64(value: Option<&Value>) -> i64 {
193
    value.and_then(Value::as_f64).map_or(0, |n| n as i64)
194
}
195
196
fn as_usize(value: Option<&Value>) -> usize {
197
    value
198
        .and_then(Value::as_f64)
199
        .filter(|n| n.is_finite() && *n >= 0.0)
200
        .map_or(0, |n| n as usize)
201
}
202
203
fn optional_string(record: &Value, key: &str) -> Option<String> {
204
    match record.get(key) {
205
        None | Some(Value::Null) => None,
206
        Some(value) => Some(value.as_str().unwrap_or_default().to_string()),
207
    }
208
}
209
210
fn optional_usize(record: &Value, key: &str) -> Option<usize> {
211
    match record.get(key) {
212
        None | Some(Value::Null) => None,
213
        Some(value) => Some(as_usize(Some(value))),
214
    }
215
}
216
217
/// Read one session row. `Err(name)` is a row whose source this cannot resume.
218
fn parse_session(value: &Value) -> Result<ForeignSession, String> {
219
    if !value.is_object() {
220
        return Err(String::new());
221
    }
222
    let raw_source = as_str(value.get("source"));
223
    let Some(source) = ForeignSource::parse(&raw_source) else {
224
        return Err(raw_source);
225
    };
226
    Ok(ForeignSession {
227
        source,
228
        session_id: as_str(value.get("session_id")),
229
        path: as_str(value.get("path")),
230
        cwd: optional_string(value, "cwd"),
231
        project_dir: optional_string(value, "project_dir"),
232
        mtime_ms: as_i64(value.get("mtime_ms")),
233
        size_bytes: as_i64(value.get("size_bytes")).max(0) as u64,
234
        record_count: optional_usize(value, "record_count"),
235
        metadata_truncated: value.get("metadata_truncated") == Some(&Value::Bool(true)),
236
    })
237
}
238
239
fn parse_scan_output(value: &Value) -> ForeignScanOutput {
240
    let empty = Vec::new();
241
    let rows = value
242
        .get("sessions")
243
        .and_then(Value::as_array)
244
        .unwrap_or(&empty);
245
246
    let mut sessions = Vec::new();
247
    let mut unsupported_sources: BTreeMap<String, usize> = BTreeMap::new();
248
    for row in rows {
249
        match parse_session(row) {
250
            Ok(session) => sessions.push(session),
251
            Err(name) if name.is_empty() => {}
252
            Err(name) => *unsupported_sources.entry(name).or_insert(0) += 1,
253
        }
254
    }
255
256
    let skipped = value.get("skipped").cloned().unwrap_or(Value::Null);
257
    let missing_sources = value
258
        .get("missing_sources")
259
        .and_then(Value::as_array)
260
        .map(|list| {
261
            list.iter()
262
                .filter_map(Value::as_str)
263
                .map(str::to_string)
264
                .collect()
265
        })
266
        .unwrap_or_default();
267
268
    ForeignScanOutput {
269
        sessions,
270
        scanned_dirs: as_usize(value.get("scanned_dirs")),
271
        scanned_files: as_usize(value.get("scanned_files")),
272
        skipped: Skipped {
273
            malformed: as_usize(skipped.get("malformed")),
274
            unreadable: as_usize(skipped.get("unreadable")),
275
            symlinked: as_usize(skipped.get("symlinked")),
276
        },
277
        oversized: as_usize(value.get("oversized")),
278
        missing_sources,
279
        scan_truncated: value.get("scan_truncated") == Some(&Value::Bool(true)),
280
        read_budget_exhausted: value.get("read_budget_exhausted") == Some(&Value::Bool(true)),
281
        unsupported_sources,
282
    }
283
}
284
285
/// What one invocation came back as.
286
#[derive(Debug, Clone)]
287
pub enum ScanResult {
288
    Ok(Box<ForeignScanOutput>),
289
    Refusal(ForeignScanRefusal),
290
    Error(String),
291
}
292
293
/// Sort a raw packet into the three shapes a caller can act on.
294
pub fn normalize_scan_result(value: &Value) -> ScanResult {
295
    if !value.is_object() {
296
        return ScanResult::Error("The scanner returned an unrecognised packet.".to_string());
297
    }
298
    if let Some(refusal) = value.get("refusal").filter(|v| !v.is_null()) {
299
        let code = as_str(refusal.get("code"));
300
        let reason = as_str(refusal.get("reason"));
301
        if !code.is_empty() && !reason.is_empty() {
302
            return ScanResult::Refusal(ForeignScanRefusal { code, reason });
303
        }
304
        return ScanResult::Error("The scanner returned a malformed refusal.".to_string());
305
    }
306
    if let Some(ok) = value.get("ok").filter(|v| !v.is_null()) {
307
        return ScanResult::Ok(Box::new(parse_scan_output(ok)));
308
    }
309
    ScanResult::Error("The scanner returned an unrecognised packet.".to_string())
310
}
311
312
/// The packet the guest's `interface.input` schema describes.
313
pub fn build_packet(deps: &ForeignResumeDeps, options: &ForeignResumeOptions) -> Value {
314
    json!({
315
        "now_ms": deps.now_ms,
316
        "cwd_filter": deps.cwd,
317
        "max_age_days": options.max_age_days.unwrap_or(DEFAULT_MAX_AGE_DAYS),
318
        "limit": options.limit.unwrap_or(DEFAULT_PICKER_LIMIT),
319
    })
320
}
321
322
// ───────────────────────────────────────────────────────────────── the rendering
323
324
/// `5 days ago`, `3 hours ago`, or `just now`.
325
pub fn format_age(mtime_ms: i64, now_ms: i64) -> String {
326
    let diff = (now_ms - mtime_ms).max(0);
327
    let days = diff / DAY_MS;
328
    if days >= 1 {
329
        return format!("{days} day{} ago", if days == 1 { "" } else { "s" });
330
    }
331
    let hours = diff / HOUR_MS;
332
    if hours >= 1 {
333
        return format!("{hours} hour{} ago", if hours == 1 { "" } else { "s" });
334
    }
335
    "just now".to_string()
336
}
337
338
/// Run one string from a foreign store through the shared redaction rules.
339
fn hide(value: &str, home: &str) -> String {
340
    redact_text(value, home).text
341
}
342
343
/// A recorded working directory, as it is shown to a reader.
344
///
345
/// Redacted first. Then, if what is left could not go inside a quoted shell
346
/// word, it is shown as an escaped literal — `"/tmp/x\"; rm -rf ~; #"` rather
347
/// than the bare bytes. The exact recorded value is still on the screen; it
348
/// just cannot be mistaken for something to run. A session file's `cwd` is
349
/// written by whatever agent owned that session, and the line above it says
350
/// `cd`.
351
fn show_cwd(value: &str, home: &str) -> String {
352
    let hidden = hide(value, home);
353
    if quotable(&hidden) {
354
        hidden
355
    } else {
356
        format!("{hidden:?}")
357
    }
358
}
359
360
/// True when a value can be put inside a double-quoted shell word and mean
361
/// itself. Deliberately narrow: the input is a field from someone else's file.
362
fn quotable(value: &str) -> bool {
363
    !value.is_empty()
364
        && !value
365
            .chars()
366
            .any(|c| c.is_control() || matches!(c, '"' | '\\' | '$' | '`' | '\n' | '\r'))
367
}
368
369
/// True when a value is shaped like a session id: what a UUID or a file stem
370
/// is made of, and nothing a shell would look at twice.
371
fn id_shaped(value: &str) -> bool {
372
    !value.is_empty()
373
        && value.len() <= 128
374
        && value
375
            .chars()
376
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'))
377
}
378
379
/// How a working directory can appear inside a resume command.
380
#[derive(Debug, Clone, PartialEq, Eq)]
381
enum ShellCwd {
382
    /// The session recorded no working directory.
383
    Unknown,
384
    /// Nothing was redacted; the literal path is quoted.
385
    Literal(String),
386
    /// Only the home rewrite applied, and `$HOME` puts it back exactly.
387
    Home(String),
388
    /// Redaction removed these categories, so no path can be printed.
389
    Redacted(Vec<String>),
390
    /// The recorded path cannot be safely quoted.
391
    Unquotable,
392
}
393
394
/// Decide how — or whether — a recorded working directory can be printed into a
395
/// command the reader is invited to run.
396
fn shell_cwd(cwd: Option<&str>, home: &str) -> ShellCwd {
397
    let Some(cwd) = cwd.filter(|value| !value.is_empty()) else {
398
        return ShellCwd::Unknown;
399
    };
400
    if !quotable(cwd) {
401
        return ShellCwd::Unquotable;
402
    }
403
    let redaction = redact_text(cwd, home);
404
    if redaction.total == 0 {
405
        return ShellCwd::Literal(cwd.to_string());
406
    }
407
    let only_home = redaction
408
        .counts
409
        .keys()
410
        .all(|category| category == "home_path");
411
    // `$HOME` is accepted only when the rewrite is a leading one, there is
412
    // exactly one of them, and expanding it back yields the recorded path
413
    // character for character. Anything less is a guess about where a session
414
    // ran, and this prints no guesses. `strip_prefix` and not `text[1..]`:
415
    // `/opt/Users/ada/x` also redacts to a string with a `~` in it, and slicing
416
    // by one byte would both mangle that path and split a leading multi-byte
417
    // character in half.
418
    if only_home && !home.is_empty() {
419
        if let Some(rest) = redaction.text.strip_prefix('~') {
420
            if !rest.contains('~') && redaction.text.replacen('~', home, 1) == cwd {
421
                return ShellCwd::Home(format!("$HOME{rest}"));
422
            }
423
        }
424
    }
425
    ShellCwd::Redacted(redaction.counts.keys().cloned().collect())
426
}
427
428
fn describe_session(session: &ForeignSession, now_ms: i64, home: &str) -> String {
429
    let age = format_age(session.mtime_ms, now_ms);
430
    let records = match session.record_count {
431
        None => "metadata only".to_string(),
432
        Some(count) => format!("{count} records"),
433
    };
434
    let truncated = if session.metadata_truncated {
435
        " · truncated"
436
    } else {
437
        ""
438
    };
439
    let cwd = match session.cwd.as_deref().filter(|value| !value.is_empty()) {
440
        Some(value) => show_cwd(value, home),
441
        None => "(cwd unknown)".to_string(),
442
    };
443
    format!(
444
        "{:<6}  {}  {}  {}  {}{}",
445
        session.source.as_str(),
446
        hide(&session.session_id, home),
447
        cwd,
448
        age,
449
        records,
450
        truncated
451
    )
452
}
453
454
fn scan_notes(output: &ForeignScanOutput) -> Vec<String> {
455
    let mut notes = Vec::new();
456
    if output.scan_truncated {
457
        notes.push("The scan hit a bound and may be partial.".to_string());
458
    }
459
    if output.read_budget_exhausted {
460
        notes.push(
461
            "The file-read budget was exhausted; some sessions may be metadata-only.".to_string(),
462
        );
463
    }
464
    for (name, count) in &output.unsupported_sources {
465
        notes.push(format!(
466
            "{count} session{} from `{name}` {} left out: this picker resumes `claude` and `codex` only.",
467
            if *count == 1 { "" } else { "s" },
468
            if *count == 1 { "was" } else { "were" }
469
        ));
470
    }
471
    notes
472
}
473
474
fn describe_list(output: &ForeignScanOutput, deps: &ForeignResumeDeps) -> String {
475
    let header = format!(
476
        "Recent foreign sessions for this directory ({}):",
477
        hide(&deps.cwd, &deps.home)
478
    );
479
480
    if output.sessions.is_empty() {
481
        let mut reasons = Vec::new();
482
        if !output.missing_sources.is_empty() {
483
            reasons.push(format!(
484
                "the scanner could not read the {} state store",
485
                output.missing_sources.join(" or ")
486
            ));
487
        }
488
        if output.scan_truncated {
489
            reasons.push("the scan was truncated".to_string());
490
        }
491
        if output.read_budget_exhausted {
492
            reasons.push("the file-read budget was exhausted".to_string());
493
        }
494
        let reason = if reasons.is_empty() {
495
            String::new()
496
        } else {
497
            format!(" ({})", reasons.join("; "))
498
        };
499
        let notes = scan_notes(output);
500
        let tail = if notes.is_empty() {
501
            String::new()
502
        } else {
503
            format!("\n\n{}", notes.join(" "))
504
        };
505
        return format!("{header}\n\nNo recent foreign sessions were found{reason}.{tail}");
506
    }
507
508
    let lines = output
509
        .sessions
510
        .iter()
511
        .enumerate()
512
        .map(|(index, session)| {
513
            format!(
514
                "  {:>2}. {}",
515
                index + 1,
516
                describe_session(session, deps.now_ms, &deps.home)
517
            )
518
        })
519
        .collect::<Vec<_>>()
520
        .join("\n");
521
522
    let notes = scan_notes(output);
523
    let note = if notes.is_empty() {
524
        String::new()
525
    } else {
526
        format!("\n\n{}", notes.join(" "))
527
    };
528
529
    format!(
530
        "{header}\n\n{lines}\n\nRun /resume <number> to see the resume command for that session.{note}"
531
    )
532
}
533
534
// ───────────────────────────────────────────────── proving the file is there
535
536
/// What confirming a reported session against its file on disk found.
537
#[derive(Debug, Clone, PartialEq, Eq)]
538
pub enum OnDisk {
539
    /// The file exists and its own leading records carry the reported id.
540
    Confirmed { path: PathBuf },
541
    /// The file exists and the scanner took the id from its name, because the
542
    /// file is over the host's per-file read bound.
543
    FromFileName { path: PathBuf },
544
    /// The file exists and does not carry the reported id.
545
    Mismatch { path: PathBuf },
546
    /// No declared store root holds the reported path.
547
    Missing {
548
        relative: String,
549
        roots: Vec<PathBuf>,
550
    },
551
    /// The file is there and could not be read.
552
    Unreadable { path: PathBuf, error: String },
553
}
554
555
/// Join a scanner-reported relative path onto a mount root without letting it
556
/// leave. `..`, absolute paths, and empty components are refused outright — the
557
/// path came out of a file this process does not control.
558
fn under_root(root: &Path, relative: &str) -> Option<PathBuf> {
559
    if relative.is_empty() || relative.starts_with('/') {
560
        return None;
561
    }
562
    let mut path = root.to_path_buf();
563
    for part in relative.split('/') {
564
        if part.is_empty() || part == "." || part == ".." {
565
            return None;
566
        }
567
        path.push(part);
568
    }
569
    Some(path)
570
}
571
572
/// Read the leading records of a session file and say whether the reported id
573
/// is in them.
574
///
575
/// Claude records it as `sessionId` on a top-level record; Codex records it as
576
/// `payload.id` on the first `session_meta` line. Both are read here rather
577
/// than taken from the scanner, because taking it from the scanner would prove
578
/// only that the scanner is self-consistent.
579
fn file_carries_id(path: &Path, session_id: &str) -> std::io::Result<bool> {
580
    use std::io::Read;
581
582
    let mut file = std::fs::File::open(path)?;
583
    let mut buffer = vec![0u8; VERIFY_READ_BYTES as usize];
584
    let mut filled = 0usize;
585
    while filled < buffer.len() {
586
        match file.read(&mut buffer[filled..])? {
587
            0 => break,
588
            got => filled += got,
589
        }
590
    }
591
    buffer.truncate(filled);
592
    let text = String::from_utf8_lossy(&buffer);
593
594
    for line in text.lines().take(VERIFY_SCAN_LINES) {
595
        let Ok(value) = serde_json::from_str::<Value>(line) else {
596
            continue;
597
        };
598
        if value.get("sessionId").and_then(Value::as_str) == Some(session_id) {
599
            return Ok(true);
600
        }
601
        if value.get("type").and_then(Value::as_str) == Some("session_meta")
602
            && value
603
                .get("payload")
604
                .and_then(|p| p.get("id"))
605
                .and_then(Value::as_str)
606
                == Some(session_id)
607
        {
608
            return Ok(true);
609
        }
610
    }
611
    Ok(false)
612
}
613
614
/// Resolve a reported session to the file it names and confirm what is in it.
615
pub fn confirm_on_disk(session: &ForeignSession, roots: &[PathBuf]) -> OnDisk {
616
    let found = roots
617
        .iter()
618
        .filter_map(|root| under_root(root, &session.path))
619
        .find(|candidate| candidate.is_file());
620
621
    let Some(path) = found else {
622
        return OnDisk::Missing {
623
            relative: session.path.clone(),
624
            roots: roots.to_vec(),
625
        };
626
    };
627
628
    // Over the read bound the guest never opened the file; the id is the file
629
    // stem. Confirm exactly that claim and no more.
630
    if session.metadata_truncated {
631
        let stem = path
632
            .file_name()
633
            .map(|name| name.to_string_lossy().into_owned())
634
            .unwrap_or_default();
635
        let stem = stem.strip_suffix(".jsonl").unwrap_or(&stem);
636
        return if stem == session.session_id {
637
            OnDisk::FromFileName { path }
638
        } else {
639
            OnDisk::Mismatch { path }
640
        };
641
    }
642
643
    match file_carries_id(&path, &session.session_id) {
644
        Ok(true) => OnDisk::Confirmed { path },
645
        Ok(false) => OnDisk::Mismatch { path },
646
        Err(error) => OnDisk::Unreadable {
647
            path,
648
            error: error.to_string(),
649
        },
650
    }
651
}
652
653
fn describe_selection(session: &ForeignSession, deps: &ForeignResumeDeps) -> String {
654
    let home = deps.home.as_str();
655
    let on_disk = confirm_on_disk(session, &deps.mount_roots);
656
657
    let (path, provenance) = match &on_disk {
658
        OnDisk::Confirmed { path } => (
659
            path.clone(),
660
            "the session id was read back out of this file's own records",
661
        ),
662
        OnDisk::FromFileName { path } => (
663
            path.clone(),
664
            "the file is over the scanner's read bound, so the session id is its file name and \
665
             nothing was read from inside it",
666
        ),
667
        OnDisk::Missing { relative, roots } => {
668
            let tried = roots
669
                .iter()
670
                .map(|root| hide(&root.to_string_lossy(), home))
671
                .collect::<Vec<_>>()
672
                .join(", ");
673
            let tried = if tried.is_empty() {
674
                "no store root was mounted".to_string()
675
            } else {
676
                tried
677
            };
678
            return format!(
679
                "The scanner reported {}, and no mounted store holds it (tried: {tried}).\n\n\
680
                 Nothing is resumed from a path that is not there.",
681
                hide(relative, home)
682
            );
683
        }
684
        OnDisk::Mismatch { path } => {
685
            return format!(
686
                "{} does not carry the session id the scanner reported ({}).\n\n\
687
                 No resume command is printed for a session this file does not hold.",
688
                hide(&path.to_string_lossy(), home),
689
                hide(&session.session_id, home)
690
            );
691
        }
692
        OnDisk::Unreadable { path, error } => {
693
            return format!(
694
                "{} could not be read: {error}.\n\n\
695
                 No resume command is printed for a session that could not be confirmed.",
696
                hide(&path.to_string_lossy(), home)
697
            );
698
        }
699
    };
700
701
    let age = format_age(session.mtime_ms, deps.now_ms);
702
    let mut lines = vec![
703
        "Resume context:".to_string(),
704
        format!("  source:      {}", session.source.as_str()),
705
        format!("  session id:  {}", hide(&session.session_id, home)),
706
        format!("  file:        {}", hide(&path.to_string_lossy(), home)),
707
        format!(
708
            "  cwd:         {}",
709
            match session.cwd.as_deref().filter(|value| !value.is_empty()) {
710
                Some(value) => show_cwd(value, home),
711
                None => "(unknown)".to_string(),
712
            }
713
        ),
714
        format!("  age:         {age}"),
715
        format!(
716
            "  records:     {}",
717
            match session.record_count {
718
                Some(count) => count.to_string(),
719
                None => "(unknown)".to_string(),
720
            }
721
        ),
722
    ];
723
    if session.metadata_truncated {
724
        lines.push("  metadata:    truncated".to_string());
725
    }
726
    lines.push(format!("  confirmed:   {provenance}"));
727
    lines.push(String::new());
728
729
    if !id_shaped(&session.session_id) {
730
        lines.push(
731
            "The recorded session id is not shaped like one, so it is not put on a command line."
732
                .to_string(),
733
        );
734
        return lines.join("\n");
735
    }
736
737
    let verb = session.source.resume_verb();
738
    let id = &session.session_id;
739
    match shell_cwd(session.cwd.as_deref(), home) {
740
        ShellCwd::Literal(cwd) => {
741
            lines.push("Run this to resume in the foreign tool:".to_string());
742
            lines.push(format!("  cd \"{cwd}\" && {verb} {id}"));
743
        }
744
        ShellCwd::Home(cwd) => {
745
            lines.push("Run this to resume in the foreign tool:".to_string());
746
            lines.push(format!("  cd \"{cwd}\" && {verb} {id}"));
747
        }
748
        ShellCwd::Unknown => {
749
            lines.push(
750
                "The session recorded no working directory, so run this from wherever it ran:"
751
                    .to_string(),
752
            );
753
            lines.push(format!("  {verb} {id}"));
754
        }
755
        ShellCwd::Redacted(categories) => {
756
            lines.push(format!(
757
                "The recorded working directory carries material the redaction rules remove ({}), \
758
                 so no `cd` is printed. Resume from the directory the session file above sits under:",
759
                categories.join(", ")
760
            ));
761
            lines.push(format!("  {verb} {id}"));
762
        }
763
        ShellCwd::Unquotable => {
764
            lines.push(
765
                "The recorded working directory cannot be safely quoted into a shell command, so \
766
                 no `cd` is printed. Resume from the directory the session file above sits under:"
767
                    .to_string(),
768
            );
769
            lines.push(format!("  {verb} {id}"));
770
        }
771
    }
772
773
    lines.join("\n")
774
}
775
776
// ─────────────────────────────────────────────────────────────────── the turn
777
778
/// The seam the scanner is reached through. A test stands a fake here; the real
779
/// call is [`scanner_invoke`].
780
pub type ForeignResumeInvoke<'a> = &'a dyn Fn(&Value) -> Result<Value, String>;
781
782
/// Run one `/resume` turn and return the single notice to put on the transcript.
783
pub fn run_foreign_resume(
784
    deps: &ForeignResumeDeps,
785
    invoke: ForeignResumeInvoke<'_>,
786
    options: &ForeignResumeOptions,
787
) -> String {
788
    let packet = build_packet(deps, options);
789
790
    let raw = match invoke(&packet) {
791
        Ok(value) => value,
792
        Err(error) => return format!("The scanner could not run: {error}"),
793
    };
794
795
    let output = match normalize_scan_result(&raw) {
796
        ScanResult::Error(message) => return message,
797
        ScanResult::Refusal(refusal) => {
798
            return format!("The scanner refused ({}): {}", refusal.code, refusal.reason)
799
        }
800
        ScanResult::Ok(output) => *output,
801
    };
802
803
    let Some(selection) = deps.selection else {
804
        return describe_list(&output, deps);
805
    };
806
807
    if selection < 1 || selection > output.sessions.len() {
808
        let hint = if output.sessions.is_empty() {
809
            String::new()
810
        } else {
811
            format!(" Choose a number from 1 to {}.", output.sessions.len())
812
        };
813
        return format!(
814
            "There is no session at {selection}.{hint}\n\n{}",
815
            describe_list(&output, deps)
816
        );
817
    }
818
819
    describe_selection(&output.sessions[selection - 1], deps)
820
}
821
822
// ──────────────────────────────────────────────────────── driving the real one
823
824
/// Find the scanner in the plugin catalog and load it.
825
///
826
/// Every failure names the capability and where it was looked for, because the
827
/// alternative — an empty listing — is indistinguishable from a machine with no
828
/// foreign sessions on it.
829
pub fn load_scanner(from: &Path) -> Result<LoadedPlugin, String> {
830
    let catalog = plugins::discover_catalog(from);
831
    let Some(entry) = catalog
832
        .iter()
833
        .find(|entry: &&CatalogEntry| entry.name == SCANNER_NAME)
834
    else {
835
        return Err(format!(
836
            "The `{SCANNER_NAME}` capability is not installed: no `plugins/*/manifest.json` \
837
             declaring it was found from {} upward. Nothing was scanned.",
838
            from.display()
839
        ));
840
    };
841
842
    // Typing `/resume` is the operator action the mount tier asks for, and the
843
    // notice says which directories were read.
844
    Approval {
845
        mounts_allowed: true,
846
    }
847
    .check(entry)
848
    .map_err(|refusal| {
849
        format!("The `{SCANNER_NAME}` capability would not load {refusal}. Nothing was scanned.")
850
    })?;
851
852
    plugins::load_plugin(&entry.manifest_path, from).map_err(|refusal| {
853
        format!(
854
            "The `{SCANNER_NAME}` capability at {} would not load {refusal}. Nothing was scanned.",
855
            entry.manifest_path.display()
856
        )
857
    })
858
}
859
860
/// The real seam: one blocking `packet-v0` invocation of the loaded scanner.
861
pub fn scanner_invoke(plugin: &LoadedPlugin) -> impl Fn(&Value) -> Result<Value, String> + '_ {
862
    move |packet: &Value| {
863
        let bytes = serde_json::to_vec(packet).map_err(|error| error.to_string())?;
864
        let answer = plugins::invoke(plugin, &bytes).map_err(|refusal| refusal.to_string())?;
865
        serde_json::from_slice(&answer)
866
            .map_err(|error| format!("the scanner's answer was not JSON: {error}"))
867
    }
868
}
869
870
/// One whole `/resume` turn against the machine's real stores.
871
///
872
/// Blocking: the wasm invocation is synchronous. Call it off the UI thread.
873
pub fn foreign_resume_turn(cwd: &Path, home: &Path, selection: Option<usize>) -> String {
874
    let plugin = match load_scanner(cwd) {
875
        Ok(plugin) => plugin,
876
        Err(message) => return message,
877
    };
878
    let deps = ForeignResumeDeps {
879
        now_ms: now_ms(),
880
        cwd: cwd.to_string_lossy().into_owned(),
881
        selection,
882
        home: home.to_string_lossy().into_owned(),
883
        mount_roots: plugin.mounts.clone(),
884
    };
885
    let invoke = scanner_invoke(&plugin);
886
    let body = run_foreign_resume(&deps, &invoke, &ForeignResumeOptions::default());
887
888
    let roots = plugin
889
        .mounts
890
        .iter()
891
        .map(|root| hide(&root.to_string_lossy(), &deps.home))
892
        .collect::<Vec<_>>()
893
        .join(", ");
894
    if roots.is_empty() {
895
        body
896
    } else {
897
        format!("{body}\n\nRead read-only from: {roots}.")
898
    }
899
}
900
901
fn now_ms() -> i64 {
902
    std::time::SystemTime::now()
903
        .duration_since(std::time::UNIX_EPOCH)
904
        .map_or(0, |since| since.as_millis() as i64)
905
}
906
907
#[cfg(test)]
908
mod tests {
909
    use super::*;
910
911
    #[test]
912
    fn ages_read_the_way_the_typescript_reports_them() {
913
        let now = 1_000_000_000_000i64;
914
        assert_eq!(format_age(now - 5 * DAY_MS, now), "5 days ago");
915
        assert_eq!(format_age(now - DAY_MS, now), "1 day ago");
916
        assert_eq!(format_age(now - 3 * HOUR_MS, now), "3 hours ago");
917
        assert_eq!(format_age(now - HOUR_MS, now), "1 hour ago");
918
        assert_eq!(format_age(now - 1000, now), "just now");
919
        // A clock that moved backwards is not a negative age.
920
        assert_eq!(format_age(now + DAY_MS, now), "just now");
921
    }
922
923
    #[test]
924
    fn a_reported_path_cannot_climb_out_of_its_mount() {
925
        let root = Path::new("/store");
926
        assert_eq!(
927
            under_root(root, "projects/a.jsonl"),
928
            Some(PathBuf::from("/store/projects/a.jsonl"))
929
        );
930
        assert_eq!(under_root(root, "../../etc/passwd"), None);
931
        assert_eq!(under_root(root, "projects/../../etc/passwd"), None);
932
        assert_eq!(under_root(root, "/etc/passwd"), None);
933
        assert_eq!(under_root(root, ""), None);
934
    }
935
936
    #[test]
937
    fn a_working_directory_becomes_a_command_only_when_it_round_trips() {
938
        let home = "/Users/ada";
939
        assert_eq!(
940
            shell_cwd(Some("/Users/ada/work"), home),
941
            ShellCwd::Home("$HOME/work".to_string())
942
        );
943
        // Nothing to hide: printed as it stands.
944
        assert_eq!(
945
            shell_cwd(Some("/srv/build"), home),
946
            ShellCwd::Literal("/srv/build".to_string())
947
        );
948
        assert_eq!(shell_cwd(None, home), ShellCwd::Unknown);
949
        assert_eq!(shell_cwd(Some(""), home), ShellCwd::Unknown);
950
        // A shell metacharacter never reaches a command line.
951
        assert_eq!(
952
            shell_cwd(Some("/tmp/x\"; rm -rf ~; #"), home),
953
            ShellCwd::Unquotable
954
        );
955
        assert_eq!(shell_cwd(Some("/tmp/$(id)"), home), ShellCwd::Unquotable);
956
        // A home rewrite that is not the leading one cannot be put back with
957
        // `$HOME`, and a byte-index slice would have produced `$HOMEopt~/x`.
958
        assert!(matches!(
959
            shell_cwd(Some("/opt/Users/ada/x"), home),
960
            ShellCwd::Redacted(_)
961
        ));
962
        // The same slice would have split this leading character in half.
963
        assert!(matches!(
964
            shell_cwd(Some("é/Users/ada/x"), home),
965
            ShellCwd::Redacted(_)
966
        ));
967
        // A path the rules gut is refused rather than half-printed.
968
        match shell_cwd(Some("/srv/.secrets/tailnet.env"), home) {
969
            ShellCwd::Redacted(categories) => {
970
                assert!(
971
                    categories.iter().any(|c| c == "secrets_path"),
972
                    "{categories:?}"
973
                )
974
            }
975
            other => panic!("a secrets path must not become a command: {other:?}"),
976
        }
977
    }
978
}
crates/openagents-cli/src/interactive.rs modified +49

@@ -68,6 +68,10 @@ pub const COMMANDS: &[(&str, &str)] = &[

68 68
    ),
69 69
    ("export", "write the transcript to a file: /export <path>"),
70 70
    ("help", "list these commands"),
71
    (
72
        "resume",
73
        "recent Claude Code and Codex sessions on this machine: /resume, /resume <number>",
74
    ),
71 75
    (
72 76
        "run",
73 77
        "run a program under a terminal in this frame: /run <command>",

@@ -85,6 +89,9 @@ pub enum Control {

85 89
    Prompt(String),
86 90
    /// Collect a diff. The words are `/diff`'s arguments, already split.
87 91
    Diff(Vec<String>),
92
    /// Ask the foreign-session scanner what other coding agents left on this
93
    /// machine. `None` lists; `Some(n)` describes the nth listed session.
94
    ForeignResume(Option<usize>),
88 95
    /// Start a program under a pseudoterminal of this size.
89 96
    Run {
90 97
        command: Vec<String>,

@@ -386,6 +393,30 @@ impl CoderApp {

386 393
                    self.push(Role::Error, "The runtime task is gone.");
387 394
                }
388 395
            }
396
            "resume" => {
397
                // A bare `/resume` lists; `/resume <n>` picks. A word that is
398
                // not a positive number is refused rather than read as a list
399
                // request, because silently listing after a mistyped pick is
400
                // how someone resumes the wrong session.
401
                let selection = match arguments.first() {
402
                    None => None,
403
                    Some(word) => match word.parse::<usize>() {
404
                        Ok(number) if number >= 1 => Some(number),
405
                        _ => {
406
                            self.push(
407
                                Role::Error,
408
                                format!(
409
                                    "`/resume` takes a number from the list: `/resume 1`. `{word}` is not one."
410
                                ),
411
                            );
412
                            return;
413
                        }
414
                    },
415
                };
416
                if control.send(Control::ForeignResume(selection)).is_err() {
417
                    self.push(Role::Error, "The runtime task is gone.");
418
                }
419
            }
389 420
            "run" => {
390 421
                // The words after `/run` are the command, but a line a shell
391 422
                // would change the meaning of is given to a shell instead, so

@@ -868,6 +899,24 @@ pub async fn runtime_actor(

868 899
                    return;
869 900
                }
870 901
            }
902
            Control::ForeignResume(selection) => {
903
                // The scan compiles and runs a wasm guest and walks two state
904
                // directories, all of it synchronous. On a blocking thread so
905
                // the frame keeps drawing while it works.
906
                let here = cwd.clone();
907
                let home = crate::auth::home_directory();
908
                let scanned = tokio::task::spawn_blocking(move || {
909
                    crate::foreign_resume::foreign_resume_turn(&here, &home, selection)
910
                })
911
                .await;
912
                let notice = match scanned {
913
                    Ok(text) => text,
914
                    Err(error) => format!("The foreign-session scan did not finish: {error}"),
915
                };
916
                if events.send(TurnEvent::Notice(notice)).is_err() {
917
                    return;
918
                }
919
            }
871 920
            Control::Run {
872 921
                command,
873 922
                label,
crates/openagents-cli/src/lib.rs modified +1

@@ -22,6 +22,7 @@ pub mod delegate;

22 22
pub mod diag;
23 23
pub mod diff;
24 24
pub mod fleet;
25
pub mod foreign_resume;
25 26
pub mod forum;
26 27
pub mod identity;
27 28
pub mod interactive;
crates/openagents-cli/tests/coder_tui_test.rs modified +1 -1

@@ -1078,7 +1078,7 @@ fn tab_lists_the_candidates_rather_than_choosing_one() {

1078 1078
1079 1079
    let frame = screen(&term);
1080 1080
    assert!(
1081
        frame.contains("clear  diff  export  help  run"),
1081
        frame.contains("clear  diff  export  help  resume  run"),
1082 1082
        "the candidates were not listed:\n{frame}"
1083 1083
    );
1084 1084
    let composer = frame
crates/openagents-cli/tests/foreign_resume_test.rs added +989

@@ -0,0 +1,989 @@

1
//! `/resume`: the foreign-session picker, and what it refuses to make up.
2
//!
3
//! Three bands of test, in order of how much they can prove:
4
//!
5
//! 1. **Port fidelity.** The packet, the listing, the selection, and the four
6
//!    soft-failure shapes, against a fake scanner. These mirror
7
//!    `packages/openagents-cli/test/coder-foreign-resume.test.ts` so the two
8
//!    pickers cannot drift.
9
//! 2. **The file on disk.** A staged store where the file is present, absent,
10
//!    or present with a different session in it. A resume command is printed
11
//!    only in the first case.
12
//! 3. **The real machine.** The shipped `foreign_sessions` artifact, loaded and
13
//!    invoked for real over `~/.claude` and `~/.codex`, and every field the
14
//!    picker prints checked against the bytes of the session file itself —
15
//!    reopened and reparsed here, not taken from the scanner. Then the same
16
//!    real session with its id altered, which must be refused.
17
//!
18
//! Band 3 is the one that matters. A picker that returned a plausible listing
19
//! would pass band 1 and 2 and fail band 3, which is the failure this crate has
20
//! actually shipped: hardcoded sessions that scanned nothing.
21
22
use openagents_cli::foreign_resume::{
23
    build_packet, confirm_on_disk, foreign_resume_turn, format_age, load_scanner,
24
    normalize_scan_result, run_foreign_resume, scanner_invoke, ForeignResumeDeps,
25
    ForeignResumeOptions, ForeignScanOutput, ForeignSession, ForeignSource, OnDisk, ScanResult,
26
    DEFAULT_MAX_AGE_DAYS, DEFAULT_PICKER_LIMIT,
27
};
28
use openagents_cli::interactive::{CoderApp, Control};
29
use openagents_cli::runtime::Lane;
30
use openagents_cli::trace::redact_text;
31
use serde_json::{json, Value};
32
use std::path::{Path, PathBuf};
33
use tokio::sync::mpsc::unbounded_channel;
34
35
const DAY_MS: i64 = 86_400_000;
36
const HOUR_MS: i64 = 3_600_000;
37
const NOW_MS: i64 = 1_000_000_000_000;
38
39
fn repo_root() -> PathBuf {
40
    Path::new(env!("CARGO_MANIFEST_DIR"))
41
        .join("..")
42
        .join("..")
43
        .canonicalize()
44
        .expect("the crate sits inside the repository")
45
}
46
47
fn deps(selection: Option<usize>) -> ForeignResumeDeps {
48
    ForeignResumeDeps {
49
        now_ms: NOW_MS,
50
        cwd: "/test/cwd".to_string(),
51
        selection,
52
        home: "/Users/ada".to_string(),
53
        mount_roots: Vec::new(),
54
    }
55
}
56
57
/// A scanner that answers with a fixed packet and records what it was asked.
58
fn fixed(output: Value) -> impl Fn(&Value) -> Result<Value, String> {
59
    move |_packet: &Value| Ok(output.clone())
60
}
61
62
fn session(overrides: Value) -> Value {
63
    let mut base = json!({
64
        "source": "claude",
65
        "session_id": "abc-123",
66
        "path": "projects/abc.jsonl",
67
        "mtime_ms": NOW_MS,
68
        "size_bytes": 100,
69
        "record_count": 1,
70
        "metadata_truncated": false,
71
    });
72
    for (key, value) in overrides.as_object().expect("an object of overrides") {
73
        base[key] = value.clone();
74
    }
75
    base
76
}
77
78
// ───────────────────────────────────────────────────────────── band 1: the port
79
80
#[test]
81
fn the_packet_carries_the_cwd_filter_now_and_both_bounds() {
82
    let packet = build_packet(&deps(None), &ForeignResumeOptions::default());
83
84
    assert_eq!(packet["cwd_filter"], "/test/cwd");
85
    assert_eq!(packet["now_ms"], NOW_MS);
86
    assert_eq!(packet["max_age_days"], DEFAULT_MAX_AGE_DAYS);
87
    assert_eq!(packet["limit"], DEFAULT_PICKER_LIMIT as u64);
88
    // Sources are left unset so the guest scans its two defaults.
89
    assert!(packet.get("sources").is_none(), "{packet}");
90
}
91
92
#[test]
93
fn the_listing_is_numbered_in_the_order_the_scanner_gave() {
94
    let answer = json!({"ok": {"sessions": [
95
        session(json!({"source": "codex", "session_id": "codex-newest",
96
                       "cwd": "/Users/ada/gamma", "mtime_ms": NOW_MS - DAY_MS, "record_count": 3})),
97
        session(json!({"source": "claude", "session_id": "claude-older",
98
                       "cwd": "/Users/ada/alpha", "mtime_ms": NOW_MS - 3 * DAY_MS, "record_count": 7})),
99
    ]}});
100
    let out = run_foreign_resume(
101
        &deps(None),
102
        &fixed(answer),
103
        &ForeignResumeOptions::default(),
104
    );
105
106
    assert!(
107
        out.contains("Recent foreign sessions for this directory (/test/cwd):"),
108
        "{out}"
109
    );
110
    assert!(out.contains(" 1. "), "{out}");
111
    assert!(out.contains(" 2. "), "{out}");
112
    let newest = out.find("codex-newest").expect("the newest is listed");
113
    let older = out.find("claude-older").expect("the older is listed");
114
    assert!(
115
        newest < older,
116
        "the scanner's order was not preserved:\n{out}"
117
    );
118
    assert!(out.contains("1 day ago"), "{out}");
119
    assert!(out.contains("3 days ago"), "{out}");
120
    assert!(out.contains("Run /resume <number>"), "{out}");
121
}
122
123
#[test]
124
fn an_empty_listing_says_which_store_could_not_be_read() {
125
    let answer = json!({"ok": {"sessions": [], "missing_sources": ["claude", "codex"]}});
126
    let out = run_foreign_resume(
127
        &deps(None),
128
        &fixed(answer),
129
        &ForeignResumeOptions::default(),
130
    );
131
132
    assert!(
133
        out.contains("No recent foreign sessions were found"),
134
        "{out}"
135
    );
136
    assert!(out.contains("claude or codex state store"), "{out}");
137
}
138
139
#[test]
140
fn a_partial_scan_says_so_rather_than_reading_as_the_whole_picture() {
141
    let answer = json!({"ok": {
142
        "sessions": [session(json!({"session_id": "one"}))],
143
        "scan_truncated": true,
144
        "read_budget_exhausted": true,
145
    }});
146
    let out = run_foreign_resume(
147
        &deps(None),
148
        &fixed(answer),
149
        &ForeignResumeOptions::default(),
150
    );
151
152
    assert!(
153
        out.contains("The scan hit a bound and may be partial."),
154
        "{out}"
155
    );
156
    assert!(out.contains("file-read budget was exhausted"), "{out}");
157
}
158
159
#[test]
160
fn a_session_over_the_read_bound_is_flagged_as_metadata_only() {
161
    let answer = json!({"ok": {"sessions": [session(json!({
162
        "session_id": "huge", "record_count": null, "metadata_truncated": true,
163
    }))]}});
164
    let out = run_foreign_resume(
165
        &deps(None),
166
        &fixed(answer),
167
        &ForeignResumeOptions::default(),
168
    );
169
170
    assert!(out.contains("huge"), "{out}");
171
    assert!(out.contains("metadata only"), "{out}");
172
    assert!(out.contains("truncated"), "{out}");
173
    assert!(out.contains("(cwd unknown)"), "{out}");
174
}
175
176
#[test]
177
fn a_source_this_picker_cannot_resume_is_counted_rather_than_dropped_in_silence() {
178
    // The guest knows four stores; this manifest mounts two, and the picker
179
    // builds a resume command for two. A row it cannot resume is accounted for.
180
    let answer = json!({"ok": {"sessions": [
181
        session(json!({"session_id": "keep"})),
182
        session(json!({"source": "opencode", "session_id": "drop-1"})),
183
        session(json!({"source": "opencode", "session_id": "drop-2"})),
184
    ]}});
185
    let out = run_foreign_resume(
186
        &deps(None),
187
        &fixed(answer),
188
        &ForeignResumeOptions::default(),
189
    );
190
191
    assert!(out.contains("keep"), "{out}");
192
    assert!(
193
        !out.contains("drop-1"),
194
        "an unresumable row was listed:\n{out}"
195
    );
196
    assert!(
197
        out.contains("2 sessions from `opencode` were left out"),
198
        "the dropped rows were not accounted for:\n{out}"
199
    );
200
}
201
202
#[test]
203
fn an_out_of_range_pick_is_refused_and_the_list_comes_back() {
204
    let answer = json!({"ok": {"sessions": [
205
        session(json!({"session_id": "one"})),
206
        session(json!({"session_id": "two"})),
207
    ]}});
208
    let out = run_foreign_resume(
209
        &deps(Some(9)),
210
        &fixed(answer),
211
        &ForeignResumeOptions::default(),
212
    );
213
214
    assert!(out.contains("There is no session at 9"), "{out}");
215
    assert!(out.contains("Choose a number from 1 to 2."), "{out}");
216
    assert!(out.contains("Recent foreign sessions"), "{out}");
217
}
218
219
#[test]
220
fn the_four_soft_failures_each_say_what_happened() {
221
    let refusal = run_foreign_resume(
222
        &deps(None),
223
        &fixed(json!({"refusal": {"code": "unsupported", "reason": "unknown source"}})),
224
        &ForeignResumeOptions::default(),
225
    );
226
    assert!(
227
        refusal.contains("The scanner refused (unsupported)"),
228
        "{refusal}"
229
    );
230
    assert!(refusal.contains("unknown source"), "{refusal}");
231
232
    let malformed = run_foreign_resume(
233
        &deps(None),
234
        &fixed(json!({"refusal": {"code": 123}})),
235
        &ForeignResumeOptions::default(),
236
    );
237
    assert!(malformed.contains("malformed refusal"), "{malformed}");
238
239
    let unknown = run_foreign_resume(
240
        &deps(None),
241
        &fixed(json!({"unexpected": true})),
242
        &ForeignResumeOptions::default(),
243
    );
244
    assert!(unknown.contains("unrecognised packet"), "{unknown}");
245
246
    let trapped = run_foreign_resume(
247
        &deps(None),
248
        &|_packet: &Value| Err("worker trap".to_string()),
249
        &ForeignResumeOptions::default(),
250
    );
251
    assert!(trapped.contains("The scanner could not run"), "{trapped}");
252
    assert!(trapped.contains("worker trap"), "{trapped}");
253
}
254
255
#[test]
256
fn ages_read_the_way_the_typescript_reports_them() {
257
    assert_eq!(format_age(NOW_MS - 5 * DAY_MS, NOW_MS), "5 days ago");
258
    assert_eq!(format_age(NOW_MS - DAY_MS, NOW_MS), "1 day ago");
259
    assert_eq!(format_age(NOW_MS - 3 * HOUR_MS, NOW_MS), "3 hours ago");
260
    assert_eq!(format_age(NOW_MS - 1000, NOW_MS), "just now");
261
}
262
263
// ────────────────────────────────────────────────────── band 2: the file on disk
264
265
/// Stage a Claude-shaped store and return its root.
266
fn stage_claude_store(dir: &Path, relative: &str, session_id: &str, cwd: &str) -> PathBuf {
267
    let root = dir.join("store");
268
    let file = root.join(relative);
269
    std::fs::create_dir_all(file.parent().expect("a parent")).expect("the store is created");
270
    let body = format!(
271
        "{}\n{}\n",
272
        json!({"type": "queue-operation", "sessionId": session_id}),
273
        json!({"type": "user", "sessionId": session_id, "cwd": cwd}),
274
    );
275
    std::fs::write(&file, body).expect("the session file is written");
276
    root
277
}
278
279
fn staged_session(relative: &str, session_id: &str, cwd: Option<&str>) -> ForeignSession {
280
    ForeignSession {
281
        source: ForeignSource::Claude,
282
        session_id: session_id.to_string(),
283
        path: relative.to_string(),
284
        cwd: cwd.map(str::to_string),
285
        project_dir: None,
286
        mtime_ms: NOW_MS - DAY_MS,
287
        size_bytes: 200,
288
        record_count: Some(2),
289
        metadata_truncated: false,
290
    }
291
}
292
293
fn selection_over(root: &Path, home: &str, session: &Value) -> String {
294
    let mut deps = deps(Some(1));
295
    deps.home = home.to_string();
296
    deps.mount_roots = vec![root.to_path_buf()];
297
    run_foreign_resume(
298
        &deps,
299
        &fixed(json!({"ok": {"sessions": [session.clone()]}})),
300
        &ForeignResumeOptions::default(),
301
    )
302
}
303
304
#[test]
305
fn a_session_whose_file_carries_the_reported_id_gets_a_resume_command() {
306
    let dir = tempfile::tempdir().expect("a temporary directory");
307
    let root = stage_claude_store(
308
        dir.path(),
309
        "projects/-Users-ada-alpha/s-1.jsonl",
310
        "s-1",
311
        "/Users/ada/alpha",
312
    );
313
314
    let out = selection_over(
315
        &root,
316
        "/Users/ada",
317
        &session(json!({
318
            "session_id": "s-1",
319
            "path": "projects/-Users-ada-alpha/s-1.jsonl",
320
            "cwd": "/Users/ada/alpha",
321
        })),
322
    );
323
324
    assert!(out.contains("source:      claude"), "{out}");
325
    assert!(out.contains("session id:  s-1"), "{out}");
326
    assert!(out.contains("cwd:         ~/alpha"), "{out}");
327
    assert!(
328
        out.contains("the session id was read back out of this file's own records"),
329
        "{out}"
330
    );
331
    // `$HOME` and not `~`: inside double quotes a tilde is a literal, and a
332
    // `cd` to a directory named `~` is not where the session ran.
333
    assert!(
334
        out.contains("cd \"$HOME/alpha\" && claude --resume s-1"),
335
        "{out}"
336
    );
337
}
338
339
#[test]
340
fn a_codex_session_gets_the_codex_verb() {
341
    let dir = tempfile::tempdir().expect("a temporary directory");
342
    let root = dir.path().join("store");
343
    let file = root.join("sessions/2026/08/26/rollout-x.jsonl");
344
    std::fs::create_dir_all(file.parent().expect("a parent")).expect("the store is created");
345
    std::fs::write(
346
        &file,
347
        format!(
348
            "{}\n",
349
            json!({"type": "session_meta", "payload": {"id": "roll-9", "cwd": "/srv/build"}})
350
        ),
351
    )
352
    .expect("the rollout is written");
353
354
    let out = selection_over(
355
        &root,
356
        "/Users/ada",
357
        &session(json!({
358
            "source": "codex",
359
            "session_id": "roll-9",
360
            "path": "sessions/2026/08/26/rollout-x.jsonl",
361
            "cwd": "/srv/build",
362
        })),
363
    );
364
365
    assert!(
366
        out.contains("cd \"/srv/build\" && codex resume roll-9"),
367
        "{out}"
368
    );
369
}
370
371
#[test]
372
fn a_session_whose_file_is_gone_is_refused_by_name_with_the_path() {
373
    let dir = tempfile::tempdir().expect("a temporary directory");
374
    let root = stage_claude_store(
375
        dir.path(),
376
        "projects/p/s-1.jsonl",
377
        "s-1",
378
        "/Users/ada/alpha",
379
    );
380
381
    let out = selection_over(
382
        &root,
383
        "/Users/ada",
384
        &session(json!({
385
            "session_id": "s-2",
386
            "path": "projects/p/s-2.jsonl",
387
            "cwd": "/Users/ada/alpha",
388
        })),
389
    );
390
391
    assert!(
392
        out.contains("projects/p/s-2.jsonl"),
393
        "the path was not named:\n{out}"
394
    );
395
    assert!(out.contains("no mounted store holds it"), "{out}");
396
    assert!(
397
        out.contains("Nothing is resumed from a path that is not there."),
398
        "{out}"
399
    );
400
    assert!(
401
        !out.contains("claude --resume"),
402
        "a command was printed for a file that is not there:\n{out}"
403
    );
404
}
405
406
#[test]
407
fn a_file_that_holds_a_different_session_does_not_become_a_resume_command() {
408
    let dir = tempfile::tempdir().expect("a temporary directory");
409
    // The file exists at the reported path and holds someone else's session.
410
    let root = stage_claude_store(
411
        dir.path(),
412
        "projects/p/s-1.jsonl",
413
        "other-session",
414
        "/Users/ada/alpha",
415
    );
416
417
    let out = selection_over(
418
        &root,
419
        "/Users/ada",
420
        &session(json!({
421
            "session_id": "s-1",
422
            "path": "projects/p/s-1.jsonl",
423
            "cwd": "/Users/ada/alpha",
424
        })),
425
    );
426
427
    assert!(
428
        out.contains("does not carry the session id the scanner reported"),
429
        "{out}"
430
    );
431
    assert!(out.contains("s-1"), "{out}");
432
    assert!(
433
        !out.contains("claude --resume"),
434
        "a command was printed for a session the file does not hold:\n{out}"
435
    );
436
}
437
438
#[test]
439
fn a_reported_path_cannot_climb_out_of_the_mounted_store() {
440
    let dir = tempfile::tempdir().expect("a temporary directory");
441
    let root = dir.path().join("store");
442
    std::fs::create_dir_all(&root).expect("the store is created");
443
    std::fs::write(
444
        dir.path().join("outside.jsonl"),
445
        "{\"sessionId\":\"s-1\"}\n",
446
    )
447
    .expect("the outside file is written");
448
449
    let escaped = staged_session("../outside.jsonl", "s-1", Some("/Users/ada/alpha"));
450
    match confirm_on_disk(&escaped, std::slice::from_ref(&root)) {
451
        OnDisk::Missing { relative, .. } => assert_eq!(relative, "../outside.jsonl"),
452
        other => panic!("a path that leaves the store was resolved: {other:?}"),
453
    }
454
455
    let absolute = staged_session("/etc/passwd", "s-1", None);
456
    assert!(matches!(
457
        confirm_on_disk(&absolute, &[root]),
458
        OnDisk::Missing { .. }
459
    ));
460
}
461
462
#[test]
463
fn an_oversized_file_says_the_id_came_from_its_name() {
464
    let dir = tempfile::tempdir().expect("a temporary directory");
465
    // The scanner never opened this one, so the id is the stem and the body is
466
    // deliberately not a match for it.
467
    let root = stage_claude_store(
468
        dir.path(),
469
        "projects/p/big.jsonl",
470
        "unrelated",
471
        "/Users/ada/x",
472
    );
473
474
    let out = selection_over(
475
        &root,
476
        "/Users/ada",
477
        &session(json!({
478
            "session_id": "big",
479
            "path": "projects/p/big.jsonl",
480
            "cwd": null,
481
            "record_count": null,
482
            "metadata_truncated": true,
483
        })),
484
    );
485
486
    assert!(out.contains("metadata:    truncated"), "{out}");
487
    assert!(
488
        out.contains("the session id is its file name and nothing was read from inside it"),
489
        "{out}"
490
    );
491
    assert!(out.contains("records:     (unknown)"), "{out}");
492
    assert!(out.contains("claude --resume big"), "{out}");
493
    assert!(
494
        !out.contains("cd \""),
495
        "a cwd was invented for a file nobody read:\n{out}"
496
    );
497
}
498
499
// ────────────────────────────────────────────────────────── band 2b: redaction
500
501
#[derive(serde::Deserialize)]
502
struct PlantedSecret {
503
    label: String,
504
    credential: bool,
505
    raw: String,
506
    leak: String,
507
}
508
509
#[derive(serde::Deserialize)]
510
struct PlantedSecrets {
511
    secrets: Vec<PlantedSecret>,
512
}
513
514
/// The one place a token family is written down. Restating the patterns here is
515
/// how `oa_pat_` leaked out of the TypeScript redactor in the first place.
516
fn planted_credentials() -> Vec<PlantedSecret> {
517
    let path = repo_root().join("fixtures/redaction/planted-secrets.json");
518
    let text = std::fs::read_to_string(&path).unwrap_or_else(|error| {
519
        panic!(
520
            "the shared redaction fixture is unreadable at {}: {error}",
521
            path.display()
522
        )
523
    });
524
    let parsed: PlantedSecrets = serde_json::from_str(&text).expect("the fixture is JSON");
525
    parsed
526
        .secrets
527
        .into_iter()
528
        .filter(|s| s.credential)
529
        .collect()
530
}
531
532
#[test]
533
fn no_planted_credential_survives_into_the_listing_or_the_selection() {
534
    let credentials = planted_credentials();
535
    assert!(
536
        credentials.len() >= 16,
537
        "the fixture yielded {} credentials, too few to be the real file",
538
        credentials.len()
539
    );
540
541
    let dir = tempfile::tempdir().expect("a temporary directory");
542
    let mut survivors: Vec<String> = Vec::new();
543
544
    for entry in &credentials {
545
        // A foreign session file is untrusted content: the working directory
546
        // and the session id are whatever some other agent wrote there.
547
        let planted = session(json!({
548
            "session_id": "s-1",
549
            "path": "projects/p/s-1.jsonl",
550
            "cwd": entry.raw,
551
        }));
552
        let root = stage_claude_store(dir.path(), "projects/p/s-1.jsonl", "s-1", &entry.raw);
553
554
        let mut listing_deps = deps(None);
555
        listing_deps.home = "/Users/octavia".to_string();
556
        listing_deps.mount_roots = vec![root.clone()];
557
        let listing = run_foreign_resume(
558
            &listing_deps,
559
            &fixed(json!({"ok": {"sessions": [planted.clone()]}})),
560
            &ForeignResumeOptions::default(),
561
        );
562
        let selection = selection_over(&root, "/Users/octavia", &planted);
563
564
        if listing.contains(&entry.leak) || selection.contains(&entry.leak) {
565
            survivors.push(entry.label.clone());
566
        }
567
        // And a working directory the rules had to touch never becomes a `cd`.
568
        assert!(
569
            !selection.contains("cd \""),
570
            "{} produced a `cd` out of a redacted working directory:\n{selection}",
571
            entry.label
572
        );
573
    }
574
575
    assert!(
576
        survivors.is_empty(),
577
        "these planted credentials reached the transcript through /resume: {}",
578
        survivors.join(", ")
579
    );
580
}
581
582
#[test]
583
fn a_working_directory_that_would_run_a_command_never_reaches_the_command_line() {
584
    let dir = tempfile::tempdir().expect("a temporary directory");
585
    let hostile = "/tmp/x\"; rm -rf ~; #";
586
    let root = stage_claude_store(dir.path(), "projects/p/s-1.jsonl", "s-1", hostile);
587
588
    let out = selection_over(
589
        &root,
590
        "/Users/ada",
591
        &session(json!({"session_id": "s-1", "path": "projects/p/s-1.jsonl", "cwd": hostile})),
592
    );
593
594
    assert!(out.contains("cannot be safely quoted"), "{out}");
595
    // The recorded value is still shown — as an escaped literal, so it reads as
596
    // data rather than as the line above it, which says `cd`.
597
    assert!(
598
        out.contains(r#"cwd:         "/tmp/x\"; rm -rf ~; #""#),
599
        "the recorded working directory was not shown as a literal:\n{out}"
600
    );
601
    // Exactly one line in the output is something to run, and it is the one
602
    // built from fields that passed the shape checks.
603
    let runnable: Vec<&str> = out
604
        .lines()
605
        .filter(|line| {
606
            line.starts_with("  ") && (line.contains("cd ") || line.contains("--resume"))
607
        })
608
        .collect();
609
    assert_eq!(
610
        runnable,
611
        vec!["  claude --resume s-1"],
612
        "a shell payload reached a printed command:\n{out}"
613
    );
614
}
615
616
// ─────────────────────────────────────────────────── band 3: this real machine
617
618
/// The id and working directory a session file itself records, parsed here
619
/// rather than taken from the scanner. Claude writes `sessionId` on a
620
/// top-level record; Codex writes `payload.id` on a `session_meta` first line.
621
fn read_back(path: &Path) -> (Option<String>, Option<String>) {
622
    let text = std::fs::read_to_string(path).unwrap_or_default();
623
    let mut id = None;
624
    let mut cwd = None;
625
    for line in text.lines().take(20) {
626
        let Ok(value) = serde_json::from_str::<Value>(line) else {
627
            continue;
628
        };
629
        if id.is_none() {
630
            if let Some(found) = value.get("sessionId").and_then(Value::as_str) {
631
                id = Some(found.to_string());
632
            } else if value.get("type").and_then(Value::as_str) == Some("session_meta") {
633
                id = value
634
                    .get("payload")
635
                    .and_then(|p| p.get("id"))
636
                    .and_then(Value::as_str)
637
                    .map(str::to_string);
638
            }
639
        }
640
        if cwd.is_none() {
641
            if let Some(found) = value.get("cwd").and_then(Value::as_str) {
642
                cwd = Some(found.to_string());
643
            } else if let Some(found) = value
644
                .get("payload")
645
                .and_then(|p| p.get("cwd"))
646
                .and_then(Value::as_str)
647
            {
648
                cwd = Some(found.to_string());
649
            }
650
        }
651
        if id.is_some() && cwd.is_some() {
652
            break;
653
        }
654
    }
655
    (id, cwd)
656
}
657
658
/// One real scan of this machine's stores: the raw packet and the parsed one.
659
///
660
/// `None` when the artifact is not beside the crate (a published crate carries
661
/// no `plugins/`) or the stores are not on this machine.
662
fn real_scan(home: &Path) -> Option<(Value, ForeignScanOutput, Vec<PathBuf>)> {
663
    let root = repo_root();
664
    if !root
665
        .join("plugins/foreign-sessions/manifest.json")
666
        .is_file()
667
    {
668
        return None;
669
    }
670
    let plugin = match load_scanner(&root) {
671
        Ok(plugin) => plugin,
672
        // A machine with no `~/.claude` or `~/.codex` refuses the mount at
673
        // load. There is nothing to check, rather than something to fail.
674
        Err(_) => return None,
675
    };
676
    let deps = ForeignResumeDeps {
677
        now_ms: std::time::SystemTime::now()
678
            .duration_since(std::time::UNIX_EPOCH)
679
            .map_or(0, |since| since.as_millis() as i64),
680
        // No filter: every session either store holds, newest first.
681
        cwd: String::new(),
682
        selection: None,
683
        home: home.to_string_lossy().into_owned(),
684
        mount_roots: plugin.mounts.clone(),
685
    };
686
    let options = ForeignResumeOptions {
687
        max_age_days: Some(36_500.0),
688
        limit: Some(10),
689
    };
690
    let raw = scanner_invoke(&plugin)(&build_packet(&deps, &options))
691
        .expect("the shipped scanner answers with a packet");
692
    let output = match normalize_scan_result(&raw) {
693
        ScanResult::Ok(output) => *output,
694
        other => panic!("the shipped scanner did not answer with a scan: {other:?}"),
695
    };
696
    Some((raw, output, plugin.mounts.clone()))
697
}
698
699
fn home() -> PathBuf {
700
    PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
701
}
702
703
/// The newest reported session the scanner actually opened, and its 1-based
704
/// pick number.
705
///
706
/// The newest session on a working machine is often the one being written right
707
/// now, which is over the per-file read bound and carries only its file name.
708
/// The content checks below need one whose id and cwd came out of the records,
709
/// so they ask for that rather than settling for whichever row is first.
710
fn newest_readable(output: &ForeignScanOutput) -> Option<(usize, ForeignSession)> {
711
    output
712
        .sessions
713
        .iter()
714
        .enumerate()
715
        .find(|(_, session)| !session.metadata_truncated)
716
        .map(|(index, session)| (index + 1, session.clone()))
717
}
718
719
#[test]
720
fn the_real_scanner_reports_sessions_that_are_actually_on_this_machine() {
721
    let home = home();
722
    let Some((_, output, roots)) = real_scan(&home) else {
723
        eprintln!("skipped: no shipped scanner or no foreign store on this machine");
724
        return;
725
    };
726
    if output.sessions.is_empty() {
727
        eprintln!("skipped: this machine's foreign stores hold no sessions");
728
        return;
729
    }
730
731
    // Every reported session resolves to a file that is there and holds it.
732
    for session in &output.sessions {
733
        match confirm_on_disk(session, &roots) {
734
            OnDisk::Confirmed { path } => {
735
                let (id, _) = read_back(&path);
736
                assert_eq!(
737
                    id.as_deref(),
738
                    Some(session.session_id.as_str()),
739
                    "{} does not record the id the scanner reported",
740
                    path.display()
741
                );
742
            }
743
            OnDisk::FromFileName { path } => {
744
                let name = path
745
                    .file_name()
746
                    .expect("a name")
747
                    .to_string_lossy()
748
                    .into_owned();
749
                assert_eq!(
750
                    name.strip_suffix(".jsonl").unwrap_or(&name),
751
                    session.session_id,
752
                    "an oversized session's id is not its file name"
753
                );
754
            }
755
            other => {
756
                panic!("the scanner reported a session that is not on disk as reported: {other:?}")
757
            }
758
        }
759
    }
760
}
761
762
#[test]
763
fn resuming_a_real_session_prints_what_that_session_file_actually_holds() {
764
    let home = home();
765
    let Some((raw, output, roots)) = real_scan(&home) else {
766
        eprintln!("skipped: no shipped scanner or no foreign store on this machine");
767
        return;
768
    };
769
    let Some((pick, first)) = newest_readable(&output) else {
770
        eprintln!("skipped: no session on this machine was inside the scanner's read bound");
771
        return;
772
    };
773
774
    // The renderer runs against the packet the real scan produced, so the
775
    // scan is not repeated — but the file it names is opened for real below.
776
    let picked = ForeignResumeDeps {
777
        now_ms: std::time::SystemTime::now()
778
            .duration_since(std::time::UNIX_EPOCH)
779
            .map_or(0, |since| since.as_millis() as i64),
780
        cwd: String::new(),
781
        selection: Some(pick),
782
        home: home.to_string_lossy().into_owned(),
783
        mount_roots: roots.clone(),
784
    };
785
    let rendered = run_foreign_resume(
786
        &picked,
787
        &fixed(raw.clone()),
788
        &ForeignResumeOptions {
789
            max_age_days: Some(36_500.0),
790
            limit: Some(10),
791
        },
792
    );
793
794
    let path = match confirm_on_disk(&first, &roots) {
795
        OnDisk::Confirmed { path } => path,
796
        other => panic!("a session the scanner read is not confirmed on disk: {other:?}"),
797
    };
798
    let (file_id, file_cwd) = read_back(&path);
799
    let home_text = home.to_string_lossy().into_owned();
800
801
    eprintln!("--- /resume {pick} against this machine ---\n{rendered}\n---");
802
    assert!(rendered.starts_with("Resume context:"), "{rendered}");
803
    assert!(
804
        rendered.contains(&format!("session id:  {}", first.session_id)),
805
        "the printed id is not the reported one:\n{rendered}"
806
    );
807
    assert!(
808
        rendered.contains(&format!(
809
            "file:        {}",
810
            redact_text(&path.to_string_lossy(), &home_text).text
811
        )),
812
        "the printed file is not the file that was confirmed:\n{rendered}"
813
    );
814
815
    assert!(
816
        rendered.contains("the session id was read back out of this file's own records"),
817
        "{rendered}"
818
    );
819
820
    let file_id = file_id.expect("a confirmed session file records its id");
821
    assert_eq!(
822
        file_id, first.session_id,
823
        "the scanner's id and the file's own id disagree"
824
    );
825
    assert!(
826
        rendered.contains(&format!("{} {file_id}", first.source.resume_verb())),
827
        "the resume command does not name the id the file records:\n{rendered}"
828
    );
829
830
    let file_cwd = file_cwd.expect("a confirmed session file records its cwd");
831
    assert_eq!(
832
        first.cwd.as_deref(),
833
        Some(file_cwd.as_str()),
834
        "the scanner's cwd and the file's own cwd disagree"
835
    );
836
    // The rendered cwd is the file's, redacted. Assert on the last path segment
837
    // too, which the rules leave alone, so this is not just the redactor
838
    // agreeing with itself.
839
    assert!(
840
        rendered.contains(&format!(
841
            "cwd:         {}",
842
            redact_text(&file_cwd, &home_text).text
843
        )),
844
        "the printed cwd is not the file's own:\n{rendered}"
845
    );
846
    if let Some(last) = file_cwd.rsplit('/').next().filter(|s| !s.is_empty()) {
847
        if redact_text(last, &home_text).total == 0 {
848
            assert!(
849
                rendered.contains(last),
850
                "the printed cwd dropped the directory the session ran in ({last}):\n{rendered}"
851
            );
852
        }
853
    }
854
    // And the `cd` is the one that lands back in that directory.
855
    let expected_cd = if file_cwd.starts_with(&home_text) && !home_text.is_empty() {
856
        format!("cd \"$HOME{}\"", &file_cwd[home_text.len()..])
857
    } else {
858
        format!("cd \"{file_cwd}\"")
859
    };
860
    assert!(
861
        rendered.contains(&expected_cd),
862
        "the printed `cd` does not lead to the directory the file records ({expected_cd}):\n{rendered}"
863
    );
864
865
    // Nothing from the file's body leaked into a metadata-only rendering.
866
    let body = std::fs::read_to_string(&path).unwrap_or_default();
867
    for line in body.lines().skip(20).take(50) {
868
        if line.len() > 80 {
869
            assert!(
870
                !rendered.contains(line),
871
                "a transcript record reached the picker's output:\n{line}"
872
            );
873
        }
874
    }
875
}
876
877
/// The composer's `/resume` reaches the runtime as the message the actor runs.
878
///
879
/// Without this the picker could be perfect and unreachable, which is the state
880
/// this port was left in twice: a module with nowhere to hang.
881
#[test]
882
fn the_composer_turns_slash_resume_into_the_message_the_actor_runs() {
883
    let (tx, mut rx) = unbounded_channel::<Control>();
884
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
885
886
    app.submit("/resume".to_string(), &tx);
887
    assert!(matches!(rx.try_recv(), Ok(Control::ForeignResume(None))));
888
889
    app.submit("/resume 3".to_string(), &tx);
890
    assert!(matches!(rx.try_recv(), Ok(Control::ForeignResume(Some(3)))));
891
892
    // A mistyped pick is refused rather than quietly re-listing, because a
893
    // listing is what a picked session that failed to parse looks like.
894
    app.submit("/resume later".to_string(), &tx);
895
    assert!(
896
        rx.try_recv().is_err(),
897
        "a mistyped pick reached the runtime"
898
    );
899
    assert!(
900
        app.transcript().contains("`later` is not one"),
901
        "{}",
902
        app.transcript()
903
    );
904
905
    app.submit("/resume 0".to_string(), &tx);
906
    assert!(rx.try_recv().is_err(), "a zero pick reached the runtime");
907
908
    // And `/help` advertises it, so it is not a hidden command.
909
    app.submit("/help".to_string(), &tx);
910
    assert!(
911
        app.transcript()
912
            .contains("/resume — recent Claude Code and Codex sessions"),
913
        "{}",
914
        app.transcript()
915
    );
916
}
917
918
/// The whole turn the actor runs, on this machine, with nothing stubbed.
919
#[test]
920
fn the_whole_turn_runs_against_this_machine_and_names_what_it_read() {
921
    let root = repo_root();
922
    if !root
923
        .join("plugins/foreign-sessions/manifest.json")
924
        .is_file()
925
    {
926
        eprintln!("skipped: no shipped scanner beside this crate");
927
        return;
928
    }
929
    let home = home();
930
    let out = foreign_resume_turn(&root, &home, None);
931
    eprintln!("--- /resume in {} ---\n{out}\n---", root.display());
932
933
    if out.contains("Nothing was scanned.") {
934
        eprintln!("skipped: this machine has no foreign store to mount");
935
        return;
936
    }
937
    assert!(
938
        out.starts_with("Recent foreign sessions for this directory"),
939
        "{out}"
940
    );
941
    // Read access is disclosed, not implied, and named as what was mounted.
942
    assert!(out.contains("Read read-only from:"), "{out}");
943
    assert!(out.contains(".claude") && out.contains(".codex"), "{out}");
944
    // The home path never reaches the transcript raw.
945
    assert!(
946
        !out.contains(&home.to_string_lossy().into_owned()),
947
        "the invoking user's home path was printed unredacted:\n{out}"
948
    );
949
}
950
951
#[test]
952
fn a_real_session_with_the_wrong_id_on_it_is_refused_against_the_file() {
953
    let home = home();
954
    let Some((raw, output, roots)) = real_scan(&home) else {
955
        eprintln!("skipped: no shipped scanner or no foreign store on this machine");
956
        return;
957
    };
958
    let Some((pick, _)) = newest_readable(&output) else {
959
        eprintln!("skipped: no session on this machine was inside the scanner's read bound");
960
        return;
961
    };
962
963
    // Same real packet, same real file, one field altered: the exact shape of a
964
    // scanner that reported a session the store does not hold.
965
    let mut tampered = raw.clone();
966
    tampered["ok"]["sessions"][pick - 1]["session_id"] =
967
        Value::String("00000000-0000-4000-8000-000000000000".to_string());
968
969
    let deps = ForeignResumeDeps {
970
        now_ms: NOW_MS,
971
        cwd: String::new(),
972
        selection: Some(pick),
973
        home: home.to_string_lossy().into_owned(),
974
        mount_roots: roots,
975
    };
976
    let rendered = run_foreign_resume(&deps, &fixed(tampered), &ForeignResumeOptions::default());
977
978
    assert!(
979
        rendered.contains("does not carry the session id the scanner reported"),
980
        "an unbacked session id was accepted against a real file:\n{rendered}"
981
    );
982
    assert!(
983
        !rendered.contains("--resume") && !rendered.contains("codex resume"),
984
        "a resume command was printed for an id the file does not hold:\n{rendered}"
985
    );
986
    // And the honest one still works, so the refusal is not the only outcome.
987
    let honest = run_foreign_resume(&deps, &fixed(raw), &ForeignResumeOptions::default());
988
    assert!(honest.starts_with("Resume context:"), "{honest}");
989
}

This page updates live while a promote is in flight · changelog