1use 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;
31pub(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
44pub(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 #[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
177fn 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 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 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
480type 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
563pub(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 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 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
602fn 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
623fn 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 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
788pub(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 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 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 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 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 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 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}