Skip to main content

harn_cli/package/manifest/
provider_setup.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use super::{
7    ConnectorCapabilities, ProviderConnectorManifest, ProviderOAuthManifest,
8    ResolvedProviderConnectorKind,
9};
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct ProviderManifestEntry {
13    pub id: harn_vm::ProviderId,
14    pub connector: ProviderConnectorManifest,
15    #[serde(default)]
16    pub oauth: Option<ProviderOAuthManifest>,
17    #[serde(default)]
18    pub setup: Option<ProviderSetupManifest>,
19    #[serde(default)]
20    pub service: Option<ConnectorServiceManifest>,
21    #[serde(default)]
22    pub capabilities: ConnectorCapabilities,
23}
24
25#[derive(Debug, Clone)]
26pub struct ResolvedProviderConnectorConfig {
27    pub id: harn_vm::ProviderId,
28    pub manifest_dir: PathBuf,
29    pub connector: ResolvedProviderConnectorKind,
30    pub oauth: Option<ProviderOAuthManifest>,
31    pub setup: Option<ProviderSetupManifest>,
32    pub service: Option<ConnectorServiceManifest>,
33    pub connector_contract_version: u32,
34}
35
36/// Product-facing connector metadata shared by every host projection.
37///
38/// Provider-specific request and response shapes stay in the connector. This
39/// contract describes only the portable service, action, disclosure, spend,
40/// evidence, and reconciliation semantics that hosts must agree on.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ConnectorServiceManifest {
44    pub name: String,
45    pub description: String,
46    #[serde(default)]
47    pub operations: Vec<ConnectorOperationManifest>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct ConnectorOperationManifest {
53    pub id: String,
54    pub capability: String,
55    /// Plain-language, action-specific reason shown before disclosure.
56    pub purpose: String,
57    pub effect: ConnectorOperationEffect,
58    #[serde(default)]
59    pub environments: Vec<ConnectorEnvironment>,
60    #[serde(default)]
61    pub evidence: Vec<ConnectorEvidenceRequirement>,
62    #[serde(default)]
63    pub protected_profile: ConnectorProtectedProfileManifest,
64    #[serde(default)]
65    pub test_profile: ConnectorTestProfile,
66    #[serde(default)]
67    pub external_spend: ConnectorExternalSpend,
68    #[serde(default)]
69    pub reconciliation: ConnectorReconciliation,
70    #[serde(default)]
71    pub redaction: Vec<ConnectorRedactionTarget>,
72    /// The arguments the operation accepts, for hosts that project it into an
73    /// agent tool.
74    ///
75    /// Empty is a legitimate state, not an omission. A connector repository
76    /// pins a Harn version and its manifest is `deny_unknown_fields`, so no
77    /// connector can declare this key until a release carrying it reaches
78    /// that repository. Hosts must therefore keep projecting an operation that
79    /// declares nothing here, falling back to free-form arguments, rather than
80    /// treating an empty list as "takes no arguments".
81    #[serde(default)]
82    pub parameters: Vec<ConnectorParameterManifest>,
83}
84
85/// One argument a connector operation accepts.
86///
87/// The service contract deliberately stops short of provider request and
88/// response shapes, but a host projecting an operation into an agent tool has
89/// to describe its arguments or the model is left guessing their names. This
90/// is that minimum and no more.
91///
92/// It is a closed vocabulary rather than embedded JSON Schema on purpose:
93/// every other field in this contract is a closed enum validated at the
94/// manifest boundary, and an arbitrary schema blob would be unauthorable in
95/// TOML, uncomparable, and unable to fail shut. Hosts widen this into whatever
96/// schema dialect their tool surface speaks.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ConnectorParameterManifest {
100    pub name: String,
101    /// Plain-language description of the argument, shown to the model.
102    pub description: String,
103    #[serde(rename = "type")]
104    pub value_type: ConnectorParameterType,
105    #[serde(default)]
106    pub required: bool,
107    /// The closed set of accepted values, when the operation accepts only
108    /// known ones. Empty means unconstrained.
109    #[serde(default)]
110    pub allowed_values: Vec<String>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ConnectorParameterType {
116    String,
117    Integer,
118    Number,
119    Boolean,
120    Object,
121    Array,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum ConnectorOperationEffect {
127    Read,
128    Consequential,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum ConnectorEnvironment {
134    Mock,
135    Test,
136    Live,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ConnectorEvidenceRequirement {
142    Citation,
143    CurrentProviderState,
144    FreshQuote,
145    UserConfirmation,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ProtectedProfileFieldClass {
151    LegalIdentity,
152    BirthDate,
153    ContactDetails,
154    AccessibilityNeeds,
155    LoyaltyAccounts,
156    TravelDocuments,
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct ConnectorProtectedProfileManifest {
162    #[serde(default)]
163    pub required: Vec<ProtectedProfileFieldClass>,
164    #[serde(default)]
165    pub optional: Vec<ProtectedProfileFieldClass>,
166    #[serde(default)]
167    pub conditional: Vec<ConnectorConditionalProfileRequirement>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct ConnectorConditionalProfileRequirement {
173    /// Stable connector-owned condition id evaluated by the adapter.
174    pub condition: String,
175    pub field_classes: Vec<ProtectedProfileFieldClass>,
176}
177
178#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum ConnectorExternalSpend {
181    #[default]
182    None,
183    Estimate,
184    Commit,
185}
186
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum ConnectorReconciliation {
190    #[default]
191    None,
192    Supported,
193    Required,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum ConnectorRedactionTarget {
199    RequestBody,
200    ResponseBody,
201    ErrorBody,
202    Headers,
203    Query,
204}
205
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum ConnectorTestProfile {
209    #[default]
210    None,
211    FictionalRequired,
212}
213
214/// Validate the product-facing service contract at every manifest boundary.
215pub fn connector_service_issues(service: &ConnectorServiceManifest) -> Vec<String> {
216    let mut issues = Vec::new();
217    if service.name.trim().is_empty() {
218        issues.push("service.name is required".to_string());
219    }
220    if service.description.trim().is_empty() {
221        issues.push("service.description is required".to_string());
222    }
223    if service.operations.is_empty() {
224        issues.push("service.operations must include at least one operation".to_string());
225    }
226
227    let mut operation_ids = std::collections::BTreeSet::new();
228    for operation in &service.operations {
229        let label = if operation.id.trim().is_empty() {
230            "<missing>"
231        } else {
232            operation.id.as_str()
233        };
234        if !portable_operation_id(&operation.id) {
235            issues.push(format!(
236                "service operation '{label}' id must use ASCII letters, digits, '.', '_', or '-'"
237            ));
238        } else if !operation_ids.insert(operation.id.as_str()) {
239            issues.push(format!(
240                "service operation id '{}' is repeated",
241                operation.id
242            ));
243        }
244        if !portable_id(&operation.capability) {
245            issues.push(format!(
246                "service operation '{label}' capability must use lowercase letters, digits, '.', '_', or '-'"
247            ));
248        }
249        if operation.purpose.trim().is_empty() {
250            issues.push(format!("service operation '{label}' purpose is required"));
251        }
252        if operation.environments.is_empty() {
253            issues.push(format!(
254                "service operation '{label}' environments must not be empty"
255            ));
256        }
257        if operation.effect == ConnectorOperationEffect::Read
258            && operation.external_spend != ConnectorExternalSpend::None
259        {
260            issues.push(format!(
261                "read operation '{label}' cannot declare external spend"
262            ));
263        }
264        if operation.effect == ConnectorOperationEffect::Read
265            && operation.reconciliation == ConnectorReconciliation::Required
266        {
267            issues.push(format!(
268                "read operation '{label}' cannot require reconciliation"
269            ));
270        }
271
272        let mut parameter_names = std::collections::BTreeSet::new();
273        for parameter in &operation.parameters {
274            let parameter_label = if parameter.name.trim().is_empty() {
275                "<missing>"
276            } else {
277                parameter.name.as_str()
278            };
279            if !portable_operation_id(&parameter.name) {
280                issues.push(format!(
281                    "service operation '{label}' parameter '{parameter_label}' name must use ASCII letters, digits, '.', '_', or '-'"
282                ));
283            } else if !parameter_names.insert(parameter.name.as_str()) {
284                issues.push(format!(
285                    "service operation '{label}' repeats parameter '{parameter_label}'"
286                ));
287            }
288            // The description is the only thing that tells a model what to put
289            // in the argument, so an undescribed parameter is worse than an
290            // undeclared one: it advertises a name and explains nothing.
291            if parameter.description.trim().is_empty() {
292                issues.push(format!(
293                    "service operation '{label}' parameter '{parameter_label}' description is required"
294                ));
295            }
296            if !parameter.allowed_values.is_empty()
297                && parameter.value_type != ConnectorParameterType::String
298            {
299                issues.push(format!(
300                    "service operation '{label}' parameter '{parameter_label}' declares allowed values, which only apply to a string parameter"
301                ));
302            }
303            let mut allowed = std::collections::BTreeSet::new();
304            for value in &parameter.allowed_values {
305                if !allowed.insert(value.as_str()) {
306                    issues.push(format!(
307                        "service operation '{label}' parameter '{parameter_label}' repeats allowed value '{value}'"
308                    ));
309                }
310            }
311        }
312
313        let profile = &operation.protected_profile;
314        let mut declared_classes = std::collections::BTreeSet::new();
315        for class in profile.required.iter().chain(profile.optional.iter()) {
316            if !declared_classes.insert(*class as u8) {
317                issues.push(format!(
318                    "service operation '{label}' repeats protected profile class '{class:?}'"
319                ));
320            }
321        }
322        for requirement in &profile.conditional {
323            if !portable_id(&requirement.condition) {
324                issues.push(format!(
325                    "service operation '{label}' conditional profile id '{}' is invalid",
326                    requirement.condition
327                ));
328            }
329            if requirement.field_classes.is_empty() {
330                issues.push(format!(
331                    "service operation '{label}' conditional profile '{}' has no field classes",
332                    requirement.condition
333                ));
334            }
335        }
336
337        let has_profile = !profile.required.is_empty()
338            || !profile.optional.is_empty()
339            || profile
340                .conditional
341                .iter()
342                .any(|requirement| !requirement.field_classes.is_empty());
343        if has_profile
344            && operation.environments.contains(&ConnectorEnvironment::Test)
345            && operation.test_profile != ConnectorTestProfile::FictionalRequired
346        {
347            issues.push(format!(
348                "service operation '{label}' uses protected profile fields in test mode but does not require a fictional fixture"
349            ));
350        }
351        if has_profile {
352            for required_target in [
353                ConnectorRedactionTarget::RequestBody,
354                ConnectorRedactionTarget::ResponseBody,
355                ConnectorRedactionTarget::ErrorBody,
356            ] {
357                if !operation.redaction.contains(&required_target) {
358                    issues.push(format!(
359                        "service operation '{label}' with protected profile fields must redact {required_target:?}"
360                    ));
361                }
362            }
363        }
364    }
365    issues
366}
367
368fn portable_id(value: &str) -> bool {
369    !value.is_empty()
370        && value.bytes().all(|byte| {
371            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
372        })
373}
374
375fn portable_operation_id(value: &str) -> bool {
376    !value.is_empty()
377        && value.bytes().all(|byte| {
378            byte.is_ascii_alphabetic()
379                || byte.is_ascii_digit()
380                || matches!(byte, b'.' | b'_' | b'-')
381        })
382}
383
384#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
385pub struct ProviderSetupManifest {
386    #[serde(default, alias = "auth-type")]
387    pub auth_type: Option<String>,
388    #[serde(default)]
389    pub flow: Option<String>,
390    #[serde(default, alias = "required-scopes", alias = "scopes")]
391    pub required_scopes: Vec<String>,
392    #[serde(default, alias = "required-secrets")]
393    pub required_secrets: Vec<String>,
394    #[serde(default, alias = "credential-environment")]
395    pub credential_environment: Vec<ConnectorCredentialEnvironmentManifest>,
396    #[serde(default, alias = "configuration-environment")]
397    pub configuration_environment: Vec<ConnectorConfigurationEnvironmentManifest>,
398    #[serde(default, alias = "setup-command")]
399    pub setup_command: Vec<String>,
400    #[serde(default, alias = "validation-command")]
401    pub validation_command: Vec<String>,
402    #[serde(default, alias = "health-checks")]
403    pub health_checks: Vec<ConnectorHealthCheckManifest>,
404    #[serde(default)]
405    pub recovery: ConnectorRecoveryCopy,
406    #[serde(flatten, default)]
407    pub extra: BTreeMap<String, toml::Value>,
408}
409
410/// Non-secret setup input that a connector may read from an explicit process
411/// environment allowlist. Values are consumed only by the setup adapter and
412/// are never projected into plans, status reports, or model context.
413#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
414pub enum ConnectorSetupConfigurationField {
415    #[serde(rename = "oauth_client_id")]
416    OAuthClientId,
417}
418
419impl ConnectorSetupConfigurationField {
420    pub const WIRE_VALUES: &'static [&'static str] = &["oauth_client_id"];
421
422    pub const fn as_str(self) -> &'static str {
423        match self {
424            Self::OAuthClientId => "oauth_client_id",
425        }
426    }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct ConnectorConfigurationEnvironmentManifest {
431    pub field: ConnectorSetupConfigurationField,
432    #[serde(default, alias = "environment-names")]
433    pub environment_names: Vec<String>,
434}
435
436/// Bounded process-environment aliases for one logical connector secret.
437///
438/// The logical secret remains the stable interface. Environment names are
439/// explicit recovery and automation sources. They never authorize scanning
440/// arbitrary process variables.
441#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
442pub struct ConnectorCredentialEnvironmentManifest {
443    pub secret: String,
444    #[serde(default, alias = "environment-names")]
445    pub environment_names: Vec<String>,
446}
447
448#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
449pub struct ConnectorHealthCheckManifest {
450    pub id: String,
451    pub kind: String,
452    #[serde(default)]
453    pub command: Vec<String>,
454    #[serde(default)]
455    pub secret: Option<String>,
456    #[serde(default)]
457    pub url: Option<String>,
458}
459
460#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
461pub struct ConnectorRecoveryCopy {
462    #[serde(default, alias = "missing-install")]
463    pub missing_install: Option<String>,
464    #[serde(default, alias = "missing-auth")]
465    pub missing_auth: Option<String>,
466    #[serde(default, alias = "expired-credentials")]
467    pub expired_credentials: Option<String>,
468    #[serde(default, alias = "revoked-credentials")]
469    pub revoked_credentials: Option<String>,
470    #[serde(default, alias = "missing-scopes")]
471    pub missing_scopes: Option<String>,
472    #[serde(default, alias = "inaccessible-resource")]
473    pub inaccessible_resource: Option<String>,
474    #[serde(default, alias = "transient-provider-outage")]
475    pub transient_provider_outage: Option<String>,
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn protected_profile_contract_is_typed_and_complete() {
484        let service: ConnectorServiceManifest = toml::from_str(
485            r#"
486name = "Duffel"
487description = "Searches flights and creates governed test orders."
488
489[[operations]]
490id = "orders.create"
491capability = "travel.booking"
492purpose = "Create the exact reviewed flight order."
493effect = "consequential"
494environments = ["test"]
495evidence = ["fresh_quote", "user_confirmation"]
496external_spend = "commit"
497reconciliation = "required"
498redaction = ["request_body", "response_body", "error_body"]
499test_profile = "fictional_required"
500
501[operations.protected_profile]
502required = ["legal_identity", "birth_date"]
503optional = ["contact_details"]
504
505[[operations.protected_profile.conditional]]
506condition = "international_itinerary"
507field_classes = ["travel_documents"]
508"#,
509        )
510        .expect("typed service manifest");
511
512        assert!(connector_service_issues(&service).is_empty());
513        assert_eq!(
514            service.operations[0].protected_profile.required,
515            [
516                ProtectedProfileFieldClass::LegalIdentity,
517                ProtectedProfileFieldClass::BirthDate,
518            ]
519        );
520    }
521
522    #[test]
523    fn protected_profile_test_actions_require_fictional_fixture_and_redaction() {
524        let service = ConnectorServiceManifest {
525            name: "Duffel".to_string(),
526            description: "Travel".to_string(),
527            operations: vec![ConnectorOperationManifest {
528                id: "orders.create".to_string(),
529                capability: "travel.booking".to_string(),
530                purpose: "Create an order".to_string(),
531                effect: ConnectorOperationEffect::Consequential,
532                environments: vec![ConnectorEnvironment::Test],
533                evidence: Vec::new(),
534                protected_profile: ConnectorProtectedProfileManifest {
535                    required: vec![ProtectedProfileFieldClass::LegalIdentity],
536                    ..ConnectorProtectedProfileManifest::default()
537                },
538                test_profile: ConnectorTestProfile::None,
539                external_spend: ConnectorExternalSpend::Commit,
540                reconciliation: ConnectorReconciliation::Required,
541                redaction: vec![ConnectorRedactionTarget::ErrorBody],
542                parameters: Vec::new(),
543            }],
544        };
545
546        let issues = connector_service_issues(&service).join("\n");
547        assert!(issues.contains("does not require a fictional fixture"));
548        assert!(issues.contains("RequestBody"));
549        assert!(issues.contains("ResponseBody"));
550    }
551
552    #[test]
553    fn operation_parameters_are_typed_and_optional() {
554        let service: ConnectorServiceManifest = toml::from_str(
555            r#"
556name = "Duffel"
557description = "Searches flights."
558
559[[operations]]
560id = "offers.list"
561capability = "flights.research"
562purpose = "List offers for a completed offer request."
563effect = "read"
564environments = ["test"]
565
566[[operations.parameters]]
567name = "offer_request_id"
568description = "The offer request to list offers for."
569type = "string"
570required = true
571
572[[operations.parameters]]
573name = "limit"
574description = "How many offers to return."
575type = "integer"
576
577[[operations.parameters]]
578name = "sort"
579description = "Ordering applied to the returned offers."
580type = "string"
581allowed_values = ["total_amount", "total_duration"]
582
583[[operations]]
584id = "places.list"
585capability = "flights.research"
586purpose = "Search airports and cities."
587effect = "read"
588environments = ["test"]
589"#,
590        )
591        .expect("typed parameters");
592
593        assert!(connector_service_issues(&service).is_empty());
594
595        let listed = &service.operations[0].parameters;
596        assert_eq!(listed.len(), 3);
597        assert_eq!(listed[0].name, "offer_request_id");
598        assert_eq!(listed[0].value_type, ConnectorParameterType::String);
599        assert!(listed[0].required);
600        // Absent `required` means optional, so a host never has to guess.
601        assert!(!listed[1].required);
602        assert_eq!(listed[1].value_type, ConnectorParameterType::Integer);
603        assert_eq!(listed[2].allowed_values, ["total_amount", "total_duration"]);
604
605        // An operation that declares no parameters stays valid. Connector
606        // repositories cannot add the key until a release carrying it reaches
607        // them, so this is the state every existing manifest is in.
608        assert!(service.operations[1].parameters.is_empty());
609    }
610
611    #[test]
612    fn operation_parameters_must_be_named_described_and_distinct() {
613        let service: ConnectorServiceManifest = toml::from_str(
614            r#"
615name = "Duffel"
616description = "Searches flights."
617
618[[operations]]
619id = "offers.list"
620capability = "flights.research"
621purpose = "List offers."
622effect = "read"
623environments = ["test"]
624
625[[operations.parameters]]
626name = "limit"
627description = "How many offers to return."
628type = "integer"
629
630[[operations.parameters]]
631name = "limit"
632description = "A repeat of the same argument."
633type = "integer"
634
635[[operations.parameters]]
636name = "sort by"
637description = "Name is not portable."
638type = "string"
639
640[[operations.parameters]]
641name = "cursor"
642description = "   "
643type = "string"
644
645[[operations.parameters]]
646name = "page"
647description = "Allowed values only apply to a string."
648type = "integer"
649allowed_values = ["1", "2"]
650"#,
651        )
652        .expect("parses; the issues are semantic");
653
654        let issues = connector_service_issues(&service).join("\n");
655        assert!(issues.contains("repeats parameter 'limit'"), "{issues}");
656        assert!(
657            issues.contains("parameter 'sort by' name must use"),
658            "{issues}"
659        );
660        assert!(
661            issues.contains("parameter 'cursor' description is required"),
662            "{issues}"
663        );
664        assert!(
665            issues.contains("parameter 'page' declares allowed values"),
666            "{issues}"
667        );
668    }
669
670    /// The parameter block is `deny_unknown_fields` like the rest of the
671    /// contract, so a misspelled key fails closed instead of silently
672    /// projecting an argument nobody described.
673    #[test]
674    fn operation_parameters_reject_unknown_fields() {
675        let error = toml::from_str::<ConnectorServiceManifest>(
676            r#"
677name = "Duffel"
678description = "Searches flights."
679
680[[operations]]
681id = "offers.list"
682capability = "flights.research"
683purpose = "List offers."
684effect = "read"
685environments = ["test"]
686
687[[operations.parameters]]
688name = "limit"
689description = "How many offers to return."
690type = "integer"
691defualt = 10
692"#,
693        )
694        .expect_err("unknown parameter keys must fail closed");
695
696        assert!(error.to_string().contains("defualt"), "{error}");
697    }
698
699    #[test]
700    fn service_manifest_rejects_unknown_policy_fields() {
701        let error = toml::from_str::<ConnectorServiceManifest>(
702            r#"
703name = "Echo"
704description = "Echoes messages."
705automatic_approval = true
706"#,
707        )
708        .expect_err("unknown policy keys must fail closed");
709        assert!(error.to_string().contains("unknown field"));
710    }
711}