Skip to main content

rpi_cli/
resource_selection.rs

1//! Opt-in native startup resource selection. This is a host contract, not a package format.
2use sha2::{Digest, Sha256};
3use std::collections::HashSet;
4use std::io::Read;
5use std::path::{Component, Path, PathBuf};
6use std::sync::{Mutex, OnceLock, Weak};
7
8use rpi_harness::agent_harness::AgentHarness;
9use rpi_harness::session::types::SessionStorage;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12
13use crate::args::{Args, Mode};
14mod fork;
15pub(crate) use fork::initial_values as fork_initial_values;
16pub(crate) use fork::initial_values_for_selection as fork_initial_values_for_selection;
17
18pub const MAX_SELECTION_BYTES: usize = 64 * 1024;
19pub const MAX_STATE_BYTES: usize = 256 * 1024;
20pub(crate) fn transition_capability() -> Value {
21    json!({"version":1, "startupFlag":"--resource-transition", "maxRequestBytes":MAX_SELECTION_BYTES,
22        "maxOperationIdBytes":128,"operationIdPattern":"^[A-Za-z0-9._-]{1,128}$",
23        "request":{"requiredFields":["version","operation_id","expected","target"],
24            "unknownFields":"reject","duplicateFields":"reject",
25            "expected":{"revision":"u64","fingerprint":"lowercase-sha256-or-null","unboundRevision":0},
26            "target":"complete-exact-selection-v1"},
27        "expected":"revision-and-fingerprint", "retry":"same-operation-current-binding", "legacyTarget":false})
28}
29
30pub(crate) const MAX_CAPABILITIES_BYTES: usize = 16 * 1024;
31/// Pure static profile; no environment, filesystem, provider or native loader.
32pub(crate) fn capabilities() -> Value {
33    json!({"version":1,"type":"resource_capabilities","maxResponseBytes":MAX_CAPABILITIES_BYTES,
34        "host":{"name":crate::APP_NAME,"version":crate::VERSION},
35        "exactSelection":{"versions":[1],"startupFlag":"--resource-config","mode":"rpc",
36            "maxConfigBytes":MAX_SELECTION_BYTES,"maxSnapshotBytes":MAX_STATE_BYTES,
37            "stateCommand":"get_resource_state","toolModes":["default","only","none"],
38            "packageScopes":["project","global"],"ordinaryReopen":"immutable","hotApply":false},
39        "management":crate::resource_management::capabilities(),
40        "transition":transition_capability(),"savedSessionRead":crate::saved_session_read::capabilities(),"loadedFacts":false})
41}
42const SESSION_KEY: &str = "rpi.resource-selection.v1";
43
44/// Only public generic append APIs are restricted. Native SessionValueWriter
45/// remains available for the host's atomic declaration/inventory persistence.
46pub(crate) fn validate_public_entry(custom_type: &str, data: Option<&Value>) -> Result<(), String> {
47    if custom_type == rpi_harness::session::values::VALUE_CUSTOM_TYPE
48        && data
49            .and_then(|d| d.get("key"))
50            .and_then(Value::as_str)
51            .is_some_and(|key| {
52                key.starts_with("rpi.resource-selection.")
53                    || key.starts_with("rpi.resource-inventory.")
54            })
55    {
56        return Err(
57            "host-owned resource session values cannot be appended through public APIs".into(),
58        );
59    }
60    Ok(())
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ResourceSelection {
66    pub version: u32,
67    pub main_package: Option<MainPackage>,
68    #[serde(default)]
69    pub skills: Vec<PathBuf>,
70    #[serde(default)]
71    pub extensions: Vec<PathBuf>,
72    #[serde(default)]
73    pub instructions: Vec<PathBuf>,
74    pub tools: ToolSelection,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct MainPackage {
80    pub name: String,
81    #[serde(
82        default,
83        skip_serializing_if = "crate::resource_management::Scope::is_project"
84    )]
85    pub scope: crate::resource_management::Scope,
86    /// Explicit package-relative persistent instruction files. Prompts are never auto-executed.
87    #[serde(default)]
88    pub instructions: Vec<PathBuf>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
93pub enum ToolSelection {
94    Default {},
95    Only { names: Vec<String> },
96    None {},
97}
98
99#[derive(Debug, Clone, Serialize)]
100pub struct ResolvedPackage {
101    pub name: String,
102    pub version: Option<String>,
103    pub source: String,
104    pub cargo_package: Option<String>,
105    pub root: PathBuf,
106}
107
108#[derive(Debug, Clone)]
109pub struct ResolvedSelection {
110    pub package: Option<ResolvedPackage>,
111    pub selection: ResourceSelection,
112    pub skills: Vec<PathBuf>,
113    pub extensions: Vec<PathBuf>,
114    pub instructions: Vec<(PathBuf, String, String)>,
115    pub transition: Option<ResourceTransition>,
116    validated_skills: Option<ValidatedSkills>,
117}
118
119#[derive(Debug, Clone)]
120pub(crate) struct ValidatedSkills {
121    pub skills: Vec<rpi_harness::types::Skill>,
122    inventory: Value,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct ResourceTransition {
128    pub version: u32,
129    pub operation_id: String,
130    pub expected: ExpectedBinding,
131    pub target: ResourceSelection,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(deny_unknown_fields)]
136pub struct ExpectedBinding {
137    pub revision: u64,
138    pub fingerprint: Option<String>,
139}
140
141impl ResourceTransition {
142    fn validate(&self) -> Result<(), String> {
143        if self.version != 1
144            || self.operation_id.is_empty()
145            || self.operation_id.len() > 128
146            || !self
147                .operation_id
148                .bytes()
149                .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
150            || (self.expected.revision == 0) != self.expected.fingerprint.is_none()
151            || self.expected.fingerprint.as_ref().is_some_and(|f| {
152                f.len() != 64
153                    || !f
154                        .bytes()
155                        .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
156            })
157        {
158            return Err(
159                "invalid resource transition version, operation ID or expected binding".into(),
160            );
161        }
162        Ok(())
163    }
164}
165
166fn contained(path: &Path, root: &Path) -> Result<PathBuf, String> {
167    let path = std::fs::canonicalize(path).map_err(|e| e.to_string())?;
168    if !path.starts_with(root) || path == root {
169        return Err(format!(
170            "package resource escapes canonical package root: {}",
171            path.display()
172        ));
173    }
174    Ok(path)
175}
176
177// Validate nested symlinks/junctions too, before feeding package directories to
178// the existing recursive loader. Explicit standalone sources are caller-authorized.
179fn validate_package_tree(
180    path: &Path,
181    root: &Path,
182    visited: &mut HashSet<PathBuf>,
183) -> Result<(), String> {
184    let path = contained(path, root)?;
185    if path.is_dir() {
186        if !visited.insert(path.clone()) {
187            return Err("package resource directory cycle/alias".into());
188        }
189        for entry in std::fs::read_dir(path).map_err(|e| e.to_string())? {
190            validate_package_tree(&entry.map_err(|e| e.to_string())?.path(), root, visited)?;
191        }
192    }
193    Ok(())
194}
195
196fn read_limited(path: &Path) -> Result<Vec<u8>, String> {
197    let mut bytes = Vec::new();
198    std::fs::File::open(path)
199        .map_err(|e| format!("{}: {e}", path.display()))?
200        .take((MAX_SELECTION_BYTES + 1) as u64)
201        .read_to_end(&mut bytes)
202        .map_err(|e| e.to_string())?;
203    if bytes.len() > MAX_SELECTION_BYTES {
204        return Err(format!("resource input exceeds 64 KiB: {}", path.display()));
205    }
206    Ok(bytes)
207}
208fn fingerprint(path: &Path) -> Result<String, String> {
209    let mut reader = std::fs::File::open(path).map_err(|e| e.to_string())?;
210    let mut hash = Sha256::new();
211    let mut buf = [0; 8192];
212    loop {
213        let n = reader.read(&mut buf).map_err(|e| e.to_string())?;
214        if n == 0 {
215            break;
216        }
217        hash.update(&buf[..n]);
218    }
219    Ok(format!("{:x}", hash.finalize()))
220}
221
222fn file(path: &Path) -> Result<PathBuf, String> {
223    if !path.is_file() {
224        return Err(format!("missing resource file: {}", path.display()));
225    }
226    std::fs::canonicalize(path).map_err(|e| e.to_string())
227}
228fn unique_paths(paths: &[PathBuf]) -> Result<(), String> {
229    let mut seen = HashSet::new();
230    for path in paths {
231        if !seen.insert(path) {
232            return Err(format!("duplicate resource: {}", path.display()));
233        }
234    }
235    Ok(())
236}
237fn relative(path: &Path) -> Result<(), String> {
238    if path.as_os_str().is_empty()
239        || path
240            .components()
241            .any(|c| !matches!(c, Component::Normal(_)))
242    {
243        return Err(format!(
244            "expected a package-relative path: {}",
245            path.display()
246        ));
247    }
248    Ok(())
249}
250
251impl ResolvedSelection {
252    pub fn load(args: &Args, cwd: &Path, trusted: bool) -> Result<Option<Self>, String> {
253        let Some(config) = args
254            .resource_transition
255            .as_ref()
256            .or(args.resource_config.as_ref())
257        else {
258            return Ok(None);
259        };
260        if (args.resource_config.is_some() && args.resource_transition.is_some())
261            || (args.resource_transition.is_some()
262                && (args.name.is_some()
263                    || args.fork.is_some()
264                    || args.no_session
265                    || !args.messages.is_empty()))
266            || args.mode != Mode::Rpc
267            || args.dev_local_only
268            || args.project_only
269            || args.trust_override == Some(false)
270            || !trusted
271            || args.system_prompt.is_some()
272            || !args.append_system_prompt.is_empty()
273            || args.tools.is_some()
274            || args.exclude_tools.is_some()
275            || args.no_tools
276            || args.no_builtin_tools
277            || args.no_skills
278            || args.no_prompt_templates
279            || args.no_extensions
280            || args.no_context_files
281            || !args.skill.is_empty()
282            || !args.prompt_template.is_empty()
283            || !args.extension.is_empty()
284            || !args.extensions_dir.is_empty()
285            || !args.unknown_flags.is_empty()
286        {
287            return Err("--resource-config requires trusted --mode rpc and conflicts with resource/tool/prompt/discovery/dev flags and unknown extension flags".into());
288        }
289        let bytes = read_limited(&cwd.join(config))?;
290        let transition: Option<ResourceTransition> = if args.resource_transition.is_some() {
291            let request: ResourceTransition = serde_json::from_slice(&bytes)
292                .map_err(|e| format!("invalid resource transition: {e}"))?;
293            request.validate()?;
294            Some(request)
295        } else {
296            None
297        };
298        let selection: ResourceSelection = if let Some(request) = &transition {
299            request.target.clone()
300        } else {
301            serde_json::from_slice(&bytes).map_err(|e| format!("invalid resource config: {e}"))?
302        };
303        if selection.version != 1 {
304            return Err("unsupported resource config version".into());
305        }
306        if let ToolSelection::Only { names } = &selection.tools {
307            let mut seen = HashSet::new();
308            for name in names {
309                if name.is_empty() || !seen.insert(name) {
310                    return Err("empty or duplicate tool name".into());
311                }
312            }
313        }
314        let mut skills = Vec::new();
315        let mut extensions = Vec::new();
316        let mut instructions = Vec::new();
317        let mut resolved_package = None;
318        if let Some(main) = &selection.main_package {
319            let native_root = main.scope.root(cwd)?;
320            let package = crate::packages::selected_package_at(&native_root, &main.name)?;
321            let packages_root =
322                std::fs::canonicalize(native_root.join("packages")).map_err(|e| e.to_string())?;
323            let root = std::fs::canonicalize(packages_root.join(&main.name))
324                .map_err(|e| format!("missing package directory: {e}"))?;
325            if !root.is_dir() || !root.starts_with(&packages_root) || root == packages_root {
326                return Err("package root escapes .rpi/packages".into());
327            }
328            let skill_dir = root.join("skills");
329            if skill_dir.exists() {
330                let skill_dir = contained(&skill_dir, &root)?;
331                validate_package_tree(&skill_dir, &root, &mut HashSet::new())?;
332                skills.push(skill_dir);
333            }
334            for artifact in &package.artifacts {
335                relative(Path::new(artifact))?;
336                extensions.push(contained(
337                    &file(&root.join("extensions").join(artifact))?,
338                    &root,
339                )?);
340            }
341            for instruction in &main.instructions {
342                relative(instruction)?;
343                let path = contained(&file(&root.join(instruction))?, &root)?;
344                instructions.push((path, format!("package:{}", main.name), String::new()));
345            }
346            resolved_package = Some(ResolvedPackage {
347                name: package.name,
348                version: if package.version.is_empty() {
349                    None
350                } else {
351                    Some(package.version)
352                },
353                source: package.source,
354                cargo_package: if package.cargo_package.is_empty() {
355                    None
356                } else {
357                    Some(package.cargo_package)
358                },
359                root,
360            });
361        }
362        // Explicit additions first: use the existing first-registration-wins loader.
363        let mut additions = Vec::new();
364        for skill in &selection.skills {
365            let path = cwd.join(skill);
366            if !path.exists() {
367                return Err(format!("missing skill: {}", path.display()));
368            }
369            additions.push(std::fs::canonicalize(path).map_err(|e| e.to_string())?);
370        }
371        additions.extend(skills);
372        skills = additions;
373        for extension in &selection.extensions {
374            extensions.push(file(&cwd.join(extension))?);
375        }
376        for instruction in &selection.instructions {
377            instructions.push((
378                file(&cwd.join(instruction))?,
379                "explicit".into(),
380                String::new(),
381            ));
382        }
383        unique_paths(&skills)?;
384        unique_paths(&extensions)?;
385        unique_paths(&instructions.iter().map(|i| i.0.clone()).collect::<Vec<_>>())?;
386        for extension in &extensions {
387            if extension.extension().and_then(|e| e.to_str())
388                != Some(std::env::consts::DLL_EXTENSION)
389            {
390                return Err(format!("not a native extension: {}", extension.display()));
391            }
392        }
393        for (path, _, text) in &mut instructions {
394            *text = String::from_utf8(read_limited(path)?)
395                .map_err(|e| format!("instruction {}: {e}", path.display()))?;
396        }
397        Ok(Some(Self {
398            package: resolved_package,
399            selection,
400            skills,
401            extensions,
402            instructions,
403            transition,
404            validated_skills: None,
405        }))
406    }
407
408    /// Resolve once, before code or native writers. Hash the very bytes parsed
409    /// by the existing loader, preserving ignore/recursion and first-wins rules.
410    pub(crate) async fn validate_skills(
411        &mut self,
412        env: &std::sync::Arc<dyn rpi_tools::ExecutionEnv>,
413    ) -> Result<(), String> {
414        let hashes = Mutex::new(std::collections::HashMap::new());
415        let observe = |path: &str, raw: &str| {
416            hashes.lock().unwrap().insert(
417                path.to_owned(),
418                format!("{:x}", Sha256::digest(raw.as_bytes())),
419            );
420        };
421        let mut skills = Vec::new();
422        let mut diagnostics = Vec::new();
423        for path in &self.skills {
424            let loaded = rpi_harness::skills::load_skills_with_source_observer(
425                env,
426                &[path.to_string_lossy().into_owned()],
427                &observe,
428            )
429            .await;
430            if loaded.skills.is_empty() {
431                return Err(format!(
432                    "selected skill source loaded no Skills: {}",
433                    path.display()
434                ));
435            }
436            skills.extend(loaded.skills);
437            diagnostics.extend(loaded.diagnostics);
438        }
439        let skills = crate::resource_dirs::dedupe_skills(skills, &mut diagnostics);
440        if !diagnostics.is_empty() {
441            return Err(format!("selected skill diagnostics: {diagnostics:?}"));
442        }
443        let hashes = hashes.into_inner().unwrap();
444        let inventory = json!(skills
445            .iter()
446            .map(|skill| json!({
447                "name": skill.name, "path": skill.file_path, "sha256": hashes[&skill.file_path]
448            }))
449            .collect::<Vec<_>>());
450        self.validated_skills = Some(ValidatedSkills { skills, inventory });
451        Ok(())
452    }
453
454    pub(crate) fn validated_skills(&self) -> Result<&ValidatedSkills, String> {
455        self.validated_skills
456            .as_ref()
457            .ok_or_else(|| "selected Skills were not preflight validated".into())
458    }
459
460    pub fn active_tools(&self, registered: &[String]) -> Result<Vec<String>, String> {
461        match &self.selection.tools {
462            ToolSelection::Default {} => Ok(registered.to_vec()),
463            ToolSelection::None {} => Ok(Vec::new()),
464            ToolSelection::Only { names } => {
465                for name in names {
466                    if !registered.contains(name) {
467                        return Err(format!("unknown selected tool: {name}"));
468                    }
469                }
470                Ok(names.clone())
471            }
472        }
473    }
474
475    pub fn provenance(&self) -> Value {
476        json!(self.instructions.iter().map(|(path, source, text)| json!({"path": path, "source": source, "sha256": format!("{:x}",Sha256::digest(text.as_bytes()))})).collect::<Vec<_>>())
477    }
478}
479
480// CLI attachment keyed by allocation, not durable session ID: concurrent hosts reopening
481// the same transcript must not inherit each other's startup observations.
482type Attachment = (Weak<dyn SessionStorage>, Value);
483fn states() -> &'static Mutex<Vec<Attachment>> {
484    static STATES: OnceLock<Mutex<Vec<Attachment>>> = OnceLock::new();
485    STATES.get_or_init(|| Mutex::new(Vec::new()))
486}
487pub(crate) fn attach(harness: &AgentHarness, state: Value) {
488    let mut states = states().lock().unwrap();
489    states.retain(|(storage, _)| storage.strong_count() > 0);
490    states.push((
491        std::sync::Arc::downgrade(&harness.session().storage()),
492        state,
493    ));
494}
495
496pub async fn get_resource_state(harness: &AgentHarness) -> Result<Value, String> {
497    let storage = harness.session().storage();
498    let state = states().lock().unwrap().iter().find_map(|(s, state)| {
499        s.upgrade()
500            .filter(|s| std::sync::Arc::ptr_eq(s, &storage))
501            .map(|_| state.clone())
502    });
503    let mut state = state.ok_or("get_resource_state requires --resource-config v1")?;
504    let metadata = harness
505        .session()
506        .get_metadata()
507        .await
508        .map_err(|e| e.to_string())?;
509    let resources = harness.get_resources().await.map_err(|e| e.to_string())?;
510    let registered: Vec<_> = harness
511        .get_tools()
512        .await
513        .map_err(|e| e.to_string())?
514        .iter()
515        .map(|t| t.tool.schema().name.clone())
516        .collect();
517    let execution = harness.execution_context().map_err(|e| e.to_string())?;
518    let active = execution["tools"].clone();
519    let read = active
520        .as_array()
521        .is_some_and(|names| names.iter().any(|n| n == "read"));
522    state["sessionId"] = json!(metadata.id);
523    let values = rpi_harness::session::SessionValues::load(harness.session())
524        .await
525        .map_err(|e| e.to_string())?;
526    if let Some(binding) = values.get(SESSION_KEY) {
527        validate_persisted_binding(binding)?;
528    }
529    state["binding"] = binding_state(&values);
530    state["transition"] = transition_capability();
531    state["registeredTools"] = json!(registered);
532    state["modelEffectiveTools"] = active;
533    state["loadedSkills"] = json!(resources.skills.unwrap_or_default().iter().map(|s| json!({
534        "name": s.name, "path": s.file_path, "modelVisible": read && !s.disable_model_invocation.unwrap_or(false)
535    })).collect::<Vec<_>>());
536    state["promptTemplates"] = json!(resources
537        .prompt_templates
538        .unwrap_or_default()
539        .iter()
540        .map(|t| &t.name)
541        .collect::<Vec<_>>());
542    if serde_json::to_vec(&state).map_err(|e| e.to_string())?.len() > MAX_STATE_BYTES {
543        return Err("resource snapshot exceeds 256 KiB".into());
544    }
545    Ok(state)
546}
547
548fn declaration(s: &ResolvedSelection) -> Value {
549    json!({"selection": s.selection, "package": s.package, "skills": s.skills, "extensions": s.extensions, "instructions": s.provenance()})
550}
551
552pub(crate) async fn bind_session(
553    session: &rpi_harness::session::Session,
554    selected: Option<&ResolvedSelection>,
555) -> Result<(), String> {
556    use rpi_harness::session::SessionValues;
557    let values = SessionValues::load(session)
558        .await
559        .map_err(|e| e.to_string())?;
560    validate_session_binding(&values, selected)
561}
562
563/// Shared read-only check for preflight facts and the accepted writer's session.
564pub(crate) fn validate_session_binding(
565    values: &rpi_harness::session::SessionValues,
566    selected: Option<&ResolvedSelection>,
567) -> Result<(), String> {
568    if let Some(binding) = values.get(SESSION_KEY) {
569        validate_persisted_binding(binding)?;
570    }
571    if let Some(request) = selected.and_then(|s| s.transition.as_ref()) {
572        validate_transition(values, request)?;
573        // A committed retry must still verify its original artifact inventory.
574        if values
575            .get(SESSION_KEY)
576            .and_then(|v| v.get("operationId"))
577            .and_then(Value::as_str)
578            != Some(request.operation_id.as_str())
579        {
580            return Ok(());
581        }
582    }
583    match (values.get(SESSION_KEY), selected) {
584        (Some(old), Some(selected)) if old.get("declaration") == Some(&declaration(selected)) => {
585            // Code identity is checked before dlopen/registration, not just after
586            // the schema registry has been constructed. No provider callback runs here.
587            let extensions = selected.extensions.iter().map(|path| Ok(json!({"path":path,"sha256":fingerprint(path)?})))
588                .collect::<Result<Vec<_>, String>>()?;
589            if old.pointer("/inventory/extensions") != Some(&json!(extensions)) {
590                return Err("session resource artifact inventory changed; restore original resources or use a new session".into());
591            }
592            if old.pointer("/inventory/skills") != Some(&selected.validated_skills()?.inventory) {
593                return Err("session resource Skill inventory changed; restore original resources or use a new session".into());
594            }
595            Ok(())
596        },
597        (Some(_), _) => Err("session resource selection differs or is missing; restart with the same declared selection or use a new session".into()),
598        (None, _) => Ok(()),
599    }
600}
601
602/// Fingerprint only native binding facts, not caller operation metadata.
603fn binding_fingerprint(binding: &Value) -> String {
604    format!(
605        "{:x}",
606        Sha256::digest(
607            serde_json::to_vec(&json!({
608                "declaration": binding["declaration"], "inventory": binding["inventory"]
609            }))
610            .expect("JSON binding")
611        )
612    )
613}
614
615fn valid_digest(value: &Value) -> bool {
616    value.as_str().is_some_and(|s| {
617        s.len() == 64
618            && s.bytes()
619                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
620    })
621}
622
623/// Missing old-v1 fields are compatible; present malformed facts are never defaults.
624fn validate_persisted_binding(binding: &Value) -> Result<u64, String> {
625    let invalid = || "invalid persisted resource binding metadata".to_string();
626    let object = binding.as_object().ok_or_else(invalid)?;
627    if !object.get("declaration").is_some_and(Value::is_object)
628        || !object.get("inventory").is_some_and(Value::is_object)
629        || serde_json::to_vec(binding).map_err(|_| invalid())?.len() > MAX_STATE_BYTES
630    {
631        return Err(invalid());
632    }
633    let revision = match object.get("revision") {
634        None if !object.contains_key("operationId") && !object.contains_key("operations") => 1,
635        Some(value) => value.as_u64().filter(|r| *r > 0).ok_or_else(invalid)?,
636        None => return Err(invalid()),
637    };
638    let operations = match object.get("operations") {
639        Some(value) => Some(value.as_object().ok_or_else(invalid)?),
640        None => None,
641    };
642    let current = match object.get("operationId") {
643        Some(value) => Some(value.as_str().ok_or_else(invalid)?),
644        None => None,
645    };
646    let mut revisions = std::collections::HashSet::new();
647    if let Some(operations) = operations {
648        for (id, operation) in operations {
649            if id.is_empty()
650                || id.len() > 128
651                || !id
652                    .bytes()
653                    .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
654            {
655                return Err(invalid());
656            }
657            let op = operation.as_object().ok_or_else(invalid)?;
658            if op.len() != 4
659                || !valid_digest(&operation["target"])
660                || !valid_digest(&operation["fingerprint"])
661            {
662                return Err(invalid());
663            }
664            let expected: ExpectedBinding =
665                serde_json::from_value(operation["expected"].clone()).map_err(|_| invalid())?;
666            if (expected.revision == 0) != expected.fingerprint.is_none()
667                || expected
668                    .fingerprint
669                    .as_ref()
670                    .is_some_and(|f| !valid_digest(&json!(f)))
671            {
672                return Err(invalid());
673            }
674            let result = operation["revision"].as_u64().ok_or_else(invalid)?;
675            if expected.revision.checked_add(1) != Some(result)
676                || result > revision
677                || !revisions.insert(result)
678            {
679                return Err(invalid());
680            }
681            if current == Some(id.as_str()) {
682                let target = format!(
683                    "{:x}",
684                    Sha256::digest(
685                        serde_json::to_vec(&binding["declaration"]["selection"])
686                            .map_err(|_| invalid())?
687                    )
688                );
689                if result != revision
690                    || operation["fingerprint"] != binding_fingerprint(binding)
691                    || operation["target"] != target
692                {
693                    return Err(invalid());
694                }
695            } else if result == revision {
696                return Err(invalid());
697            }
698            // Ledger entries are never pruned. Validate every retained predecessor
699            // association, not only the operation being retried.
700            if let Some(previous) = operations
701                .values()
702                .find(|p| p["revision"].as_u64() == Some(expected.revision))
703            {
704                if previous["fingerprint"] != json!(expected.fingerprint) {
705                    return Err(invalid());
706                }
707            }
708        }
709    }
710    if let Some(operations) = operations {
711        if !operations.is_empty() {
712            let first = *revisions.iter().min().ok_or_else(invalid)?;
713            if first > 2
714                || revision.checked_sub(first).and_then(|n| n.checked_add(1))
715                    != Some(operations.len() as u64)
716            {
717                return Err(invalid());
718            }
719        }
720    }
721    match current {
722        Some(id) if !operations.is_some_and(|ops| ops.contains_key(id)) => return Err(invalid()),
723        None if operations.is_some_and(|ops| !ops.is_empty()) || revision != 1 => {
724            return Err(invalid())
725        }
726        _ => {}
727    }
728    Ok(revision)
729}
730
731pub(crate) fn saved_binding_state(
732    values: &rpi_harness::session::SessionValues,
733) -> Result<Value, String> {
734    if let Some(binding) = values.get(SESSION_KEY) {
735        validate_persisted_binding(binding)?;
736    }
737    Ok(binding_state(values))
738}
739pub(crate) fn binding_state(values: &rpi_harness::session::SessionValues) -> Value {
740    match values.get(SESSION_KEY) {
741        Some(binding) => match validate_persisted_binding(binding) {
742            Ok(revision) => json!({"revision": revision,
743                "fingerprint": binding_fingerprint(binding), "operationId": binding.get("operationId")}),
744            Err(error) => json!({"error": error}),
745        },
746        None => json!({"revision": 0, "fingerprint": null, "operationId": null}),
747    }
748}
749
750fn target_fingerprint(request: &ResourceTransition) -> String {
751    format!(
752        "{:x}",
753        Sha256::digest(serde_json::to_vec(&request.target).expect("typed target"))
754    )
755}
756
757fn validate_transition(
758    values: &rpi_harness::session::SessionValues,
759    request: &ResourceTransition,
760) -> Result<(), String> {
761    let old = values.get(SESSION_KEY);
762    if let Some(binding) = old {
763        validate_persisted_binding(binding)?;
764    }
765    let actual = binding_state(values);
766    if let Some(operation) = old
767        .and_then(|v| v.get("operations"))
768        .and_then(|v| v.get(&request.operation_id))
769    {
770        if operation["target"] != target_fingerprint(request)
771            || operation["expected"] != json!(request.expected)
772            || actual["operationId"] != request.operation_id
773            || operation["revision"] != actual["revision"]
774            || operation["fingerprint"] != actual["fingerprint"]
775        {
776            return Err("resource transition operation conflict or superseded retry".into());
777        }
778        return Ok(());
779    }
780    if actual["revision"] != request.expected.revision
781        || actual["fingerprint"] != json!(request.expected.fingerprint)
782    {
783        return Err("stale resource transition expected binding".into());
784    }
785    Ok(())
786}
787
788/// Reopening observes the same files/registrations, not merely the same JSON.
789/// Native values remain in the native transcript, outside model-visible history.
790pub(crate) async fn bind_inventory(
791    harness: &AgentHarness,
792    selected: &ResolvedSelection,
793) -> Result<(), String> {
794    use rpi_harness::session::{SessionValueWriter, SessionValues};
795    let resources = harness.get_resources().await.map_err(|e| e.to_string())?;
796    let mut skills = Vec::new();
797    for skill in resources.skills.unwrap_or_default() {
798        skills.push(json!({"name": skill.name, "path": skill.file_path, "sha256": fingerprint(Path::new(&skill.file_path))?}));
799    }
800    // Defense in depth: neither a registration-time file edit nor a different
801    // constructed resource set may bind source bytes other than those parsed.
802    if json!(skills) != selected.validated_skills()?.inventory {
803        return Err("session resource Skill inventory changed after preflight".into());
804    }
805    let mut extensions = Vec::new();
806    for path in &selected.extensions {
807        extensions.push(json!({"path": path, "sha256": fingerprint(path)?}));
808    }
809    let tools: Vec<_> = harness
810        .get_tools()
811        .await
812        .map_err(|e| e.to_string())?
813        .iter()
814        .map(|t| json!(t.tool.schema()))
815        .collect();
816    let inventory = json!({"skills":skills, "extensions":extensions, "toolSchemasSha256":format!("{:x}",Sha256::digest(serde_json::to_vec(&tools).map_err(|e| e.to_string())?))});
817    let session = harness.session();
818    let values = SessionValues::load(session)
819        .await
820        .map_err(|e| e.to_string())?;
821    validate_session_binding(&values, Some(selected))?;
822    let old = values.get(SESSION_KEY);
823    let retry = selected.transition.as_ref().is_some_and(|r| {
824        old.and_then(|v| v.get("operationId"))
825            .and_then(Value::as_str)
826            == Some(r.operation_id.as_str())
827    });
828    if let Some(old) = old.filter(|_| selected.transition.is_none() || retry) {
829        if old.get("inventory") != Some(&inventory)
830            || old.get("declaration") != Some(&declaration(selected))
831        {
832            return Err(
833                "session resource inventory changed; use original resources or a new session"
834                    .into(),
835            );
836        }
837    } else {
838        // One native append commits the full binding and operation association.
839        let revision = binding_state(&values)["revision"]
840            .as_u64()
841            .unwrap()
842            .checked_add(1)
843            .ok_or("resource binding revision overflow")?;
844        let mut binding = json!({"declaration": declaration(selected), "inventory": inventory, "revision": revision});
845        if let Some(request) = &selected.transition {
846            let mut operations = old
847                .and_then(|v| v.get("operations"))
848                .cloned()
849                .unwrap_or(json!({}));
850            operations[&request.operation_id] = json!({"target": target_fingerprint(request), "expected": request.expected,
851                "revision": revision, "fingerprint": binding_fingerprint(&binding)});
852            binding["operations"] = operations;
853            binding["operationId"] = json!(request.operation_id);
854        }
855        if serde_json::to_vec(&binding)
856            .map_err(|e| e.to_string())?
857            .len()
858            > MAX_STATE_BYTES
859        {
860            return Err("resource binding/operation ledger exceeds 256 KiB".into());
861        }
862        SessionValueWriter::new(session.clone())
863            .set(SESSION_KEY, binding)
864            .await
865            .map_err(|e| e.to_string())?;
866    }
867    Ok(())
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    #[test]
874    fn typed_schema_rejects_unknown_and_duplicate_fields_at_each_level() {
875        for input in [
876            r#"{"version":1,"tools":{"mode":"none","unexpected":1}}"#,
877            r#"{"version":1,"tools":{"mode":"default","names":[]}}"#,
878            r#"{"version":1,"main_package":{"name":"x","unexpected":1},"tools":{"mode":"none"}}"#,
879            r#"{"version":1,"version":1,"tools":{"mode":"none"}}"#,
880            r#"{"version":1,"tools":{"mode":"only","names":[],"unexpected":1}}"#,
881        ] {
882            assert!(
883                serde_json::from_str::<ResourceSelection>(input).is_err(),
884                "{input}"
885            );
886        }
887        for path in ["../outside.md", "./instruction.md", ""] {
888            assert!(relative(Path::new(path)).is_err());
889        }
890    }
891
892    #[test]
893    fn configuration_instruction_and_registry_reads_are_bounded() {
894        let temp = tempfile::tempdir().unwrap();
895        let root = temp.path();
896        let input = root.join("input.md");
897        std::fs::write(&input, vec![b'x'; MAX_SELECTION_BYTES]).unwrap();
898        assert_eq!(read_limited(&input).unwrap().len(), MAX_SELECTION_BYTES);
899        std::fs::write(&input, vec![b'x'; MAX_SELECTION_BYTES + 1]).unwrap();
900        assert!(read_limited(&input).unwrap_err().contains("exceeds 64 KiB"));
901        std::fs::create_dir(root.join(".rpi")).unwrap();
902        std::fs::write(
903            root.join(".rpi/packages.json"),
904            vec![b' '; MAX_STATE_BYTES + 1],
905        )
906        .unwrap();
907        assert!(crate::packages::selected_project_package(root, "valid")
908            .unwrap_err()
909            .contains("exceeds 256 KiB"));
910    }
911
912    #[tokio::test]
913    async fn real_storage_v1_reopen_and_complete_committed_retry_never_double_revision() {
914        use rpi_harness::session::jsonl::{JsonlSessionStorage, JsonlV4Header};
915        use rpi_harness::session::{Session, SessionValueWriter, SessionValues};
916        let temp = tempfile::tempdir().unwrap();
917        let path = temp.path().join("history.jsonl");
918        let fs: std::sync::Arc<dyn rpi_tools::FileSystem> =
919            std::sync::Arc::new(rpi_tools::OsExecutionEnv::with_cwd(temp.path().into()));
920        let clock = std::sync::Arc::new(rpi_harness::session::memory::SystemClock);
921        let ids = std::sync::Arc::new(rpi_harness::session::session::DefaultIdGenerator::new());
922        let storage = JsonlSessionStorage::create(
923            fs.clone(),
924            path.to_str().unwrap(),
925            JsonlV4Header::new(
926                "binding-retry".into(),
927                1,
928                temp.path().to_string_lossy().into(),
929                None,
930                None,
931                None,
932            ),
933            clock.clone(),
934            ids.clone(),
935        )
936        .await
937        .unwrap();
938        let session = Session::new(std::sync::Arc::new(storage), None);
939        let harness = AgentHarness::create(rpi_harness::types::AgentHarnessOptions {
940            session: session.clone(),
941            ..Default::default()
942        })
943        .await
944        .unwrap();
945        let message = session
946            .append_message(rpi_agent::message::AgentMessage::User(
947                rpi_ai::types::UserMessage::new("preserved real message", 1),
948            ))
949            .await
950            .unwrap();
951        let mut selected = ResolvedSelection {
952            package: None,
953            selection: serde_json::from_value(json!({"version":1,"tools":{"mode":"none"}}))
954                .unwrap(),
955            skills: vec![],
956            extensions: vec![],
957            instructions: vec![],
958            transition: None,
959            validated_skills: None,
960        };
961        let env: std::sync::Arc<dyn rpi_tools::ExecutionEnv> =
962            std::sync::Arc::new(rpi_tools::OsExecutionEnv::with_cwd(temp.path().into()));
963        selected.validate_skills(&env).await.unwrap();
964        bind_inventory(&harness, &selected).await.unwrap();
965        // Construct a real old-v1 fixture through native storage, not transcript
966        // rewriting. Ordinary exact reopen must not migrate its binding fields.
967        let mut old = SessionValues::load(&session)
968            .await
969            .unwrap()
970            .get(SESSION_KEY)
971            .unwrap()
972            .clone();
973        old.as_object_mut().unwrap().remove("revision");
974        let original = old.clone();
975        for (field, value) in [
976            ("revision", json!("corrupt")),
977            ("revision", json!(null)),
978            ("revision", json!(0)),
979            ("operations", json!([])),
980            ("operations", json!(1)),
981            ("operationId", json!(7)),
982            ("operations", json!({"bad":null})),
983            (
984                "operations",
985                json!({"bad":{"target":"bad","expected":{"revision":0,"fingerprint":null},"revision":1,"fingerprint":"bad"}}),
986            ),
987        ] {
988            let mut damaged = original.clone();
989            damaged[field] = value;
990            SessionValueWriter::new(session.clone())
991                .set(SESSION_KEY, damaged)
992                .await
993                .unwrap();
994            let before = std::fs::read(&path).unwrap();
995            assert!(bind_session(&session, Some(&selected))
996                .await
997                .unwrap_err()
998                .contains("invalid persisted"));
999            assert!(bind_inventory(&harness, &selected).await.is_err());
1000            let facts = SessionValues::load(&session).await.unwrap();
1001            assert!(binding_state(&facts).get("error").is_some());
1002            assert_eq!(std::fs::read(&path).unwrap(), before);
1003        }
1004        SessionValueWriter::new(session.clone())
1005            .set(SESSION_KEY, original)
1006            .await
1007            .unwrap();
1008        let bytes = std::fs::read(&path).unwrap();
1009        bind_session(&session, Some(&selected)).await.unwrap();
1010        bind_inventory(&harness, &selected).await.unwrap();
1011        assert_eq!(std::fs::read(&path).unwrap(), bytes);
1012        let base = binding_state(&SessionValues::load(&session).await.unwrap());
1013        assert_eq!(base["revision"], 1);
1014        selected.selection.tools = ToolSelection::Only { names: vec![] };
1015        selected.transition = Some(ResourceTransition {
1016            version: 1,
1017            operation_id: "committed-with-lost-result".into(),
1018            expected: serde_json::from_value(
1019                json!({"revision":base["revision"],"fingerprint":base["fingerprint"]}),
1020            )
1021            .unwrap(),
1022            target: selected.selection.clone(),
1023        });
1024        bind_inventory(&harness, &selected).await.unwrap();
1025        drop(harness);
1026        drop(session);
1027        let committed = std::fs::read(&path).unwrap();
1028        let storage = JsonlSessionStorage::load_for_resource_transition(
1029            fs,
1030            path.to_str().unwrap(),
1031            clock,
1032            ids,
1033        )
1034        .await
1035        .unwrap();
1036        let session = Session::new(std::sync::Arc::new(storage), None);
1037        let harness = AgentHarness::create(rpi_harness::types::AgentHarnessOptions {
1038            session: session.clone(),
1039            allow_existing_session: true,
1040            ..Default::default()
1041        })
1042        .await
1043        .unwrap();
1044        // This is the same production retry path used when the storage fault
1045        // regression leaves a whole new record despite reporting append error.
1046        bind_session(&session, Some(&selected)).await.unwrap();
1047        bind_inventory(&harness, &selected).await.unwrap();
1048        assert_eq!(std::fs::read(&path).unwrap(), committed);
1049        let state = binding_state(&SessionValues::load(&session).await.unwrap());
1050        assert_eq!(state["revision"], 2);
1051        assert_eq!(state["operationId"], "committed-with-lost-result");
1052        assert!(session.get_entry(&message).await.unwrap().is_some());
1053    }
1054
1055    #[test]
1056    fn persisted_operation_associations_are_validated_in_full() {
1057        let selected = json!({"version":1,"tools":{"mode":"none"}});
1058        let target = format!(
1059            "{:x}",
1060            Sha256::digest(serde_json::to_vec(&selected).unwrap())
1061        );
1062        let mut binding = json!({"declaration":{"selection":selected},"inventory":{},"revision":2,"operationId":"one"});
1063        let fingerprint = binding_fingerprint(&binding);
1064        binding["operations"] = json!({"one":{"target":target,"expected":{"revision":1,"fingerprint":"a".repeat(64)},"revision":2,"fingerprint":fingerprint}});
1065        assert_eq!(validate_persisted_binding(&binding).unwrap(), 2);
1066        for (pointer, value) in [
1067            ("/operationId", json!("missing")),
1068            ("/operations/one/target", json!("b".repeat(64))),
1069            ("/operations/one/fingerprint", json!("b".repeat(64))),
1070            (
1071                "/operations/one/expected",
1072                json!({"revision":1,"fingerprint":null}),
1073            ),
1074            (
1075                "/operations/one/expected",
1076                json!({"revision":0,"fingerprint":"a".repeat(64)}),
1077            ),
1078            ("/operations/one/revision", json!(3)),
1079            ("/operations/one/expected/revision", json!("1")),
1080        ] {
1081            let mut damaged = binding.clone();
1082            *damaged.pointer_mut(pointer).unwrap() = value;
1083            assert!(validate_persisted_binding(&damaged).is_err(), "{pointer}");
1084        }
1085        let mut damaged = binding.clone();
1086        damaged["operations"]["older"] = json!({"target":"c".repeat(64),"expected":{"revision":0,"fingerprint":null},"revision":1,"fingerprint":"d".repeat(64)});
1087        assert!(validate_persisted_binding(&damaged).is_err());
1088        damaged["operations"]["one"]["expected"]["fingerprint"] = json!("d".repeat(64));
1089        assert!(validate_persisted_binding(&damaged).is_ok());
1090    }
1091
1092    #[tokio::test]
1093    async fn validated_skills_keep_parsed_content_and_exact_source_hash() {
1094        let temp = tempfile::tempdir().unwrap();
1095        let root = temp.path();
1096        std::fs::create_dir_all(root.join("review")).unwrap();
1097        let path = root.join("review/SKILL.md");
1098        let raw = "---\r\nname: review\r\ndescription: Review fixture\r\n---\r\nOriginal body\r\n";
1099        std::fs::write(&path, raw).unwrap();
1100        std::fs::write(
1101            root.join("resources.json"),
1102            r#"{"version":1,"skills":["review"],"tools":{"mode":"none"}}"#,
1103        )
1104        .unwrap();
1105        let args = Args {
1106            mode: Mode::Rpc,
1107            resource_config: Some("resources.json".into()),
1108            ..Default::default()
1109        };
1110        let mut selected = ResolvedSelection::load(&args, root, true).unwrap().unwrap();
1111        let env: std::sync::Arc<dyn rpi_tools::ExecutionEnv> =
1112            std::sync::Arc::new(rpi_tools::OsExecutionEnv::with_cwd(root.to_path_buf()));
1113        selected.validate_skills(&env).await.unwrap();
1114        let validated = selected.validated_skills().unwrap();
1115        assert_eq!(
1116            validated.inventory[0]["sha256"],
1117            format!("{:x}", Sha256::digest(raw.as_bytes()))
1118        );
1119        assert_eq!(validated.skills[0].name, "review");
1120        let content = validated.skills[0].content.clone();
1121        // Construction must retain these parsed Skills, not do a weaker second
1122        // read/discovery after native code has had a chance to alter the file.
1123        std::fs::write(&path, "---\nname: [\n---\nMalformed replacement").unwrap();
1124        assert_eq!(
1125            selected.validated_skills().unwrap().skills[0].content,
1126            content
1127        );
1128        assert_ne!(
1129            selected.validated_skills().unwrap().inventory[0]["sha256"],
1130            fingerprint(&path).unwrap()
1131        );
1132    }
1133
1134    #[tokio::test]
1135    async fn overlapping_skill_roots_fail_closed_without_validated_inventory() {
1136        let temp = tempfile::tempdir().unwrap();
1137        let root = temp.path();
1138        std::fs::create_dir(root.join("review")).unwrap();
1139        std::fs::write(
1140            root.join("review/SKILL.md"),
1141            "---\nname: review\ndescription: Review fixture\n---\nValid body\n",
1142        )
1143        .unwrap();
1144        std::fs::write(
1145            root.join("resources.json"),
1146            r#"{"version":1,"skills":["review","review/SKILL.md"],"tools":{"mode":"none"}}"#,
1147        )
1148        .unwrap();
1149        let args = Args {
1150            mode: Mode::Rpc,
1151            resource_config: Some("resources.json".into()),
1152            ..Default::default()
1153        };
1154        // Distinct overlapping roots pass declaration-level path uniqueness,
1155        // but repeat a Skill name even though both reads have the same path.
1156        let mut selected = ResolvedSelection::load(&args, root, true).unwrap().unwrap();
1157        let env: std::sync::Arc<dyn rpi_tools::ExecutionEnv> =
1158            std::sync::Arc::new(rpi_tools::OsExecutionEnv::with_cwd(root.to_path_buf()));
1159        let error = selected.validate_skills(&env).await.unwrap_err();
1160        assert!(error.contains("selected skill diagnostics"), "{error}");
1161        assert!(error.contains("shadowed"), "{error}");
1162        assert!(error.contains("first-registration wins"), "{error}");
1163        assert!(selected.validated_skills().is_err());
1164    }
1165
1166    #[tokio::test]
1167    async fn snapshot_errors_without_exact_mode_and_when_oversized() {
1168        let harness = AgentHarness::create(Default::default()).await.unwrap();
1169        assert!(get_resource_state(&harness)
1170            .await
1171            .unwrap_err()
1172            .contains("requires --resource-config"));
1173        harness.seal_resources(vec![]).unwrap();
1174        attach(&harness, json!({"oversized": "x".repeat(MAX_STATE_BYTES)}));
1175        assert!(get_resource_state(&harness)
1176            .await
1177            .unwrap_err()
1178            .contains("exceeds 256 KiB"));
1179    }
1180}