1use crate::interactive::{NativeEntryKind, NativePurpose, NativeSessionScope};
4use crate::ir::{ComponentNode, WarbleIr};
5use crate::session::{produce_session, COMPONENT_HOST_PROTOCOL, MAX_INPUT_BYTES};
6use crate::slots::SlotSupply;
7use crate::DispatchError;
8use serde::Deserialize;
9use serde_json::{json, Value};
10use sha2::{Digest, Sha256};
11use std::collections::{BTreeMap, BTreeSet};
12
13#[derive(Deserialize)]
14#[serde(deny_unknown_fields)]
15struct Descriptor {
16 version: String,
17 protocol: String,
18 vendor: String,
19 session_id: String,
20 auth_identity: String,
21 runtime_generation: String,
22 binding: crate::interactive::NativeBinding,
23 prepared_contexts: BTreeMap<String, String>,
25 roots: BTreeMap<String, Value>,
27}
28
29#[derive(Clone, Debug)]
30pub struct NativeHost {
31 document: Value,
32 ir: WarbleIr,
33 scope_digest: String,
34 target: String,
35 roots: BTreeMap<String, String>,
36}
37
38fn fail(message: &str) -> DispatchError {
39 DispatchError(format!("native component host: {message} (wall-hit)"))
40}
41fn digest(value: &Value) -> String {
42 format!("sha256:{:x}", Sha256::digest(value.to_string().as_bytes()))
43}
44fn opaque(value: &str) -> bool {
45 !value.is_empty()
46 && value.len() <= 256
47 && !value.chars().any(|c| c.is_control() || c.is_whitespace())
48}
49fn is_digest(value: &str) -> bool {
50 value.strip_prefix("sha256:").is_some_and(|s| {
51 s.len() == 64
52 && s.bytes()
53 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
54 })
55}
56
57pub fn has_calls(node: &ComponentNode) -> bool {
58 node.llm_calls
59 .iter()
60 .any(|step| !step.component_calls.is_empty())
61}
62
63pub(crate) fn resolution_report(node: &ComponentNode) -> crate::resolve::ResolutionReport {
64 node.required_capabilities.iter().map(|capability| crate::resolve::ResolvedCapability {
65 capability: capability.clone(), outcome: crate::targets::CapabilityOutcome::RealizeVia,
66 provided_by: crate::targets::ProvidedBy::Runtime, criticality: crate::targets::Criticality::SafetyCritical,
67 note: Some("Explicit host-owned direct-session plan; runtime identity, context and isolation must be verified before execution. Emission is not readiness.".into()),
68 }).collect()
69}
70
71impl NativeHost {
72 pub fn ir(&self) -> &WarbleIr {
75 &self.ir
76 }
77 pub fn prepare(
80 raw_ir: &str,
81 raw_descriptor: &str,
82 target: &str,
83 purpose: NativePurpose,
84 scope: &NativeSessionScope,
85 ) -> Result<Self, DispatchError> {
86 if raw_ir.len() > MAX_INPUT_BYTES || raw_descriptor.len() > MAX_INPUT_BYTES {
87 return Err(fail("input exceeds size limit"));
88 }
89 let value = crate::session::unique_json(raw_descriptor)?;
90 let descriptor: Descriptor =
91 serde_json::from_value(value).map_err(|_| fail("invalid host descriptor"))?;
92 let raw = crate::session::unique_json(raw_ir)?;
93 let ir: WarbleIr = serde_json::from_value(raw.clone()).map_err(|_| fail("invalid IR"))?;
94 let vendor = match target {
95 "claude-code:interactive" => "claude",
96 "codex:interactive" => "codex",
97 "pi:interactive" => "pi",
98 _ => return Err(fail("requires native interactive target")),
99 };
100 if descriptor.version != "1"
101 || descriptor.protocol != COMPONENT_HOST_PROTOCOL
102 || descriptor.vendor != vendor
103 || purpose != NativePurpose::Analysis
104 || (matches!(vendor, "codex" | "pi") && scope.entry.kind == NativeEntryKind::Scope)
105 {
106 return Err(fail("unsupported purpose, vendor, entry or protocol"));
107 }
108 for identity in [
109 &descriptor.session_id,
110 &descriptor.auth_identity,
111 &descriptor.runtime_generation,
112 ] {
113 if !opaque(identity) {
114 return Err(fail("missing bounded host identity"));
115 }
116 }
117 if serde_json::to_value(&scope.binding).map_err(|_| fail("invalid scope binding"))?
118 != serde_json::to_value(&descriptor.binding)
119 .map_err(|_| fail("invalid host binding"))?
120 {
121 return Err(fail("host binding differs from native scope"));
122 }
123 let entries = WarbleIr {
124 components: ir
125 .components
126 .iter()
127 .filter(|n| n.entrypoint)
128 .cloned()
129 .collect(),
130 ..ir.clone()
131 };
132 purpose.validate_profile(&entries, &scope.entry)?;
133 for node in raw["components"]
134 .as_array()
135 .ok_or_else(|| fail("missing components"))?
136 {
137 if node["entrypoint"] == true
138 && scope.entry.pinned_verb().is_none_or(|id| node["id"] == id)
139 && node
140 .get("assets")
141 .is_some_and(|v| !v.as_array().is_some_and(Vec::is_empty))
142 {
143 return Err(fail("native host materialization does not support assets"));
144 }
145 }
146 let selected = entries
147 .components
148 .iter()
149 .filter(|n| scope.entry.pinned_verb().is_none_or(|id| id == n.id))
150 .filter(|n| scope.entry.kind == NativeEntryKind::Agent || has_calls(n))
151 .collect::<Vec<_>>();
152 if selected.is_empty()
153 || selected
154 .iter()
155 .map(|n| n.id.as_str())
156 .collect::<BTreeSet<_>>()
157 != descriptor
158 .roots
159 .keys()
160 .map(String::as_str)
161 .collect::<BTreeSet<_>>()
162 {
163 return Err(fail(
164 "host roots must exactly match admitted composed entries",
165 ));
166 }
167 let mut roots = BTreeMap::new();
168 let mut plans = BTreeMap::new();
169 let mut contexts = BTreeSet::new();
170 for node in selected {
171 if node.id != node.verb {
172 return Err(fail("native root identity must match its advertised verb"));
173 }
174 let contract = &descriptor.roots[&node.id];
175 if contract["version"] != "2" {
176 return Err(fail("native composition requires direct-session format 2"));
177 }
178 let plan =
179 produce_session(raw_ir, &contract.to_string(), &node.id, &SlotSupply::new())?;
180 for id in plan["components"]
181 .as_object()
182 .expect("validated plan registry")
183 .keys()
184 {
185 contexts.insert(id.clone());
186 }
187 let tool = format!("warble_run_{:x}", Sha256::digest(node.id.as_bytes()));
188 roots.insert(node.id.clone(), tool[..43].to_string());
190 plans.insert(node.id.clone(), plan);
191 }
192 if descriptor
193 .prepared_contexts
194 .keys()
195 .cloned()
196 .collect::<BTreeSet<_>>()
197 != contexts
198 || descriptor.prepared_contexts.values().any(|v| !is_digest(v))
199 {
200 return Err(fail(
201 "missing or invalid per-component prepared context identity",
202 ));
203 }
204 let scope_digest =
205 digest(&serde_json::to_value(scope).map_err(|_| fail("scope serialization"))?);
206 let mut document = json!({
207 "version": "1", "protocol": COMPONENT_HOST_PROTOCOL, "execution": "host_owned_steps",
208 "execution_status": "not_executed", "vendor": vendor,
209 "session_id": descriptor.session_id, "auth_identity": descriptor.auth_identity,
210 "runtime_generation": descriptor.runtime_generation, "binding": descriptor.binding,
211 "prepared_contexts": descriptor.prepared_contexts, "root_tools": roots, "plans": plans,
212 "input_ir_sha256": format!("sha256:{:x}", Sha256::digest(raw_ir.as_bytes())),
213 "scope_sha256": scope_digest,
214 "request_schema": {"type":"object", "additionalProperties":false,
215 "required":["request"], "properties":{"request":{"type":"string", "minLength":1, "maxLength":65536}}},
216 });
217 document["host_plan_sha256"] = json!(digest(&document));
218 if document.to_string().len() > MAX_INPUT_BYTES {
219 return Err(fail("native plan exceeds size limit"));
220 }
221 Ok(Self {
222 document,
223 ir,
224 scope_digest,
225 target: target.into(),
226 roots,
227 })
228 }
229
230 pub(crate) fn validate(
231 &self,
232 ir: &WarbleIr,
233 target: &str,
234 purpose: Option<NativePurpose>,
235 scope: Option<&NativeSessionScope>,
236 has_mcp: bool,
237 ) -> Result<(), DispatchError> {
238 if self.target != target
239 || purpose != Some(NativePurpose::Analysis)
240 || !has_mcp
241 || ir != &self.ir
242 || scope.map(|s| digest(&serde_json::to_value(s).expect("scope serializes")))
243 != Some(self.scope_digest.clone())
244 {
245 return Err(fail("host plan does not match native launch inputs"));
246 }
247 Ok(())
248 }
249 pub(crate) fn tool(&self, id: &str) -> Option<&str> {
250 self.roots.get(id).map(String::as_str)
251 }
252 pub(crate) fn tool_names(&self) -> Vec<&str> {
253 self.roots.values().map(String::as_str).collect()
254 }
255 pub(crate) fn document(&self) -> &Value {
256 &self.document
257 }
258 pub(crate) fn launch_value(&self) -> Value {
259 json!({"protocol": COMPONENT_HOST_PROTOCOL, "execution": "host_owned_steps",
260 "plan_path": ".warble/component-plans.json", "host_plan_sha256": self.document["host_plan_sha256"],
261 "vendor": self.document["vendor"], "auth_identity": self.document["auth_identity"],
262 "session_id": self.document["session_id"], "runtime_generation": self.document["runtime_generation"],
263 "binding": self.document["binding"], "root_tools": self.roots})
264 }
265 pub(crate) fn instructions(&self, node: &ComponentNode) -> String {
266 let tool = self.tool(&node.id).expect("governed root");
267 format!("Run the requested work through `genbi_session.{tool}` with only `request`, the user's request as text. This is admission to this fixed root; the host owns every step, child, context, tool and artifact. Do not execute these steps locally, supply step/child identity, run SQL, or reconstruct a result. Report only the correlated host result in readable prose. A refusal is not a successful artifact. The terminal's existing conversation is retained, but governed steps execute in fresh host-managed contexts using the same approved vendor/account.")
268 }
269 pub(crate) fn claude_wrapper(&self, node: &ComponentNode) -> String {
270 let description = serde_json::to_string(node.description.as_deref().unwrap_or(&node.verb))
272 .expect("string");
273 format!(
274 "---\nname: {}\ndescription: {}\ntools: mcp__genbi_session__{}\n---\n\n{}\n",
275 node.id,
276 description,
277 self.tool(&node.id).expect("governed root"),
278 self.instructions(node)
279 )
280 }
281}