@lamentis/naome 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/Cargo.lock +2 -2
  2. package/README.md +5 -0
  3. package/crates/naome-cli/Cargo.toml +1 -1
  4. package/crates/naome-cli/src/install_bridge.rs +56 -8
  5. package/crates/naome-core/Cargo.toml +1 -1
  6. package/crates/naome-core/src/context/select.rs +58 -4
  7. package/crates/naome-core/src/harness_health/integrity.rs +41 -23
  8. package/crates/naome-core/src/harness_health/manifest.rs +97 -0
  9. package/crates/naome-core/src/harness_health.rs +58 -106
  10. package/crates/naome-core/src/intent/classifier.rs +56 -81
  11. package/crates/naome-core/src/intent/envelope.rs +173 -19
  12. package/crates/naome-core/src/intent/legacy_response.rs +2 -0
  13. package/crates/naome-core/src/intent/model.rs +6 -0
  14. package/crates/naome-core/src/intent/resolver.rs +25 -0
  15. package/crates/naome-core/src/intent/risk.rs +11 -1
  16. package/crates/naome-core/src/intent.rs +1 -1
  17. package/crates/naome-core/src/quality/cache.rs +122 -19
  18. package/crates/naome-core/src/quality/scanner/analysis.rs +4 -2
  19. package/crates/naome-core/src/quality/scanner/repo_paths.rs +27 -3
  20. package/crates/naome-core/src/quality/scanner.rs +5 -2
  21. package/crates/naome-core/src/route/context.rs +8 -0
  22. package/crates/naome-core/src/workflow/integrity_support.rs +10 -3
  23. package/crates/naome-core/tests/context.rs +92 -0
  24. package/crates/naome-core/tests/harness_health.rs +149 -0
  25. package/crates/naome-core/tests/intent.rs +98 -18
  26. package/crates/naome-core/tests/intent_support/mod.rs +39 -1
  27. package/crates/naome-core/tests/intent_v2.rs +299 -10
  28. package/crates/naome-core/tests/quality_performance.rs +63 -2
  29. package/crates/naome-core/tests/repo_support/routes.rs +8 -2
  30. package/crates/naome-core/tests/route_baseline.rs +29 -0
  31. package/crates/naome-core/tests/route_completion.rs +26 -5
  32. package/crates/naome-core/tests/route_harness_refresh.rs +7 -1
  33. package/crates/naome-core/tests/route_user_diff.rs +1 -1
  34. package/crates/naome-core/tests/task_state_compact.rs +7 -1
  35. package/installer/filesystem.js +38 -0
  36. package/installer/flows.js +6 -1
  37. package/installer/harness-file-ops.js +36 -8
  38. package/installer/manifest-state.js +2 -2
  39. package/installer/native.js +63 -18
  40. package/native/darwin-arm64/naome +0 -0
  41. package/native/linux-x64/naome +0 -0
  42. package/package.json +1 -1
  43. package/templates/naome-root/.naome/bin/check-harness-health.js +25 -21
  44. package/templates/naome-root/.naome/bin/check-task-state.js +35 -42
  45. package/templates/naome-root/.naome/manifest.json +10 -10
  46. package/templates/naome-root/docs/naome/agent-workflow.md +14 -5
  47. package/templates/naome-root/docs/naome/architecture.md +9 -0
  48. package/crates/naome-core/src/intent/patterns.rs +0 -170
package/Cargo.lock CHANGED
@@ -76,7 +76,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
76
76
 
77
77
  [[package]]
78
78
  name = "naome-cli"
79
- version = "1.3.7"
79
+ version = "1.3.9"
80
80
  dependencies = [
81
81
  "naome-core",
82
82
  "serde_json",
@@ -84,7 +84,7 @@ dependencies = [
84
84
 
85
85
  [[package]]
86
86
  name = "naome-core"
87
- version = "1.3.7"
87
+ version = "1.3.9"
88
88
  dependencies = [
89
89
  "serde",
90
90
  "serde_json",
package/README.md CHANGED
@@ -41,6 +41,11 @@ For agent-driven work, route the user's request through the harness:
41
41
  naome route --prompt-file /path/to/prompt.txt --execute --json
42
42
  ```
43
43
 
44
+ Prompt files should start with a fenced `naome-prompt-envelope-v1` JSON
45
+ envelope that normalizes the raw user text into canonical routing fields. A raw
46
+ natural-language prompt routes to a non-mutating normalization decision instead
47
+ of becoming a task by keyword inference.
48
+
44
49
  ## Why NAOME?
45
50
 
46
51
  - Keeps agents inside explicit task scope.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-cli"
3
- version = "1.3.7"
3
+ version = "1.3.9"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -10,8 +10,7 @@ pub fn run_install_bridge(
10
10
  let package_root = option_value(args, "--package-root")
11
11
  .map(PathBuf::from)
12
12
  .or_else(|| std::env::var("NAOME_PACKAGE_ROOT").ok().map(PathBuf::from))
13
- .or_else(resolve_package_root_from_exe)
14
- .or_else(resolve_package_root_from_cwd);
13
+ .or_else(resolve_package_root_from_exe);
15
14
  let installer_js = option_value(args, "--installer-js")
16
15
  .map(PathBuf::from)
17
16
  .or_else(|| std::env::var("NAOME_INSTALLER_JS").ok().map(PathBuf::from))
@@ -72,12 +71,61 @@ fn resolve_package_root_from_exe() -> Option<PathBuf> {
72
71
  None
73
72
  }
74
73
 
75
- fn resolve_package_root_from_cwd() -> Option<PathBuf> {
76
- let current = std::env::current_dir().ok()?;
77
- for candidate in [current.join("packages").join("naome"), current] {
78
- if candidate.join("bin").join("naome-node.js").is_file() {
79
- return Some(candidate);
74
+ #[cfg(test)]
75
+ mod tests {
76
+ use std::fs;
77
+ use std::sync::{Mutex, OnceLock};
78
+ use std::time::{SystemTime, UNIX_EPOCH};
79
+
80
+ use super::run_install_bridge;
81
+
82
+ fn env_lock() -> &'static Mutex<()> {
83
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
84
+ LOCK.get_or_init(|| Mutex::new(()))
85
+ }
86
+
87
+ #[test]
88
+ fn install_bridge_does_not_discover_installer_from_cwd() {
89
+ let _guard = env_lock().lock().unwrap();
90
+ let original_dir = std::env::current_dir().unwrap();
91
+ let original_package_root = std::env::var_os("NAOME_PACKAGE_ROOT");
92
+ let original_installer_js = std::env::var_os("NAOME_INSTALLER_JS");
93
+ let temp_root = std::env::temp_dir().join(format!(
94
+ "naome-cwd-installer-test-{}",
95
+ SystemTime::now()
96
+ .duration_since(UNIX_EPOCH)
97
+ .unwrap()
98
+ .as_nanos()
99
+ ));
100
+ let installer_path = temp_root
101
+ .join("packages")
102
+ .join("naome")
103
+ .join("bin")
104
+ .join("naome-node.js");
105
+ fs::create_dir_all(installer_path.parent().unwrap()).unwrap();
106
+ fs::write(&installer_path, "console.log('untrusted cwd installer');\n").unwrap();
107
+
108
+ std::env::remove_var("NAOME_PACKAGE_ROOT");
109
+ std::env::remove_var("NAOME_INSTALLER_JS");
110
+ std::env::set_current_dir(&temp_root).unwrap();
111
+
112
+ let args = vec!["install".to_string()];
113
+ let error = run_install_bridge("install", &args)
114
+ .expect_err("cwd-local installer must not be treated as trusted");
115
+ assert!(
116
+ error.to_string().contains("needs naome-node.js"),
117
+ "unexpected error: {error}"
118
+ );
119
+
120
+ std::env::set_current_dir(original_dir).unwrap();
121
+ match original_package_root {
122
+ Some(value) => std::env::set_var("NAOME_PACKAGE_ROOT", value),
123
+ None => std::env::remove_var("NAOME_PACKAGE_ROOT"),
80
124
  }
125
+ match original_installer_js {
126
+ Some(value) => std::env::set_var("NAOME_INSTALLER_JS", value),
127
+ None => std::env::remove_var("NAOME_INSTALLER_JS"),
128
+ }
129
+ let _ = fs::remove_dir_all(temp_root);
81
130
  }
82
- None
83
131
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "naome-core"
3
- version = "1.3.7"
3
+ version = "1.3.9"
4
4
  edition.workspace = true
5
5
  license.workspace = true
6
6
  repository.workspace = true
@@ -1,5 +1,8 @@
1
- use std::path::Path;
1
+ use std::path::{Component, Path};
2
2
 
3
+ use serde::Deserialize;
4
+
5
+ use crate::intent::prompt_envelope_json;
3
6
  use crate::{git, models::NaomeError};
4
7
 
5
8
  use super::helpers::{capsule_for_paths, context_item, is_source_path, normalize_paths};
@@ -7,6 +10,13 @@ use super::types::{ContextBudgetLedger, ContextItem, ContextSelection};
7
10
 
8
11
  const MAX_CONTEXT_FILES: usize = 12;
9
12
 
13
+ #[derive(Debug, Deserialize)]
14
+ #[serde(rename_all = "camelCase")]
15
+ struct PromptEnvelopeContext {
16
+ schema: Option<String>,
17
+ referenced_paths: Option<Vec<String>>,
18
+ }
19
+
10
20
  pub fn select_context_for_changed_paths(root: &Path) -> Result<ContextSelection, NaomeError> {
11
21
  let paths = git::changed_paths(root)?;
12
22
  Ok(selection_for_paths(root, "changed", paths))
@@ -19,7 +29,9 @@ pub fn select_context_for_prompt(
19
29
  Ok(selection_for_paths(
20
30
  root,
21
31
  "prompt",
22
- mentioned_paths(prompt.as_ref()),
32
+ envelope_referenced_paths(prompt.as_ref())
33
+ .filter(|paths| !paths.is_empty())
34
+ .unwrap_or_else(|| mentioned_paths(root, prompt.as_ref())),
23
35
  ))
24
36
  }
25
37
 
@@ -119,7 +131,7 @@ fn forbidden_context() -> Vec<String> {
119
131
  .collect()
120
132
  }
121
133
 
122
- fn mentioned_paths(prompt: &str) -> Vec<String> {
134
+ fn mentioned_paths(root: &Path, prompt: &str) -> Vec<String> {
123
135
  prompt
124
136
  .split_whitespace()
125
137
  .map(|token| {
@@ -127,8 +139,50 @@ fn mentioned_paths(prompt: &str) -> Vec<String> {
127
139
  matches!(ch, '"' | '\'' | '`' | ',' | ';' | ':' | ')' | '(')
128
140
  })
129
141
  })
130
- .filter(|token| token.contains('/') || is_source_path(token) || token.ends_with(".md"))
131
142
  .filter(|token| !token.starts_with('-') && !token.starts_with("http"))
132
143
  .map(|token| token.trim_start_matches("./").replace('\\', "/"))
144
+ .filter(|path| path_looks_contextual(path))
145
+ .filter(|path| path_exists_or_has_repo_parent(root, path))
133
146
  .collect()
134
147
  }
148
+
149
+ fn envelope_referenced_paths(prompt: &str) -> Option<Vec<String>> {
150
+ let input =
151
+ serde_json::from_str::<PromptEnvelopeContext>(prompt_envelope_json(prompt)?).ok()?;
152
+ if input.schema.as_deref() != Some("naome.prompt-envelope.v1") {
153
+ return None;
154
+ }
155
+ Some(
156
+ input
157
+ .referenced_paths
158
+ .unwrap_or_default()
159
+ .into_iter()
160
+ .map(|path| path.trim_start_matches("./").replace('\\', "/"))
161
+ .filter(|path| is_repo_relative_path(path))
162
+ .filter(|path| path_looks_contextual(path))
163
+ .collect(),
164
+ )
165
+ }
166
+
167
+ fn is_repo_relative_path(path: &str) -> bool {
168
+ let candidate = Path::new(path);
169
+ !path.contains(':')
170
+ && !candidate.is_absolute()
171
+ && !candidate
172
+ .components()
173
+ .any(|component| matches!(component, Component::ParentDir))
174
+ }
175
+
176
+ fn path_looks_contextual(path: &str) -> bool {
177
+ path.contains('/') || is_source_path(path) || path.ends_with(".md")
178
+ }
179
+
180
+ fn path_exists_or_has_repo_parent(root: &Path, path: &str) -> bool {
181
+ let candidate = root.join(path);
182
+ if candidate.exists() {
183
+ return true;
184
+ }
185
+ candidate
186
+ .parent()
187
+ .is_some_and(|parent| parent != root && parent.exists())
188
+ }
@@ -29,12 +29,10 @@ pub(crate) fn is_integrity_hash(value: &str) -> bool {
29
29
  }
30
30
 
31
31
  pub(crate) fn native_integrity_from_naome_command(content: &str) -> Option<String> {
32
- let prefix = "const expectedNativeBinaryIntegrity = \"";
33
- let start = content.find(prefix)? + prefix.len();
34
- let rest = &content[start..];
35
- let end = rest.find("\";")?;
36
- let value = &rest[..end];
37
- is_integrity_hash(value).then(|| value.to_string())
32
+ content
33
+ .lines()
34
+ .find_map(native_integrity_assignment_value)
35
+ .map(ToString::to_string)
38
36
  }
39
37
 
40
38
  fn machine_sha256(relative_path: &str, content: &[u8]) -> String {
@@ -43,17 +41,22 @@ fn machine_sha256(relative_path: &str, content: &[u8]) -> String {
43
41
  }
44
42
 
45
43
  fn normalize_machine_owned_content(relative_path: &str, content: &[u8]) -> Vec<u8> {
44
+ let mut normalized = content.to_vec();
45
+
46
46
  if relative_path == HEALTH_CHECKER_RELATIVE_PATH
47
47
  || relative_path == TASK_STATE_CHECKER_RELATIVE_PATH
48
48
  {
49
- return replace_expected_integrity_block(content);
49
+ normalized = replace_expected_integrity_block(&normalized);
50
50
  }
51
51
 
52
- if relative_path == NAOME_COMMAND_RELATIVE_PATH {
53
- return replace_native_integrity_line(content);
52
+ if relative_path == HEALTH_CHECKER_RELATIVE_PATH
53
+ || relative_path == TASK_STATE_CHECKER_RELATIVE_PATH
54
+ || relative_path == NAOME_COMMAND_RELATIVE_PATH
55
+ {
56
+ normalized = replace_native_integrity_line(&normalized);
54
57
  }
55
58
 
56
- content.to_vec()
59
+ normalized
57
60
  }
58
61
 
59
62
  fn replace_expected_integrity_block(content: &[u8]) -> Vec<u8> {
@@ -79,18 +82,33 @@ fn replace_expected_integrity_block(content: &[u8]) -> Vec<u8> {
79
82
 
80
83
  fn replace_native_integrity_line(content: &[u8]) -> Vec<u8> {
81
84
  let text = String::from_utf8_lossy(content);
82
- let prefix = "const expectedNativeBinaryIntegrity = \"";
83
- let Some(start) = text.find(prefix) else {
84
- return content.to_vec();
85
- };
86
- let Some(relative_end) = text[start..].find(";\n") else {
87
- return content.to_vec();
88
- };
89
- let end = start + relative_end + ";\n".len();
90
-
85
+ let mut changed = false;
91
86
  let mut next = String::with_capacity(text.len());
92
- next.push_str(&text[..start]);
93
- next.push_str("const expectedNativeBinaryIntegrity = \"sha256:generated\";\n");
94
- next.push_str(&text[end..]);
95
- next.into_bytes()
87
+
88
+ for segment in text.split_inclusive('\n') {
89
+ let (line, ending) = segment
90
+ .strip_suffix('\n')
91
+ .map(|line| (line, "\n"))
92
+ .unwrap_or((segment, ""));
93
+ if native_integrity_assignment_value(line).is_some() {
94
+ next.push_str("const expectedNativeBinaryIntegrity = \"sha256:generated\";");
95
+ next.push_str(ending);
96
+ changed = true;
97
+ } else {
98
+ next.push_str(segment);
99
+ }
100
+ }
101
+
102
+ if changed {
103
+ next.into_bytes()
104
+ } else {
105
+ content.to_vec()
106
+ }
107
+ }
108
+
109
+ fn native_integrity_assignment_value(line: &str) -> Option<&str> {
110
+ let value = line
111
+ .strip_prefix("const expectedNativeBinaryIntegrity = \"")?
112
+ .strip_suffix("\";")?;
113
+ is_integrity_hash(value).then_some(value)
96
114
  }
@@ -0,0 +1,97 @@
1
+ use std::collections::HashSet;
2
+
3
+ use serde_json::Value;
4
+
5
+ use crate::install_plan::{MACHINE_OWNED_PATHS, PROJECT_OWNED_PATHS};
6
+
7
+ pub(super) fn validate_manifest_shape(manifest: &Value, errors: &mut Vec<String>) {
8
+ let Some(object) = manifest.as_object() else {
9
+ errors.push(".naome/manifest.json must be a JSON object.".to_string());
10
+ return;
11
+ };
12
+
13
+ if object.get("name").and_then(Value::as_str) != Some("naome") {
14
+ errors.push(".naome/manifest.json name must be naome.".to_string());
15
+ }
16
+
17
+ if !object
18
+ .get("harnessVersion")
19
+ .and_then(Value::as_str)
20
+ .is_some_and(is_version)
21
+ {
22
+ errors.push(".naome/manifest.json harnessVersion must be semver.".to_string());
23
+ }
24
+
25
+ if !string_array(object.get("machineOwned")).is_some() {
26
+ errors.push(".naome/manifest.json machineOwned must be a string array.".to_string());
27
+ }
28
+
29
+ if !string_array(object.get("projectOwned")).is_some() {
30
+ errors.push(".naome/manifest.json projectOwned must be a string array.".to_string());
31
+ }
32
+
33
+ if !object.get("integrity").is_some_and(Value::is_object) {
34
+ errors.push(".naome/manifest.json integrity must be an object.".to_string());
35
+ }
36
+ }
37
+
38
+ pub(super) fn validate_manifest_ownership(manifest: &Value, errors: &mut Vec<String>) {
39
+ let Some(object) = manifest.as_object() else {
40
+ return;
41
+ };
42
+ let Some(machine_owned) = string_array(object.get("machineOwned")) else {
43
+ return;
44
+ };
45
+ let Some(project_owned) = string_array(object.get("projectOwned")) else {
46
+ return;
47
+ };
48
+
49
+ validate_contains_all(
50
+ &machine_owned,
51
+ MACHINE_OWNED_PATHS,
52
+ ".naome/manifest.json machineOwned",
53
+ errors,
54
+ );
55
+ validate_contains_all(
56
+ &project_owned,
57
+ PROJECT_OWNED_PATHS,
58
+ ".naome/manifest.json projectOwned",
59
+ errors,
60
+ );
61
+ }
62
+
63
+ pub(super) fn string_array(value: Option<&Value>) -> Option<Vec<String>> {
64
+ value.and_then(Value::as_array).and_then(|entries| {
65
+ entries
66
+ .iter()
67
+ .map(|entry| {
68
+ entry
69
+ .as_str()
70
+ .filter(|text| !text.trim().is_empty())
71
+ .map(ToString::to_string)
72
+ })
73
+ .collect()
74
+ })
75
+ }
76
+
77
+ fn validate_contains_all(
78
+ actual_values: &[String],
79
+ expected_values: &[&str],
80
+ field_name: &str,
81
+ errors: &mut Vec<String>,
82
+ ) {
83
+ let actual: HashSet<&str> = actual_values.iter().map(String::as_str).collect();
84
+ for expected in expected_values {
85
+ if !actual.contains(expected) {
86
+ errors.push(format!("{field_name} must include {expected}."));
87
+ }
88
+ }
89
+ }
90
+
91
+ fn is_version(value: &str) -> bool {
92
+ let parts: Vec<&str> = value.split('.').collect();
93
+ parts.len() == 3
94
+ && parts
95
+ .iter()
96
+ .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
97
+ }
@@ -1,6 +1,7 @@
1
1
  mod integrity;
2
+ mod manifest;
2
3
 
3
- use std::collections::{HashMap, HashSet};
4
+ use std::collections::HashMap;
4
5
  use std::fs;
5
6
  use std::path::{Component, Path};
6
7
 
@@ -11,6 +12,7 @@ use self::integrity::{
11
12
  is_integrity_hash, native_integrity_from_naome_command, sha256_bytes,
12
13
  NAOME_COMMAND_RELATIVE_PATH, NATIVE_BINARY_RELATIVE_PATH,
13
14
  };
15
+ use self::manifest::{string_array, validate_manifest_ownership, validate_manifest_shape};
14
16
  use crate::install_plan::{MACHINE_OWNED_PATHS, PROJECT_OWNED_PATHS};
15
17
  use crate::models::NaomeError;
16
18
 
@@ -56,62 +58,6 @@ pub fn validate_harness_health(
56
58
  Ok(errors)
57
59
  }
58
60
 
59
- fn validate_manifest_shape(manifest: &Value, errors: &mut Vec<String>) {
60
- let Some(object) = manifest.as_object() else {
61
- errors.push(".naome/manifest.json must be a JSON object.".to_string());
62
- return;
63
- };
64
-
65
- if object.get("name").and_then(Value::as_str) != Some("naome") {
66
- errors.push(".naome/manifest.json name must be naome.".to_string());
67
- }
68
-
69
- if !object
70
- .get("harnessVersion")
71
- .and_then(Value::as_str)
72
- .is_some_and(is_version)
73
- {
74
- errors.push(".naome/manifest.json harnessVersion must be semver.".to_string());
75
- }
76
-
77
- if !string_array(object.get("machineOwned")).is_some() {
78
- errors.push(".naome/manifest.json machineOwned must be a string array.".to_string());
79
- }
80
-
81
- if !string_array(object.get("projectOwned")).is_some() {
82
- errors.push(".naome/manifest.json projectOwned must be a string array.".to_string());
83
- }
84
-
85
- if !object.get("integrity").is_some_and(Value::is_object) {
86
- errors.push(".naome/manifest.json integrity must be an object.".to_string());
87
- }
88
- }
89
-
90
- fn validate_manifest_ownership(manifest: &Value, errors: &mut Vec<String>) {
91
- let Some(object) = manifest.as_object() else {
92
- return;
93
- };
94
- let Some(machine_owned) = string_array(object.get("machineOwned")) else {
95
- return;
96
- };
97
- let Some(project_owned) = string_array(object.get("projectOwned")) else {
98
- return;
99
- };
100
-
101
- validate_contains_all(
102
- &machine_owned,
103
- MACHINE_OWNED_PATHS,
104
- ".naome/manifest.json machineOwned",
105
- errors,
106
- );
107
- validate_contains_all(
108
- &project_owned,
109
- PROJECT_OWNED_PATHS,
110
- ".naome/manifest.json projectOwned",
111
- errors,
112
- );
113
- }
114
-
115
61
  fn validate_manifest_integrity(
116
62
  root: &Path,
117
63
  manifest: &Value,
@@ -186,13 +132,41 @@ fn validate_native_decision_binary(
186
132
  return Ok(());
187
133
  };
188
134
 
189
- if !machine_owned
135
+ let wrapper_paths = [
136
+ NAOME_COMMAND_RELATIVE_PATH,
137
+ ".naome/bin/check-harness-health.js",
138
+ ".naome/bin/check-task-state.js",
139
+ ];
140
+ let wrapper_integrity = wrapper_paths
141
+ .iter()
142
+ .map(|relative_path| {
143
+ native_integrity_from_regular_file(root, relative_path)
144
+ .map(|expected| (*relative_path, expected))
145
+ })
146
+ .collect::<Result<Vec<_>, _>>()?;
147
+ let native_is_declared = machine_owned
148
+ .iter()
149
+ .any(|entry| entry == NATIVE_BINARY_RELATIVE_PATH);
150
+ let native_has_integrity = integrity.contains_key(NATIVE_BINARY_RELATIVE_PATH);
151
+ let native_path_present = fs::symlink_metadata(root.join(NATIVE_BINARY_RELATIVE_PATH)).is_ok();
152
+ let wrapper_requires_native = wrapper_integrity
190
153
  .iter()
191
- .any(|entry| entry == NATIVE_BINARY_RELATIVE_PATH)
154
+ .any(|(_, expected)| expected.is_some());
155
+
156
+ if !native_is_declared
157
+ && !native_has_integrity
158
+ && !wrapper_requires_native
159
+ && !native_path_present
192
160
  {
193
161
  return Ok(());
194
162
  }
195
163
 
164
+ if !native_is_declared {
165
+ errors.push(format!(
166
+ ".naome/manifest.json machineOwned must include {NATIVE_BINARY_RELATIVE_PATH}."
167
+ ));
168
+ }
169
+
196
170
  validate_regular_file(root, NATIVE_BINARY_RELATIVE_PATH, errors)?;
197
171
 
198
172
  if !root.join(NATIVE_BINARY_RELATIVE_PATH).exists()
@@ -222,22 +196,36 @@ fn validate_native_decision_binary(
222
196
  ));
223
197
  }
224
198
 
225
- let command_path = root.join(NAOME_COMMAND_RELATIVE_PATH);
226
- if !command_path.exists() || has_symlink_in_path(root, NAOME_COMMAND_RELATIVE_PATH)? {
227
- return Ok(());
228
- }
229
-
230
- let command_content = fs::read_to_string(command_path)?;
231
- let command_expected = native_integrity_from_naome_command(&command_content);
232
- if command_expected.as_deref() != Some(manifest_expected) {
233
- errors.push(format!(
234
- "{NAOME_COMMAND_RELATIVE_PATH} native binary integrity does not match .naome/manifest.json."
235
- ));
199
+ for (relative_path, expected) in wrapper_integrity {
200
+ if expected.as_deref() != Some(manifest_expected) {
201
+ errors.push(format!(
202
+ "{relative_path} native binary integrity does not match .naome/manifest.json."
203
+ ));
204
+ }
236
205
  }
237
206
 
238
207
  Ok(())
239
208
  }
240
209
 
210
+ fn native_integrity_from_regular_file(
211
+ root: &Path,
212
+ relative_path: &str,
213
+ ) -> Result<Option<String>, NaomeError> {
214
+ let file_path = root.join(relative_path);
215
+ if has_symlink_in_path(root, relative_path)? {
216
+ return Ok(None);
217
+ }
218
+ let Ok(metadata) = fs::symlink_metadata(&file_path) else {
219
+ return Ok(None);
220
+ };
221
+ if !metadata.is_file() {
222
+ return Ok(None);
223
+ }
224
+ Ok(native_integrity_from_naome_command(&fs::read_to_string(
225
+ file_path,
226
+ )?))
227
+ }
228
+
241
229
  fn validate_naome_ignore(root: &Path, errors: &mut Vec<String>) -> Result<(), NaomeError> {
242
230
  let relative_path = ".naomeignore";
243
231
  if !root.join(relative_path).exists() || has_symlink_in_path(root, relative_path)? {
@@ -342,20 +330,6 @@ fn read_json(
342
330
  }
343
331
  }
344
332
 
345
- fn validate_contains_all(
346
- actual_values: &[String],
347
- expected_values: &[&str],
348
- field_name: &str,
349
- errors: &mut Vec<String>,
350
- ) {
351
- let actual: HashSet<&str> = actual_values.iter().map(String::as_str).collect();
352
- for expected in expected_values {
353
- if !actual.contains(expected) {
354
- errors.push(format!("{field_name} must include {expected}."));
355
- }
356
- }
357
- }
358
-
359
333
  fn validate_regular_file(
360
334
  root: &Path,
361
335
  relative_path: &str,
@@ -423,25 +397,3 @@ fn has_symlink_in_path(root: &Path, relative_path: &str) -> Result<bool, NaomeEr
423
397
 
424
398
  Ok(false)
425
399
  }
426
-
427
- fn string_array(value: Option<&Value>) -> Option<Vec<String>> {
428
- value.and_then(Value::as_array).and_then(|entries| {
429
- entries
430
- .iter()
431
- .map(|entry| {
432
- entry
433
- .as_str()
434
- .filter(|text| !text.trim().is_empty())
435
- .map(ToString::to_string)
436
- })
437
- .collect()
438
- })
439
- }
440
-
441
- fn is_version(value: &str) -> bool {
442
- let parts: Vec<&str> = value.split('.').collect();
443
- parts.len() == 3
444
- && parts
445
- .iter()
446
- .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
447
- }