1use runx_contracts::{JsonObject, JsonValue};
5
6use crate::ValidationError;
7use crate::graph::{RawGraphIr, validate_graph_document};
8
9use crate::graph::MintScopeSource;
10
11use super::{
12 ActDeclaration, FIELDS, InputMode, SkillHttpSource, SkillMcpServer, SkillSource, SourceKind,
13 field_value, first_value, validate_sandbox,
14};
15
16const SOURCE_FIELDS: &[&str] = &[
17 "act",
18 "agent",
19 "agent_card_url",
20 "agent_identity",
21 "allow_private_network",
22 "args",
23 "arguments",
24 "catalog_ref",
25 "command",
26 "cwd",
27 "external_adapter",
28 "external_adapter_manifest",
29 "external_adapter_manifest_path",
30 "graph",
31 "headers",
32 "hook",
33 "http",
34 "input_mode",
35 "invocation_id",
36 "method",
37 "outputs",
38 "run_id",
39 "sandbox",
40 "server",
41 "skill_ref",
42 "task",
43 "timeout_seconds",
44 "tool",
45 "type",
46 "url",
47];
48
49pub fn validate_skill_source(
50 source: &JsonObject,
51 runx: Option<&JsonObject>,
52) -> Result<SkillSource, ValidationError> {
53 validate_source(source, runx)
54}
55
56pub(super) fn validate_source_fields(
57 source: &JsonObject,
58 field: &str,
59) -> Result<(), ValidationError> {
60 FIELDS.reject_unknown_fields(source, field, SOURCE_FIELDS)
61}
62
63pub(super) fn validate_source(
64 source: &JsonObject,
65 runx: Option<&JsonObject>,
66) -> Result<SkillSource, ValidationError> {
67 let source_type = FIELDS.required_string(source.get("type"), "source.type")?;
68 let args = FIELDS
69 .optional_string_array(source.get("args"), "source.args")?
70 .unwrap_or_default();
71 let input_mode = optional_input_mode(source.get("input_mode"))?;
72 let timeout_seconds =
73 FIELDS.optional_u64(source.get("timeout_seconds"), "source.timeout_seconds")?;
74
75 if source_type == "cli-tool" {
76 FIELDS.required_string(source.get("command"), "source.command")?;
77 }
78 validate_agent_command_boundary(source, &source_type)?;
79 let source_kind = parse_source_kind(&source_type, "source.type")?;
80
81 Ok(SkillSource {
82 command: FIELDS.optional_string(source.get("command"), "source.command")?,
83 args,
84 cwd: FIELDS.optional_string(source.get("cwd"), "source.cwd")?,
85 timeout_seconds,
86 input_mode,
87 sandbox: validate_sandbox(first_value(
88 source.get("sandbox"),
89 field_value(runx, "sandbox"),
90 ))?,
91 server: validate_mcp_server(source, &source_type)?,
92 catalog_ref: validate_catalog_ref(source, &source_type)?,
93 tool: validate_mcp_tool(source, &source_type)?,
94 arguments: FIELDS.optional_object(source.get("arguments"), "source.arguments")?,
95 agent_card_url: validate_a2a_url(source, &source_type)?,
96 agent_identity: FIELDS
97 .optional_string(source.get("agent_identity"), "source.agent_identity")?,
98 agent: validate_agent(source, &source_type)?,
99 task: validate_task(source, &source_type)?,
100 hook: validate_hook(source, &source_type)?,
101 outputs: FIELDS.optional_object(source.get("outputs"), "source.outputs")?,
102 graph: validate_graph_source(source, &source_type)?,
103 http: validate_http_source(source, &source_type)?,
104 act: validate_act_declaration(source.get("act"))?,
105 raw: source.clone(),
106 source_type: source_kind,
107 })
108}
109
110fn validate_act_declaration(
114 value: Option<&JsonValue>,
115) -> Result<Option<ActDeclaration>, ValidationError> {
116 let Some(value) = value else {
117 return Ok(None);
118 };
119 let act = serde_json::to_value(value)
120 .and_then(serde_json::from_value::<ActDeclaration>)
121 .map_err(|error| FIELDS.validation_error(format!("source.act is malformed: {error}")))?;
122 validate_act_authority_coherence(&act)?;
123 Ok(Some(act))
124}
125
126fn validate_act_authority_coherence(act: &ActDeclaration) -> Result<(), ValidationError> {
131 let Some(directive) = act.mint_authority else {
132 if act.requested_scope_from.is_some() {
133 return Err(FIELDS.validation_error(
134 "source.act.requested_scope_from is only valid with a mint_authority directive.",
135 ));
136 }
137 return Ok(());
138 };
139 if act.authority_term_from.is_some()
140 || act.authority_parent_from.is_some()
141 || act.authority_subset_proof_from.is_some()
142 {
143 return Err(FIELDS.validation_error(
144 "source.act.mint_authority (compute path) is mutually exclusive with the pre-built authority_term_from / authority_parent_from / authority_subset_proof_from keys.",
145 ));
146 }
147 match directive.source {
148 MintScopeSource::StaticScopes => {
149 if act.requested_scope_from.is_some() {
150 return Err(FIELDS.validation_error(
151 "source.act.mint_authority source static_scopes must not declare requested_scope_from.",
152 ));
153 }
154 }
155 MintScopeSource::RequestedScope => {
156 if act.requested_scope_from.is_none() {
157 return Err(FIELDS.validation_error(
158 "source.act.mint_authority source requested_scope requires requested_scope_from.",
159 ));
160 }
161 }
162 }
163 Ok(())
164}
165
166fn parse_source_kind(value: &str, field: &str) -> Result<SourceKind, ValidationError> {
167 match value {
168 "cli-tool" => Ok(SourceKind::CliTool),
169 "mcp" => Ok(SourceKind::Mcp),
170 "catalog" => Ok(SourceKind::Catalog),
171 "a2a" => Ok(SourceKind::A2a),
172 "agent" => Ok(SourceKind::Agent),
173 "agent-task" => Ok(SourceKind::AgentStep),
174 "harness-hook" => Ok(SourceKind::HarnessHook),
175 "graph" => Ok(SourceKind::Graph),
176 "http" => Ok(SourceKind::Http),
177 "external-adapter" => Ok(SourceKind::ExternalAdapter),
178 "thread-outbox-provider" => Ok(SourceKind::ThreadOutboxProvider),
179 other => {
180 Err(FIELDS.validation_error(format!("{field} {other} is not a supported source type.")))
181 }
182 }
183}
184
185fn optional_input_mode(value: Option<&JsonValue>) -> Result<Option<InputMode>, ValidationError> {
186 let Some(value) = FIELDS.optional_string(value, "source.input_mode")? else {
187 return Ok(None);
188 };
189 match value.as_str() {
190 "args" => Ok(Some(InputMode::Args)),
191 "stdin" => Ok(Some(InputMode::Stdin)),
192 "none" => Ok(Some(InputMode::None)),
193 _ => Err(FIELDS.validation_error("source.input_mode must be args, stdin, or none.")),
194 }
195}
196
197pub(super) fn default_agent_source() -> JsonObject {
198 [("type".to_owned(), JsonValue::String("agent".to_owned()))]
199 .into_iter()
200 .collect()
201}
202
203fn validate_mcp_server(
204 source: &JsonObject,
205 source_type: &str,
206) -> Result<Option<SkillMcpServer>, ValidationError> {
207 if source_type != "mcp" {
208 return Ok(None);
209 }
210 let server = FIELDS.required_object(source.get("server"), "source.server")?;
211 Ok(Some(SkillMcpServer {
212 command: FIELDS.required_string(server.get("command"), "source.server.command")?,
213 args: FIELDS
214 .optional_string_array(server.get("args"), "source.server.args")?
215 .unwrap_or_default(),
216 cwd: FIELDS.optional_string(server.get("cwd"), "source.server.cwd")?,
217 }))
218}
219
220fn validate_mcp_tool(
221 source: &JsonObject,
222 source_type: &str,
223) -> Result<Option<String>, ValidationError> {
224 if source_type == "mcp" {
225 return Ok(Some(
226 FIELDS.required_string(source.get("tool"), "source.tool")?,
227 ));
228 }
229 FIELDS.optional_string(source.get("tool"), "source.tool")
230}
231
232fn validate_catalog_ref(
233 source: &JsonObject,
234 source_type: &str,
235) -> Result<Option<String>, ValidationError> {
236 if source_type == "catalog" {
237 return Ok(Some(FIELDS.required_string(
238 source.get("catalog_ref"),
239 "source.catalog_ref",
240 )?));
241 }
242 FIELDS.optional_string(source.get("catalog_ref"), "source.catalog_ref")
243}
244
245fn validate_a2a_url(
246 source: &JsonObject,
247 source_type: &str,
248) -> Result<Option<String>, ValidationError> {
249 if source_type == "a2a" {
250 return Ok(Some(FIELDS.required_string(
251 source.get("agent_card_url"),
252 "source.agent_card_url",
253 )?));
254 }
255 FIELDS.optional_string(source.get("agent_card_url"), "source.agent_card_url")
256}
257
258fn validate_agent(
259 source: &JsonObject,
260 source_type: &str,
261) -> Result<Option<String>, ValidationError> {
262 if source_type == "agent-task" {
263 return Ok(Some(
264 FIELDS.required_string(source.get("agent"), "source.agent")?,
265 ));
266 }
267 FIELDS.optional_string(source.get("agent"), "source.agent")
268}
269
270fn validate_task(
271 source: &JsonObject,
272 source_type: &str,
273) -> Result<Option<String>, ValidationError> {
274 if matches!(source_type, "agent-task" | "a2a") {
275 return Ok(Some(
276 FIELDS.required_string(source.get("task"), "source.task")?,
277 ));
278 }
279 FIELDS.optional_string(source.get("task"), "source.task")
280}
281
282fn validate_hook(
283 source: &JsonObject,
284 source_type: &str,
285) -> Result<Option<String>, ValidationError> {
286 if source_type == "harness-hook" {
287 return Ok(Some(
288 FIELDS.required_string(source.get("hook"), "source.hook")?,
289 ));
290 }
291 FIELDS.optional_string(source.get("hook"), "source.hook")
292}
293
294fn validate_graph_source(
295 source: &JsonObject,
296 source_type: &str,
297) -> Result<Option<crate::ExecutionGraph>, ValidationError> {
298 if source_type != "graph" {
299 return Ok(None);
300 }
301 let graph = FIELDS
302 .required_object(source.get("graph"), "source.graph")?
303 .clone();
304 validate_graph_document(graph.clone(), Some(RawGraphIr { document: graph })).map(Some)
305}
306
307fn validate_http_source(
308 source: &JsonObject,
309 source_type: &str,
310) -> Result<Option<SkillHttpSource>, ValidationError> {
311 if source_type != "http" {
312 return Ok(None);
313 }
314 let http = FIELDS
315 .optional_object(source.get("http"), "source.http")?
316 .unwrap_or_else(|| source.clone());
317 let url = FIELDS.required_string(http.get("url"), "source.url")?;
318 let method = match FIELDS.optional_string(http.get("method"), "source.method")? {
319 Some(method) => {
320 if !matches!(
321 method.to_ascii_uppercase().as_str(),
322 "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
323 ) {
324 return Err(FIELDS.validation_error(format!(
325 "source.method {method} is not supported; use GET, POST, PUT, PATCH, or DELETE."
326 )));
327 }
328 Some(method)
329 }
330 None => None,
331 };
332 Ok(Some(SkillHttpSource {
333 url,
334 method,
335 headers: validate_http_headers(http.get("headers"))?,
336 allow_private_network: FIELDS.optional_bool(
337 http.get("allow_private_network"),
338 "source.allow_private_network",
339 )?,
340 }))
341}
342
343fn validate_http_headers(
344 value: Option<&JsonValue>,
345) -> Result<Option<std::collections::BTreeMap<String, String>>, ValidationError> {
346 let Some(value) = value else {
347 return Ok(None);
348 };
349 let object = value.as_object().ok_or_else(|| {
350 FIELDS.validation_error(
351 "source.headers must be an object of header name to value.".to_owned(),
352 )
353 })?;
354 let mut headers = std::collections::BTreeMap::new();
355 for (name, value) in object {
356 let value = value.as_str().ok_or_else(|| {
357 FIELDS.validation_error(format!("source.headers.{name} must be a string."))
358 })?;
359 headers.insert(name.clone(), value.to_owned());
360 }
361 Ok(Some(headers))
362}
363
364fn validate_agent_command_boundary(
365 source: &JsonObject,
366 source_type: &str,
367) -> Result<(), ValidationError> {
368 if matches!(source_type, "agent-task" | "harness-hook")
369 && (source.contains_key("command") || source.contains_key("args"))
370 {
371 return Err(FIELDS.validation_error(format!(
372 "{source_type} sources must not declare source.command or source.args."
373 )));
374 }
375 Ok(())
376}
377
378#[cfg(test)]
379#[allow(clippy::expect_used, clippy::unwrap_used)]
380mod tests {
381 use crate::graph::MintScopeSource;
382 use runx_contracts::JsonValue;
383
384 use super::validate_act_declaration;
385 use serde_json::json;
386
387 fn act_value(value: serde_json::Value) -> JsonValue {
388 serde_json::from_value(value).expect("convertible act value")
389 }
390
391 fn act_err(value: serde_json::Value) -> String {
392 validate_act_declaration(Some(&act_value(value)))
393 .err()
394 .map(|error| error.to_string())
395 .expect("act unexpectedly validated")
396 }
397
398 #[test]
399 fn requested_scope_mint_act_validates() {
400 let act = validate_act_declaration(Some(&act_value(json!({
401 "mint_authority": {"source": "requested_scope"},
402 "requested_scope_from": "needed_scope",
403 }))))
404 .expect("valid act")
405 .expect("present act");
406 assert_eq!(
407 act.mint_authority.map(|directive| directive.source),
408 Some(MintScopeSource::RequestedScope)
409 );
410 }
411
412 #[test]
413 fn mint_authority_conflicts_with_prebuilt_path() {
414 let message = act_err(json!({
415 "mint_authority": {"source": "static_scopes"},
416 "authority_term_from": "member_authority",
417 }));
418 assert!(message.contains("mutually exclusive"));
419 }
420
421 #[test]
422 fn requested_scope_act_requires_input_key() {
423 let message = act_err(json!({
424 "mint_authority": {"source": "requested_scope"},
425 }));
426 assert!(message.contains("requires requested_scope_from"));
427 }
428
429 #[test]
430 fn static_scopes_act_rejects_requested_scope_from() {
431 let message = act_err(json!({
432 "mint_authority": {"source": "static_scopes"},
433 "requested_scope_from": "needed_scope",
434 }));
435 assert!(message.contains("must not declare requested_scope_from"));
436 }
437
438 #[test]
439 fn dangling_requested_scope_from_in_act_is_rejected() {
440 let message = act_err(json!({
441 "requested_scope_from": "needed_scope",
442 }));
443 assert!(message.contains("only valid with a mint_authority directive"));
444 }
445}