1use crate::api::openai_compat::{AppState, ChatProviderInfo};
2use crate::package::config::load_manifest;
3use crate::package::{
4 build_service_config, native_library_candidates, DiscoveredPackage, PackageInfo, PackageRuntime,
5};
6use axum::extract::State;
7use axum::http::StatusCode;
8use axum::Json;
9use std::path::{Component, Path, PathBuf};
10
11#[derive(Debug, serde::Deserialize)]
12pub struct ActivationPlanRequest {
13 #[serde(default)]
14 materialized_path: Option<String>,
15 #[serde(default)]
16 package_path: Option<String>,
17 #[serde(default)]
18 path: Option<String>,
19 #[serde(default)]
20 apply: bool,
21 #[serde(default)]
22 confirm: bool,
23 #[serde(default)]
24 start_service: bool,
25}
26
27fn requested_package_path(request: &ActivationPlanRequest) -> Option<&str> {
28 request
29 .materialized_path
30 .as_deref()
31 .or(request.package_path.as_deref())
32 .or(request.path.as_deref())
33 .map(str::trim)
34 .filter(|value| !value.is_empty())
35}
36
37fn has_parent_dir_component(path: &std::path::Path) -> bool {
38 path.components()
39 .any(|component| matches!(component, Component::ParentDir))
40}
41
42fn resolve_package_path(repo_root: &std::path::Path, requested: &str) -> Result<PathBuf, String> {
43 let path = PathBuf::from(requested);
44 if has_parent_dir_component(&path) {
45 return Err("package path must not contain parent-directory components".to_string());
46 }
47
48 if path.is_absolute() {
49 Ok(path)
50 } else {
51 Ok(repo_root.join(path))
52 }
53}
54
55fn normalize_path_for_guard(path: &Path) -> PathBuf {
56 std::fs::canonicalize(path).unwrap_or_else(|_| {
57 if path.is_absolute() {
58 path.to_path_buf()
59 } else {
60 std::env::current_dir()
61 .map(|cwd| cwd.join(path))
62 .unwrap_or_else(|_| path.to_path_buf())
63 }
64 })
65}
66
67fn materialized_managed_root(repo_root: &Path) -> PathBuf {
68 repo_root.join(".weft").join("materialized")
69}
70
71fn is_under_explicit_temp_or_managed_root(repo_root: &Path, package_path: &Path) -> bool {
72 let package_path = normalize_path_for_guard(package_path);
73 let temp_root = normalize_path_for_guard(&std::env::temp_dir());
74 let managed_root = normalize_path_for_guard(&materialized_managed_root(repo_root));
75
76 package_path.starts_with(&temp_root) || package_path.starts_with(&managed_root)
77}
78
79fn blocked_response(
80 package_path: &Path,
81 manifest_found: bool,
82 checks: Vec<serde_json::Value>,
83 validation_issues: Vec<String>,
84) -> serde_json::Value {
85 serde_json::json!({
86 "status": "activation_plan_blocked",
87 "plan_only": true,
88 "metadata_only": true,
89 "activation_performed": false,
90 "mutation_performed": false,
91 "lock_mutation_performed": false,
92 "package_path": package_path.display().to_string(),
93 "manifest_found": manifest_found,
94 "activation_required": false,
95 "ready_for_activation": false,
96 "checks": checks,
97 "validation_issues": validation_issues,
98 })
99}
100
101pub async fn activation_plan(
103 State(state): State<AppState>,
104 Json(request): Json<ActivationPlanRequest>,
105) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
106 let requested_path = requested_package_path(&request).ok_or_else(|| {
107 (
108 StatusCode::BAD_REQUEST,
109 Json(serde_json::json!({
110 "error": "missing materialized_path/package_path/path",
111 "activation_performed": false,
112 "mutation_performed": false,
113 })),
114 )
115 })?;
116
117 let package_path = resolve_package_path(&state.repo_root, requested_path).map_err(|error| {
118 (
119 StatusCode::BAD_REQUEST,
120 Json(serde_json::json!({
121 "error": error,
122 "activation_performed": false,
123 "mutation_performed": false,
124 })),
125 )
126 })?;
127
128 let manifest_path = package_path.join("package.toml");
129 let path_exists = package_path.is_dir();
130 let manifest_found = manifest_path.is_file();
131
132 let mut checks = vec![
133 serde_json::json!({
134 "name": "package_directory_exists",
135 "ok": path_exists,
136 "path": package_path.display().to_string(),
137 }),
138 serde_json::json!({
139 "name": "plugin_manifest_found",
140 "ok": manifest_found,
141 "path": manifest_path.display().to_string(),
142 }),
143 ];
144
145 if !path_exists || !manifest_found {
146 return Ok(Json(blocked_response(
147 &package_path,
148 manifest_found,
149 checks,
150 vec!["materialized package directory and package.toml are required before activation can be planned".to_string()],
151 )));
152 }
153
154 let manifest = load_manifest(&package_path).map_err(|error| {
155 (
156 StatusCode::BAD_REQUEST,
157 Json(serde_json::json!({
158 "error": format!("failed to parse package.toml: {error}"),
159 "activation_performed": false,
160 "mutation_performed": false,
161 })),
162 )
163 })?;
164
165 let runtime = manifest.runtime_kind();
166 let package_runtime = PackageRuntime::from_manifest(&manifest);
167 let entry = manifest.resolved_entry();
168 let entry_path = entry.as_ref().map(|entry| package_path.join(entry));
169 let requires_entry = matches!(runtime.as_str(), "wasm" | "service" | "embedded");
170 let entry_exists = entry_path
171 .as_ref()
172 .map(|path| path.is_file())
173 .unwrap_or(false);
174 let dependencies = manifest.dependencies.clone();
175 let installed_packages = {
176 let pm = state.package_manager.read().await;
177 pm.list().into_iter().cloned().collect::<Vec<_>>()
178 };
179 let missing_dependencies = crate::package::check_dependencies(&manifest, &installed_packages);
180 let dependency_check_ok = missing_dependencies.is_empty();
181 let entry_check_ok = !requires_entry || entry_exists;
182 let manifest_identity_ok = !manifest.package_info.name.trim().is_empty();
183 let native_runtime_blocked = package_runtime == PackageRuntime::Native;
184 let native_allowed = manifest
185 .package
186 .as_ref()
187 .map(|package| package.native_allowed)
188 .unwrap_or(false);
189 let expected_digest = manifest
190 .package
191 .as_ref()
192 .and_then(|package| package.expected_digest.as_deref())
193 .map(str::trim)
194 .filter(|digest| !digest.is_empty());
195 let native_library_candidate = if package_runtime == PackageRuntime::Native {
196 entry_path.as_ref().and_then(|entry_path| {
197 native_library_candidates(entry_path)
198 .into_iter()
199 .find(|path| path.is_file())
200 })
201 } else {
202 None
203 };
204 let root_check_ok =
205 !request.apply || is_under_explicit_temp_or_managed_root(&state.repo_root, &package_path);
206 let profile = *state.active_profile.read().await;
207 let profile_allowed_for_apply = matches!(
208 profile,
209 crate::app::AppProfile::Developer | crate::app::AppProfile::Trusted
210 );
211 let profile_check_ok = !request.apply || profile_allowed_for_apply;
212 let trusted_native_policy = state.core_policy.check("core.native_execution", profile);
213 let trusted_native_profile_ok = profile == crate::app::AppProfile::Trusted;
214 let confirmation_check_ok = !request.apply || request.confirm;
215 let trusted_native_confirmation_ok = request.confirm;
216 let expected_digest_present = expected_digest.is_some();
217 let native_library_candidate_exists = native_library_candidate.is_some();
218 let ready_for_trusted_native_load = native_runtime_blocked
219 && manifest_identity_ok
220 && dependency_check_ok
221 && trusted_native_profile_ok
222 && trusted_native_policy.allowed
223 && trusted_native_confirmation_ok
224 && native_allowed
225 && expected_digest_present
226 && native_library_candidate_exists;
227 let ready_for_activation = manifest_identity_ok && entry_check_ok && dependency_check_ok;
228 let apply_allowed = ready_for_activation
229 && !native_runtime_blocked
230 && root_check_ok
231 && profile_check_ok
232 && confirmation_check_ok;
233 let runtime_safe_for_controlled_activation_ok = if request.apply {
234 !native_runtime_blocked
235 } else {
236 !native_runtime_blocked || ready_for_trusted_native_load
237 };
238
239 checks.push(serde_json::json!({
240 "name": "manifest_identity_present",
241 "ok": manifest_identity_ok,
242 "package_name": manifest.package_info.name,
243 "version": manifest.package_info.version,
244 }));
245 checks.push(serde_json::json!({
246 "name": "runtime_entry_available",
247 "ok": entry_check_ok,
248 "required": requires_entry,
249 "entry": entry,
250 "path": entry_path.as_ref().map(|path| path.display().to_string()),
251 "exists": entry_exists,
252 }));
253 checks.push(serde_json::json!({
254 "name": "dependencies_satisfied",
255 "ok": dependency_check_ok,
256 "missing": missing_dependencies,
257 }));
258 checks.push(serde_json::json!({
259 "name": "runtime_safe_for_controlled_activation",
260 "ok": runtime_safe_for_controlled_activation_ok,
261 "runtime": runtime,
262 "blocked_reason": if !runtime_safe_for_controlled_activation_ok { Some("native runtime requires trusted native execution flow and is not hot-loaded by this controlled endpoint") } else { None },
263 }));
264 if native_runtime_blocked {
265 checks.push(serde_json::json!({
266 "name": "trusted_native_profile_required",
267 "ok": trusted_native_profile_ok && trusted_native_policy.allowed,
268 "profile": profile.as_str(),
269 "policy_reason": trusted_native_policy.reason,
270 }));
271 checks.push(serde_json::json!({
272 "name": "trusted_native_explicit_confirmation_required",
273 "ok": trusted_native_confirmation_ok,
274 "confirmed": request.confirm,
275 }));
276 checks.push(serde_json::json!({
277 "name": "trusted_native_manifest_allows_native",
278 "ok": native_allowed,
279 "native_allowed": native_allowed,
280 }));
281 checks.push(serde_json::json!({
282 "name": "trusted_native_expected_digest_present",
283 "ok": expected_digest_present,
284 "expected_digest": expected_digest,
285 }));
286 checks.push(serde_json::json!({
287 "name": "trusted_native_library_candidate_exists",
288 "ok": native_library_candidate_exists,
289 "candidate": native_library_candidate.as_ref().map(|path| path.display().to_string()),
290 "entry_path": entry_path.as_ref().map(|path| path.display().to_string()),
291 }));
292 }
293 checks.push(serde_json::json!({
294 "name": "materialized_path_under_explicit_temp_or_managed_root",
295 "ok": root_check_ok,
296 "required_for_apply": true,
297 "temp_root": normalize_path_for_guard(&std::env::temp_dir()).display().to_string(),
298 "managed_root": materialized_managed_root(&state.repo_root).display().to_string(),
299 }));
300 checks.push(serde_json::json!({
301 "name": "developer_or_trusted_profile_required_for_apply",
302 "ok": profile_check_ok,
303 "profile": profile.as_str(),
304 }));
305 checks.push(serde_json::json!({
306 "name": "explicit_confirmation_required_for_apply",
307 "ok": confirmation_check_ok,
308 "confirmed": request.confirm,
309 }));
310
311 let validation_issues = checks
312 .iter()
313 .filter(|check| {
314 if request.apply {
315 return !check
316 .get("ok")
317 .and_then(serde_json::Value::as_bool)
318 .unwrap_or(false);
319 }
320
321 match check.get("name").and_then(serde_json::Value::as_str) {
322 Some("materialized_path_under_explicit_temp_or_managed_root")
323 | Some("developer_or_trusted_profile_required_for_apply")
324 | Some("explicit_confirmation_required_for_apply") => false,
325 _ => !check
326 .get("ok")
327 .and_then(serde_json::Value::as_bool)
328 .unwrap_or(false),
329 }
330 })
331 .filter_map(|check| check.get("name").and_then(serde_json::Value::as_str))
332 .map(|name| format!("activation check failed: {name}"))
333 .collect::<Vec<_>>();
334
335 if native_runtime_blocked && !request.apply {
336 return Ok(Json(serde_json::json!({
337 "status": if ready_for_trusted_native_load { "ready_for_trusted_native_load" } else { "activation_plan_blocked" },
338 "plan_only": true,
339 "metadata_only": true,
340 "activation_performed": false,
341 "mutation_performed": false,
342 "lock_mutation_performed": false,
343 "native_load_performed": false,
344 "package_path": package_path.display().to_string(),
345 "manifest_found": true,
346 "package": {
347 "name": manifest.package_info.name,
348 "version": manifest.package_info.version,
349 "description": manifest.package_info.description,
350 "runtime": runtime,
351 "provides": manifest.resolved_provides(),
352 "has_ui": false,
353 },
354 "activation_required": true,
355 "ready_for_activation": false,
356 "ready_for_trusted_native_load": ready_for_trusted_native_load,
357 "trusted_native": {
358 "native_allowed": native_allowed,
359 "expected_digest_present": expected_digest_present,
360 "expected_digest": expected_digest,
361 "library_candidate": native_library_candidate.as_ref().map(|path| path.display().to_string()),
362 "profile": profile.as_str(),
363 "confirmed": request.confirm,
364 },
365 "requirements": {
366 "profile": "trusted",
367 "confirm": true,
368 "native_allowed": true,
369 "expected_digest": "present",
370 "library_candidate": "exists",
371 "native_load": "not performed by this plan endpoint"
372 },
373 "checks": checks,
374 "validation_issues": validation_issues,
375 "notes": [
376 "This endpoint only plans trusted native activation and never loads native libraries.",
377 "apply=true remains blocked for native runtimes unless a separate trusted native load flow is added."
378 ],
379 })));
380 }
381
382 if request.apply && !apply_allowed {
383 return Ok(Json(serde_json::json!({
384 "status": "activation_apply_blocked",
385 "plan_only": false,
386 "metadata_only": true,
387 "activation_performed": false,
388 "mutation_performed": false,
389 "lock_mutation_performed": false,
390 "package_path": package_path.display().to_string(),
391 "manifest_found": true,
392 "activation_required": true,
393 "ready_for_activation": ready_for_activation,
394 "checks": checks,
395 "validation_issues": validation_issues,
396 "requirements": {
397 "apply_requires_confirm_true": true,
398 "apply_requires_profile": "developer_or_trusted",
399 "apply_requires_materialized_path_under": [
400 normalize_path_for_guard(&std::env::temp_dir()).display().to_string(),
401 materialized_managed_root(&state.repo_root).display().to_string()
402 ],
403 "native_runtime": "blocked; use trusted native execution flow",
404 "missing_entry": "provide the runtime entry declared by package.toml before activation"
405 }
406 })));
407 }
408
409 if request.apply {
410 let package = DiscoveredPackage {
411 manifest: manifest.clone(),
412 dir: normalize_path_for_guard(&package_path),
413 entry_path: entry_path.clone(),
414 runtime: package_runtime.clone(),
415 };
416
417 state.package_manager.write().await.register(PackageInfo {
418 name: manifest.package_info.name.clone(),
419 version: Some(manifest.package_info.version.clone()),
420 overrides: vec![],
421 enabled: true,
422 has_ui: false,
423 description: Some(manifest.package_info.description.clone()),
424 });
425
426 let mut service_registered = false;
427 let mut runtime_started = false;
428 let mut service_auto_start = false;
429 let mut service_start_error: Option<String> = None;
430 if package_runtime == PackageRuntime::Service {
431 let service_config = build_service_config(&package).map_err(|error| {
432 (
433 StatusCode::BAD_REQUEST,
434 Json(serde_json::json!({
435 "error": format!("failed to build service metadata: {error}"),
436 "activation_performed": false,
437 "mutation_performed": false,
438 })),
439 )
440 })?;
441 service_auto_start = service_config.auto_start;
442 state.process_manager.register(service_config).await;
443 service_registered = true;
444
445 if request.start_service {
446 if service_auto_start {
447 service_start_error = Some(
448 "service declares persistent auto-start; controlled activation will not duplicate implicit auto-start".to_string(),
449 );
450 } else {
451 match state
452 .process_manager
453 .start(&manifest.package_info.name)
454 .await
455 {
456 Ok(()) => {
457 runtime_started = true;
458 }
459 Err(error) => {
460 service_start_error = Some(error.to_string());
461 }
462 }
463 }
464 }
465 }
466
467 if manifest
468 .resolved_provides()
469 .contains(&"chat_channel".to_string())
470 {
471 let mut providers = state.chat_providers.write().await;
472 providers.retain(|provider| provider.name != manifest.package_info.name);
473 providers.push(ChatProviderInfo {
474 name: manifest.package_info.name.clone(),
475 endpoint: manifest
476 .resolved_chat_endpoint()
477 .unwrap_or_else(|| "/chat".to_string()),
478 description: manifest.package_info.description.clone(),
479 });
480 providers.sort_by(|left, right| left.name.cmp(&right.name));
481 }
482
483 return Ok(Json(serde_json::json!({
484 "status": "activation_metadata_registered",
485 "plan_only": false,
486 "metadata_only": true,
487 "activation_performed": true,
488 "mutation_performed": true,
489 "lock_mutation_performed": false,
490 "native_load_performed": false,
491 "package_path": package_path.display().to_string(),
492 "manifest_found": true,
493 "package": {
494 "name": manifest.package_info.name,
495 "version": manifest.package_info.version,
496 "description": manifest.package_info.description,
497 "runtime": runtime,
498 "provides": manifest.resolved_provides(),
499 "has_ui": false,
500 },
501 "service_registered": service_registered,
502 "service_start_requested": request.start_service,
503 "service_auto_start": service_auto_start,
504 "runtime_started": runtime_started,
505 "service_start_error": service_start_error,
506 "checks": checks,
507 "validation_issues": validation_issues,
508 "notes": [
509 "Controlled activation registered package metadata only; it did not write lock files, copy packages, load native code, or start services unless start_service=true was explicitly requested for a non-auto-start service runtime.",
510 "Native runtime remains blocked pending the trusted native execution flow."
511 ],
512 })));
513 }
514
515 Ok(Json(serde_json::json!({
516 "status": if ready_for_activation { "activation_plan_ready" } else { "activation_plan_blocked" },
517 "plan_only": true,
518 "metadata_only": true,
519 "activation_performed": false,
520 "mutation_performed": false,
521 "lock_mutation_performed": false,
522 "package_path": package_path.display().to_string(),
523 "manifest_found": true,
524 "package": {
525 "name": manifest.package_info.name,
526 "version": manifest.package_info.version,
527 "description": manifest.package_info.description,
528 "runtime": runtime,
529 "provides": manifest.resolved_provides(),
530 "has_ui": false,
531 },
532 "activation_required": true,
533 "ready_for_activation": ready_for_activation,
534 "requirements": {
535 "core_verification_required": true,
536 "lock_mutation_required": true,
537 "reload_or_runtime_start_required": true,
538 "rollback_snapshot_required": true,
539 "apply_requires_confirm_true": true,
540 "apply_requires_profile": "developer_or_trusted",
541 "apply_requires_materialized_path_under": [
542 normalize_path_for_guard(&std::env::temp_dir()).display().to_string(),
543 materialized_managed_root(&state.repo_root).display().to_string()
544 ],
545 "dependencies": dependencies,
546 },
547 "checks": checks,
548 "validation_issues": validation_issues,
549 "notes": [
550 "apply defaults to false; without apply=true this endpoint is metadata-only and never loads, reloads, installs, activates, deletes, or writes package state.",
551 "apply=true requires confirm=true, developer profile, a materialized path under the explicit temp/managed root, and a non-native runtime."
552 ],
553 })))
554}