|
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
|
+ |
}
|