1use std::collections::{BTreeSet, HashMap, HashSet};
10use std::panic::AssertUnwindSafe;
11use std::path::{Path as FsPath, PathBuf};
12use std::time::{Duration, Instant};
13
14use axum::{
15 body::{Body, Bytes},
16 extract::{
17 ws::{Message, WebSocket, WebSocketUpgrade},
18 Path, Query, Request, State,
19 },
20 http::{header, StatusCode},
21 middleware::{self, Next},
22 response::{sse::Event as SseEvent, sse::Sse, IntoResponse, Response},
23 routing::{get, patch, post},
24 Json, Router,
25};
26use futures_util::{FutureExt, SinkExt, Stream, StreamExt};
27use regex::Regex;
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30use tokio::io::AsyncReadExt;
31use tokio::sync::broadcast::error::RecvError;
32use tower_http::trace::TraceLayer;
33
34use nyx_agent_core::report::{
35 build_bundle, build_run_card, render_html as render_run_card_html,
36 render_markdown as render_run_card_markdown, verify_sha256 as verify_bundle_sha256,
37 BundleError, BundleManifest, RunCard, RunCardError,
38};
39use nyx_agent_core::store::{
40 CandidateFindingRecord, CandidateStatus, ChainRecord, FindingFilter, FindingRecord,
41 ProjectIntegrationInsert, ProjectIntegrationPatch, ProjectPatch, ProjectPatchOption,
42 ProjectRecord, RepoRecord, RunRecord,
43};
44use nyx_agent_core::{
45 now_epoch_ms, parse_git_auth, run_event_log_path, safe_run_log_segment, AiRuntime, IngestError,
46 SandboxBackend, ACCOUNT_AI_ANTHROPIC, ACCOUNT_AI_LOCAL_LLM,
47};
48use nyx_agent_types::api::{
49 AgentTraceRow, DoctorCheck, DoctorRequest, DoctorResponse, FindingDiffStatus, FindingWithDiff,
50 HealthResponse, QuarantineItem, QuarantineKind, RunFindingsResponse, SetupRequest,
51 SetupStatusResponse,
52};
53use nyx_agent_types::business_logic::{
54 business_logic_template_by_id, business_logic_template_metadata, BusinessLogicRunSummary,
55 BusinessLogicTemplateMetadata,
56};
57use nyx_agent_types::event::{AgentEvent, AiEvent, ReproEvent, RunEvent, SandboxEvent};
58use nyx_agent_types::integration::{
59 CreateProjectIntegrationRequest, PatchProjectIntegrationRequest, ProjectIntegrationRecord,
60 TestProjectIntegrationResponse,
61};
62use nyx_agent_types::product::{
63 ProjectLaunchProfile, ProjectLaunchProfileInput, ProjectSetupError,
64 ProjectSetupJobListResponse, ProjectSetupJobRecord, ProjectSetupPhase, ProjectSetupRequest,
65 ProjectSetupResponse, ProjectSetupStartResponse, ProjectSetupVerification,
66 ProjectSetupVerificationStatus, SeedSetupPlan, SeedSetupResponse, StartPentestRequest,
67 StartPentestResponse, TestLaunchTargetRequest, TestLaunchTargetResponse,
68};
69use nyx_agent_types::project::{
70 AuthSetupError, AuthSetupJobRecord, AuthSetupPhase, AuthSetupRequest, AuthSetupResponse,
71 AuthSetupStartResponse, AuthSetupVerification, AuthSetupVerificationStatus,
72 CreateProjectRequest, PatchProjectRequest, ProjectAuthMode, ProjectAuthOwnedObject,
73 ProjectAuthProfile, ProjectOtpSourceConfig, ProjectOtpSourceKind, ProjectRuntimeEnvVar,
74 ProjectRuntimeProfile, TriStateJson, TriStateProjectRuntimeProfile,
75};
76use nyx_agent_types::repo::{
77 CreateRepoRequest, PatchRepoRequest, TestRepoRequest, TestRepoResponse,
78};
79
80use crate::state::{
81 ApiError, AuthSetupAgentError, AuthSetupAgentOutput, AuthSetupAgentRequest,
82 ProjectSetupAgentError, ProjectSetupAgentRequest, RemediationAgentRequest, RemediationJobError,
83 ScanRunOverrides, ScanTriggerSource, SeedSetupAgentError, SeedSetupAgentRequest, ServerState,
84};
85
86pub fn build_router(state: ServerState) -> Router {
88 Router::new()
89 .route("/api/v1/health", get(health))
90 .route("/api/v1/setup/status", get(setup_status))
91 .route("/api/v1/setup", post(submit_setup))
92 .route("/api/v1/setup/doctor", post(setup_doctor))
93 .route("/api/v1/business-logic/templates", get(business_logic_templates))
94 .route("/api/v1/launch-target/test", post(test_launch_target))
95 .route("/api/v1/projects", get(list_projects).post(create_project))
96 .route(
97 "/api/v1/projects/{project_id}",
98 get(get_project).patch(patch_project).delete(delete_project),
99 )
100 .route("/api/v1/projects/{project_id}/auth/auto-setup", post(start_auth_auto_setup_project))
101 .route(
102 "/api/v1/projects/{project_id}/auth/auto-setup/{job_id}",
103 get(get_auth_auto_setup_job),
104 )
105 .route(
106 "/api/v1/projects/{project_id}/setup/ai",
107 get(list_ai_project_setup_jobs).post(start_ai_project_setup),
108 )
109 .route("/api/v1/projects/{project_id}/setup/ai/{job_id}", get(get_ai_project_setup_job))
110 .route(
111 "/api/v1/projects/{project_id}/repos",
112 get(list_project_repos).post(create_project_repo),
113 )
114 .route("/api/v1/projects/{project_id}/repos/test", post(test_repo_connectivity))
115 .route(
116 "/api/v1/projects/{project_id}/repos/{name}",
117 get(get_project_repo).patch(patch_project_repo).delete(delete_project_repo),
118 )
119 .route("/api/v1/projects/{project_id}/scan", post(scan_project))
120 .route("/api/v1/projects/{project_id}/pentest", post(start_pentest_project))
121 .route(
122 "/api/v1/projects/{project_id}/integrations",
123 get(list_project_integrations).post(create_project_integration),
124 )
125 .route(
126 "/api/v1/projects/{project_id}/integrations/{integration_id}",
127 get(get_project_integration)
128 .patch(patch_project_integration)
129 .delete(delete_project_integration),
130 )
131 .route(
132 "/api/v1/projects/{project_id}/integrations/{integration_id}/test",
133 post(test_project_integration),
134 )
135 .route(
136 "/api/v1/projects/{project_id}/launch-profile/default",
137 get(get_default_launch_profile).patch(patch_default_launch_profile),
138 )
139 .route("/api/v1/projects/{project_id}/vulnerabilities", get(project_vulnerabilities))
140 .route("/api/v1/runs", get(list_runs))
141 .route("/api/v1/runs/{id}", get(get_run))
142 .route("/api/v1/runs/{id}/findings", get(findings_for_run))
143 .route("/api/v1/runs/{id}/signals", get(signals_for_run))
144 .route("/api/v1/runs/{id}/candidates", get(candidates_for_run))
145 .route("/api/v1/runs/{id}/route-model", get(route_model_for_run))
146 .route("/api/v1/runs/{id}/environment-runs", get(environment_runs_for_run))
147 .route("/api/v1/runs/{id}/events.jsonl", get(run_event_log))
148 .route("/api/v1/runs/{id}/verification-attempts", get(verification_attempts_for_run))
149 .route("/api/v1/runs/{id}/authz-matrix", get(authz_matrix_for_run))
150 .route("/api/v1/runs/{id}/exploration-memory", get(exploration_memory_for_run))
151 .route("/api/v1/runs/{id}/vulnerabilities", get(run_vulnerabilities))
152 .route("/api/v1/runs/{id}/summary", get(run_summary))
153 .route("/api/v1/runs/{id}/business-logic", get(run_business_logic))
154 .route("/api/v1/runs/{id}/summary.md", get(run_summary_markdown))
155 .route("/api/v1/runs/{id}/summary.html", get(run_summary_html))
156 .route("/api/v1/findings", get(list_findings))
157 .route("/api/v1/vulnerabilities", get(list_vulnerabilities))
158 .route("/api/v1/vulnerabilities/status", patch(bulk_update_vulnerability_status))
159 .route("/api/v1/vulnerabilities/{id}", get(get_vulnerability))
160 .route("/api/v1/vulnerabilities/{id}/fix", post(start_vulnerability_fix))
161 .route("/api/v1/vulnerabilities/{id}/fix/{job_id}", get(get_vulnerability_fix_job))
162 .route("/api/v1/vulnerabilities/{id}/status", patch(update_vulnerability_status))
163 .route("/api/v1/findings/{id}", get(get_finding))
164 .route("/api/v1/findings/{id}/repro-bundle", post(create_repro_bundle))
165 .route("/api/v1/findings/{id}/repro-bundle.tar", get(download_repro_bundle))
166 .route("/api/v1/findings/{id}/replay", post(replay_repro_bundle))
167 .route("/api/v1/chains", get(list_chains))
168 .route("/api/v1/chains/{id}", get(get_chain))
169 .route("/api/v1/findings/{id}/traces", get(traces_for_finding))
170 .route("/api/v1/traces/{id}", get(get_trace))
171 .route("/api/v1/quarantine", get(list_quarantine))
172 .route("/api/v1/quarantine/{id}/promote", post(promote_quarantine))
173 .route("/api/v1/quarantine/{id}/dismiss", post(dismiss_quarantine))
174 .route("/api/v1/events", get(events_ws))
175 .route("/webhook/git", post(crate::webhook::webhook_git))
176 .layer(middleware::from_fn_with_state(state.clone(), auth_layer))
177 .layer(TraceLayer::new_for_http())
178 .with_state(state)
179}
180
181async fn business_logic_templates() -> Json<Vec<BusinessLogicTemplateMetadata>> {
182 Json(business_logic_template_metadata())
183}
184
185async fn auth_layer(
193 State(state): State<ServerState>,
194 req: Request,
195 next: Next,
196) -> Result<Response, ApiError> {
197 if !state.auth.is_enforced() {
198 return Ok(next.run(req).await);
199 }
200 let path = req.uri().path();
201 if is_always_open(path) {
202 return Ok(next.run(req).await);
203 }
204 if is_setup_status_path(path) {
205 return Ok(next.run(req).await);
206 }
207 if is_setup_path(path) && !state.setup.is_complete() {
208 return Ok(next.run(req).await);
209 }
210 let token = state.auth.token.as_deref().unwrap_or_default();
211 if check_bearer(&req, token) || check_query_token(&req, token) {
212 return Ok(next.run(req).await);
213 }
214 Err(ApiError::Unauthorized)
215}
216
217fn is_always_open(path: &str) -> bool {
218 path == "/api/v1/health" || path == "/webhook/git"
221}
222
223fn is_setup_path(path: &str) -> bool {
224 matches!(path, "/api/v1/setup" | "/api/v1/setup/status" | "/api/v1/setup/doctor")
225}
226
227fn is_setup_status_path(path: &str) -> bool {
228 path == "/api/v1/setup/status"
229}
230
231fn check_bearer(req: &Request, expected: &str) -> bool {
232 let Some(value) = req.headers().get(axum::http::header::AUTHORIZATION) else {
233 return false;
234 };
235 let Ok(text) = value.to_str() else { return false };
236 let trimmed = text.trim();
237 let Some(rest) = trimmed.strip_prefix("Bearer ") else { return false };
238 constant_eq(rest.trim(), expected)
239}
240
241fn check_query_token(req: &Request, expected: &str) -> bool {
242 let Some(q) = req.uri().query() else { return false };
243 for pair in q.split('&') {
244 if let Some(rest) = pair.strip_prefix("token=") {
245 let decoded = urlencoded_decode(rest);
246 if constant_eq(&decoded, expected) {
247 return true;
248 }
249 }
250 }
251 false
252}
253
254fn constant_eq(a: &str, b: &str) -> bool {
255 if a.len() != b.len() {
256 return false;
257 }
258 let mut diff = 0u8;
259 for (x, y) in a.bytes().zip(b.bytes()) {
260 diff |= x ^ y;
261 }
262 diff == 0
263}
264
265fn urlencoded_decode(s: &str) -> String {
266 let mut out = String::with_capacity(s.len());
267 let bytes = s.as_bytes();
268 let mut i = 0;
269 while i < bytes.len() {
270 match bytes[i] {
271 b'+' => {
272 out.push(' ');
273 i += 1;
274 }
275 b'%' if i + 2 < bytes.len() => {
276 let hi = hex_digit(bytes[i + 1]);
277 let lo = hex_digit(bytes[i + 2]);
278 if let (Some(h), Some(l)) = (hi, lo) {
279 out.push((h * 16 + l) as char);
280 i += 3;
281 } else {
282 out.push(bytes[i] as char);
283 i += 1;
284 }
285 }
286 b => {
287 out.push(b as char);
288 i += 1;
289 }
290 }
291 }
292 out
293}
294
295fn hex_digit(b: u8) -> Option<u8> {
296 match b {
297 b'0'..=b'9' => Some(b - b'0'),
298 b'a'..=b'f' => Some(b - b'a' + 10),
299 b'A'..=b'F' => Some(b - b'A' + 10),
300 _ => None,
301 }
302}
303
304async fn health() -> impl IntoResponse {
305 Json(HealthResponse {
306 status: "ok".to_string(),
307 version: env!("CARGO_PKG_VERSION").to_string(),
308 })
309}
310
311async fn test_launch_target(
312 Json(req): Json<TestLaunchTargetRequest>,
313) -> Result<Json<TestLaunchTargetResponse>, ApiError> {
314 let raw = req.url.trim();
315 let url = local_http_url(raw).ok_or_else(|| {
316 ApiError::BadRequest(
317 "app URL must be local http:// or https:// (localhost, 127.0.0.1, or ::1)".to_string(),
318 )
319 })?;
320 let timeout = Duration::from_secs(req.timeout_seconds.unwrap_or(3).clamp(1, 15));
321 let started = Instant::now();
322 let client = reqwest::Client::builder()
323 .timeout(timeout)
324 .build()
325 .map_err(|e| ApiError::Internal(format!("build URL test client: {e}")))?;
326
327 let response = match client.get(url.clone()).send().await {
328 Ok(resp) => {
329 let status = resp.status();
330 let ok = status.is_success();
331 TestLaunchTargetResponse {
332 ok,
333 url: url.to_string(),
334 message: if ok {
335 format!("Reachable in {}ms", started.elapsed().as_millis())
336 } else {
337 format!("Responded with HTTP {}", status.as_u16())
338 },
339 status: Some(status.as_u16()),
340 elapsed_ms: millis_u64(started.elapsed()),
341 }
342 }
343 Err(err) => TestLaunchTargetResponse {
344 ok: false,
345 url: url.to_string(),
346 message: if err.is_timeout() {
347 format!("Timed out after {}s", timeout.as_secs())
348 } else {
349 format!("Could not reach app: {err}")
350 },
351 status: None,
352 elapsed_ms: millis_u64(started.elapsed()),
353 },
354 };
355
356 Ok(Json(response))
357}
358
359fn millis_u64(duration: Duration) -> u64 {
360 duration.as_millis().min(u128::from(u64::MAX)) as u64
361}
362
363async fn setup_status(State(s): State<ServerState>) -> Result<Json<SetupStatusResponse>, ApiError> {
366 let cfg = s.setup.config.read().await;
367 Ok(Json(SetupStatusResponse {
368 complete: s.setup.is_complete(),
369 config_path: s.setup.config_path.display().to_string(),
370 ai_runtime: ai_runtime_label(cfg.ai.runtime).to_string(),
371 ai_provider: cfg.ai.provider.clone(),
372 ai_model: cfg.ai.model.clone(),
373 ai_api_base: cfg.ai.api_base.clone(),
374 default_run_budget_usd_micros: cfg.ai.default_run_budget_usd_micros,
375 sandbox_backend: sandbox_backend_label(cfg.sandbox.backend).to_string(),
376 sandbox_enabled: cfg.sandbox.enabled,
377 sandbox_allow_network: cfg.sandbox.allow_network,
378 ui_listen_addr: cfg.ui.listen_addr.clone(),
379 ui_open_browser: cfg.ui.open_browser,
380 log_level: cfg.general.log_level.clone(),
381 state_dir: cfg.general.state_dir.as_ref().map(|p| p.display().to_string()),
382 max_parallel_scans: cfg.performance.max_parallel_scans,
383 scan_timeout_secs: cfg.performance.scan_timeout_secs,
384 }))
385}
386
387fn ai_runtime_label(r: AiRuntime) -> &'static str {
388 match r {
389 AiRuntime::None => "none",
390 AiRuntime::Anthropic => "anthropic",
391 AiRuntime::LocalLlm => "local-llm",
392 AiRuntime::ClaudeCode => "claude-code",
393 AiRuntime::Codex => "codex",
394 }
395}
396
397fn sandbox_backend_label(b: SandboxBackend) -> &'static str {
398 match b {
399 SandboxBackend::Auto => "auto",
400 SandboxBackend::Process => "process",
401 SandboxBackend::Birdcage => "birdcage",
402 SandboxBackend::Libkrun => "libkrun",
403 SandboxBackend::Firecracker => "firecracker",
404 SandboxBackend::Docker => "docker",
405 }
406}
407
408#[derive(Debug, Serialize)]
409struct SetupResponse {
410 ok: bool,
411 config_path: String,
412}
413
414async fn submit_setup(
415 State(s): State<ServerState>,
416 Json(req): Json<SetupRequest>,
417) -> Result<Json<SetupResponse>, ApiError> {
418 if !req.i_own_this {
419 return Err(ApiError::BadRequest(
420 "i_own_this must be true before the daemon will write a config".to_string(),
421 ));
422 }
423
424 let ai_runtime = parse_ai_runtime(&req.ai_runtime)?;
425 let sandbox_backend = parse_sandbox_backend(&req.sandbox_backend)?;
426 let default_run_budget_usd_micros = parse_optional_positive_micros(
427 req.default_run_budget_usd_micros,
428 "default_run_budget_usd_micros",
429 )?;
430 let mut cfg = s.setup.config.read().await.clone();
431 let anthropic_api_key =
432 req.anthropic_api_key.as_deref().map(str::trim).filter(|v| !v.is_empty());
433 let local_llm_url = req.local_llm_url.as_deref().map(str::trim).filter(|v| !v.is_empty());
434
435 if matches!(ai_runtime, AiRuntime::Anthropic) && anthropic_api_key.is_none() {
436 let has_existing_key = s
437 .setup
438 .secrets
439 .get(ACCOUNT_AI_ANTHROPIC)
440 .map_err(|e| ApiError::Internal(format!("read Anthropic key: {e}")))?
441 .is_some();
442 if !has_existing_key {
443 return Err(ApiError::BadRequest(
444 "anthropic_api_key is required when ai_runtime = \"anthropic\"".to_string(),
445 ));
446 }
447 }
448 if matches!(ai_runtime, AiRuntime::LocalLlm) && local_llm_url.is_none() {
449 let missing_existing_url =
450 cfg.ai.api_base.as_deref().map(str::trim).unwrap_or("").is_empty();
451 if missing_existing_url {
452 return Err(ApiError::BadRequest(
453 "local_llm_url is required when ai_runtime = \"local-llm\"".to_string(),
454 ));
455 }
456 }
457
458 if let Some(key) = anthropic_api_key {
463 s.setup
464 .secrets
465 .set(ACCOUNT_AI_ANTHROPIC, key)
466 .map_err(|e| ApiError::Internal(format!("store Anthropic key: {e}")))?;
467 } else if matches!(
468 ai_runtime,
469 AiRuntime::None | AiRuntime::LocalLlm | AiRuntime::ClaudeCode | AiRuntime::Codex
470 ) {
471 let _ = s.setup.secrets.delete(ACCOUNT_AI_ANTHROPIC);
472 }
473 if let Some(tok) = req.local_llm_token.as_deref().filter(|v| !v.trim().is_empty()) {
474 s.setup
475 .secrets
476 .set(ACCOUNT_AI_LOCAL_LLM, tok.trim())
477 .map_err(|e| ApiError::Internal(format!("store local-llm token: {e}")))?;
478 } else if !matches!(ai_runtime, AiRuntime::LocalLlm) {
479 let _ = s.setup.secrets.delete(ACCOUNT_AI_LOCAL_LLM);
480 }
481
482 cfg.ai.runtime = ai_runtime;
483 cfg.ai.provider = match ai_runtime {
484 AiRuntime::None => None,
485 AiRuntime::Anthropic => Some("anthropic".to_string()),
486 AiRuntime::LocalLlm => Some("local-llm".to_string()),
487 AiRuntime::ClaudeCode => Some("claude-code".to_string()),
488 AiRuntime::Codex => Some("codex".to_string()),
489 };
490 cfg.ai.api_base = match ai_runtime {
491 AiRuntime::LocalLlm => {
492 local_llm_url.map(str::to_string).or_else(|| cfg.ai.api_base.clone())
493 }
494 _ => cfg.ai.api_base.clone(),
495 };
496 cfg.ai.default_run_budget_usd_micros = default_run_budget_usd_micros;
497 cfg.sandbox.backend = sandbox_backend;
498
499 let rendered =
500 cfg.to_toml_string().map_err(|e| ApiError::Internal(format!("render toml: {e}")))?;
501 write_config_atomic(&s.setup.config_path, &rendered)
502 .map_err(|e| ApiError::Internal(format!("write {}: {e}", s.setup.config_path.display())))?;
503 *s.setup.config.write().await = cfg;
504 s.setup.mark_complete();
505 Ok(Json(SetupResponse { ok: true, config_path: s.setup.config_path.display().to_string() }))
506}
507
508fn parse_ai_runtime(raw: &str) -> Result<AiRuntime, ApiError> {
509 match raw.trim() {
510 "none" => Ok(AiRuntime::None),
511 "anthropic" => Ok(AiRuntime::Anthropic),
512 "local-llm" => Ok(AiRuntime::LocalLlm),
513 "claude-code" => Ok(AiRuntime::ClaudeCode),
514 "codex" => Ok(AiRuntime::Codex),
515 other => Err(ApiError::BadRequest(format!("unknown ai_runtime `{other}`"))),
516 }
517}
518
519fn parse_optional_positive_micros(raw: Option<i64>, field: &str) -> Result<Option<i64>, ApiError> {
520 match raw {
521 Some(v) if v <= 0 => {
522 Err(ApiError::BadRequest(format!("{field} must be a positive integer or null")))
523 }
524 other => Ok(other),
525 }
526}
527
528fn parse_sandbox_backend(raw: &str) -> Result<SandboxBackend, ApiError> {
529 match raw.trim() {
530 "auto" => Ok(SandboxBackend::Auto),
531 "process" => Ok(SandboxBackend::Process),
532 "birdcage" => Ok(SandboxBackend::Birdcage),
533 "libkrun" => Ok(SandboxBackend::Libkrun),
534 "firecracker" => Ok(SandboxBackend::Firecracker),
535 "docker" => Ok(SandboxBackend::Docker),
536 other => Err(ApiError::BadRequest(format!("unknown sandbox_backend `{other}`"))),
537 }
538}
539
540fn write_config_atomic(path: &std::path::Path, body: &str) -> std::io::Result<()> {
541 use std::io::Write;
542 let parent = path.parent().unwrap_or(std::path::Path::new("."));
543 std::fs::create_dir_all(parent)?;
544 let tmp = path.with_extension("toml.tmp");
545 {
546 let mut f =
547 std::fs::OpenOptions::new().write(true).create(true).truncate(true).open(&tmp)?;
548 f.write_all(body.as_bytes())?;
549 f.flush()?;
550 }
551 #[cfg(unix)]
552 {
553 use std::os::unix::fs::PermissionsExt;
554 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
555 }
556 std::fs::rename(&tmp, path)
557}
558
559async fn setup_doctor(
564 State(s): State<ServerState>,
565 Json(req): Json<DoctorRequest>,
566) -> Result<Json<DoctorResponse>, ApiError> {
567 let mut checks = Vec::new();
568 checks.push(DoctorCheck {
569 name: "state-dir".to_string(),
570 passed: s.setup.config_path.parent().is_some(),
571 message: "state directory writable".to_string(),
572 });
573 let ai_runtime = parse_ai_runtime(&req.ai_runtime)?;
574 match ai_runtime {
575 AiRuntime::None => checks.push(DoctorCheck {
576 name: "ai".to_string(),
577 passed: true,
578 message: "AI disabled: static pass only".to_string(),
579 }),
580 AiRuntime::Anthropic => checks.push(anthropic_doctor_check(&s, &req)),
581 AiRuntime::LocalLlm => checks.push(local_llm_doctor_check(&s, &req).await),
582 AiRuntime::ClaudeCode => {
583 let found = which_on_path("claude");
584 checks.push(DoctorCheck {
585 name: "ai-claude-code".to_string(),
586 passed: found.is_some(),
587 message: match found {
588 Some(p) => format!(
589 "Claude Code binary found at {p}; optional local CLI adapter enabled. Use provider-authorized credentials; Nyx Agent does not include or resell model access."
590 ),
591 None => "`claude` not found on PATH; install Claude Code only if you want the optional local CLI adapter".to_string(),
592 },
593 });
594 }
595 AiRuntime::Codex => checks.push(codex_doctor_check().await),
596 }
597
598 let sandbox_backend = parse_sandbox_backend(&req.sandbox_backend)?;
599 let (sandbox_pass, sandbox_msg) = sandbox_backend_probe(sandbox_backend);
600 checks.push(DoctorCheck {
601 name: "sandbox".to_string(),
602 passed: sandbox_pass,
603 message: sandbox_msg,
604 });
605
606 Ok(Json(DoctorResponse { checks }))
607}
608
609fn anthropic_doctor_check(s: &ServerState, req: &DoctorRequest) -> DoctorCheck {
610 let provided = req.anthropic_api_key.as_deref().map(str::trim).is_some_and(|v| !v.is_empty());
611 if provided {
612 return DoctorCheck {
613 name: "ai-anthropic".to_string(),
614 passed: true,
615 message: "Anthropic API key provided for this check; save settings to store it"
616 .to_string(),
617 };
618 }
619
620 match s.setup.secrets.get(ACCOUNT_AI_ANTHROPIC) {
621 Ok(Some(_)) => DoctorCheck {
622 name: "ai-anthropic".to_string(),
623 passed: true,
624 message: "Anthropic API key found in the OS keychain".to_string(),
625 },
626 Ok(None) => DoctorCheck {
627 name: "ai-anthropic".to_string(),
628 passed: false,
629 message: "Anthropic API key is not set; enter one before saving this runtime"
630 .to_string(),
631 },
632 Err(e) => DoctorCheck {
633 name: "ai-anthropic".to_string(),
634 passed: false,
635 message: format!("Could not read Anthropic API key from the OS keychain: {e}"),
636 },
637 }
638}
639
640async fn local_llm_doctor_check(s: &ServerState, req: &DoctorRequest) -> DoctorCheck {
641 let provided_url = req.local_llm_url.as_deref().map(str::trim).filter(|v| !v.is_empty());
642 let configured_url = if provided_url.is_none() {
643 let cfg = s.setup.config.read().await;
644 cfg.ai.api_base.clone()
645 } else {
646 None
647 };
648 let url = provided_url.or_else(|| configured_url.as_deref().map(str::trim));
649 match url.filter(|v| !v.is_empty()) {
650 Some(url) => DoctorCheck {
651 name: "ai-local-llm".to_string(),
652 passed: true,
653 message: format!(
654 "Local OpenAI-compatible endpoint configured at {url}; one-shot helpers enabled. Set [ai].model if the server requires a specific model id."
655 ),
656 },
657 None => DoctorCheck {
658 name: "ai-local-llm".to_string(),
659 passed: false,
660 message: "Local LLM endpoint is not set; enter a /v1 URL before saving this runtime"
661 .to_string(),
662 },
663 }
664}
665
666async fn codex_doctor_check() -> DoctorCheck {
667 let Some(path) = which_on_path("codex") else {
668 return DoctorCheck {
669 name: "ai-codex".to_string(),
670 passed: false,
671 message: "`codex` not found on PATH; install Codex CLI only if you want the optional local CLI adapter".to_string(),
672 };
673 };
674
675 let mut cmd = tokio::process::Command::new(&path);
676 cmd.arg("doctor")
677 .arg("--json")
678 .stdout(std::process::Stdio::piped())
679 .stderr(std::process::Stdio::piped());
680 let output = match tokio::time::timeout(Duration::from_secs(5), cmd.output()).await {
681 Ok(Ok(output)) => output,
682 Ok(Err(err)) => {
683 return DoctorCheck {
684 name: "ai-codex".to_string(),
685 passed: false,
686 message: format!("Codex binary found at {path}, but doctor failed to run: {err}"),
687 };
688 }
689 Err(_) => {
690 return DoctorCheck {
691 name: "ai-codex".to_string(),
692 passed: false,
693 message: format!("Codex binary found at {path}, but doctor timed out"),
694 };
695 }
696 };
697
698 let stdout = String::from_utf8_lossy(&output.stdout);
699 let parsed = serde_json::from_str::<serde_json::Value>(&stdout);
700 let Ok(report) = parsed else {
701 let stderr = String::from_utf8_lossy(&output.stderr);
702 let detail = if stderr.trim().is_empty() { stdout.trim() } else { stderr.trim() };
703 return DoctorCheck {
704 name: "ai-codex".to_string(),
705 passed: false,
706 message: format!(
707 "Codex binary found at {path}, but doctor did not return JSON: {detail}"
708 ),
709 };
710 };
711
712 let version = report.get("codexVersion").and_then(|v| v.as_str()).unwrap_or("unknown version");
713 let overall = report.get("overallStatus").and_then(|v| v.as_str()).unwrap_or("unknown");
714 let auth = doctor_check_status(&report, "auth.credentials");
715 let install = doctor_check_status(&report, "installation");
716 let runtime = doctor_check_status(&report, "runtime.provenance");
717 let passed = matches!(auth, Some("ok")) && matches!(install, Some("ok"));
718 let auth_msg = match auth {
719 Some("ok") => "auth configured",
720 Some(other) => other,
721 None => "auth status unavailable",
722 };
723 let runtime_msg = match runtime {
724 Some("ok") => "runtime healthy",
725 Some(other) => other,
726 None => "runtime status unavailable",
727 };
728 DoctorCheck {
729 name: "ai-codex".to_string(),
730 passed,
731 message: format!(
732 "Codex CLI {version} found at {path}; {auth_msg}; {runtime_msg}; doctor overall {overall}; optional local CLI adapter enabled. Use provider-authorized credentials."
733 ),
734 }
735}
736
737fn doctor_check_status<'a>(report: &'a serde_json::Value, id: &str) -> Option<&'a str> {
738 report.get("checks")?.get(id)?.get("status")?.as_str()
739}
740
741fn which_on_path(bin: &str) -> Option<String> {
742 let path = std::env::var_os("PATH")?;
743 for entry in std::env::split_paths(&path) {
744 let candidate = entry.join(bin);
745 if candidate.is_file() {
746 return Some(candidate.display().to_string());
747 }
748 }
749 None
750}
751
752fn sandbox_backend_probe(b: SandboxBackend) -> (bool, String) {
753 if matches!(b, SandboxBackend::Auto) {
759 return (true, "Backend will be chosen at scan time".to_string());
760 }
761 let kind = match b {
762 SandboxBackend::Process => nyx_agent_sandbox::BackendKind::Process,
763 SandboxBackend::Birdcage => nyx_agent_sandbox::BackendKind::Birdcage,
764 SandboxBackend::Libkrun => nyx_agent_sandbox::BackendKind::Libkrun,
765 SandboxBackend::Firecracker => nyx_agent_sandbox::BackendKind::Firecracker,
766 SandboxBackend::Docker => nyx_agent_sandbox::BackendKind::Docker,
767 SandboxBackend::Auto => unreachable!("Auto handled above"),
768 };
769 match nyx_agent_sandbox::probe(kind) {
770 Ok(()) => (true, format!("{} ready on this host", kind.as_str())),
771 Err(err) => (false, err.to_string()),
772 }
773}
774
775async fn list_projects(State(s): State<ServerState>) -> Result<Json<Vec<ProjectRecord>>, ApiError> {
778 let rows = s.store.projects().list().await?;
779 Ok(Json(rows))
780}
781
782async fn create_project(
783 State(s): State<ServerState>,
784 Json(req): Json<CreateProjectRequest>,
785) -> Result<Json<ProjectRecord>, ApiError> {
786 let name = req.name.trim();
787 if name.is_empty() {
788 return Err(ApiError::BadRequest("name is required".to_string()));
789 }
790 if s.store.projects().get_by_name(name).await?.is_some() {
791 return Err(ApiError::BadRequest(format!("project `{name}` already exists")));
792 }
793 let id = format!("proj-{}", uuid_like(name, now_epoch_ms()));
794 let env_config_json = match req.env_config.as_ref() {
795 Some(v) => Some(serde_json::to_string(v).map_err(|e| {
796 ApiError::BadRequest(format!("env_config must serialize to JSON: {e}"))
797 })?),
798 None => None,
799 };
800 let mut runtime_profile = req.runtime_profile;
801 let target_base_url =
802 normalize_create_target_base_url(req.target_base_url, &mut runtime_profile)?;
803 let launch_profile = req.default_launch_profile.or_else(|| {
804 runtime_profile
805 .as_ref()
806 .map(|profile| launch_profile_input_from_runtime(profile, target_base_url.as_deref()))
807 });
808 let runtime_profile_json = match runtime_profile.as_ref() {
809 Some(v) => Some(serde_json::to_string(v).map_err(|e| {
810 ApiError::BadRequest(format!("runtime_profile must serialize to JSON: {e}"))
811 })?),
812 None => None,
813 };
814 let _rec = s
815 .store
816 .projects()
817 .create_with_runtime_profile(
818 &id,
819 name,
820 req.description.as_deref(),
821 target_base_url.as_deref(),
822 env_config_json.as_deref(),
823 runtime_profile_json.as_deref(),
824 now_epoch_ms(),
825 )
826 .await?;
827 if let Some(input) = launch_profile.as_ref() {
828 s.store.launch_profiles().upsert_default(&id, input, now_epoch_ms()).await?;
829 }
830 let rec = s
831 .store
832 .projects()
833 .get(&id)
834 .await?
835 .ok_or_else(|| ApiError::Internal("project vanished after create".to_string()))?;
836 Ok(Json(rec))
837}
838
839async fn get_project(
840 State(s): State<ServerState>,
841 Path(id): Path<String>,
842) -> Result<Json<ProjectRecord>, ApiError> {
843 s.store
844 .projects()
845 .get(&id)
846 .await?
847 .map(Json)
848 .ok_or_else(|| ApiError::NotFound(format!("project `{id}` not found")))
849}
850
851async fn patch_project(
852 State(s): State<ServerState>,
853 Path(id): Path<String>,
854 Json(req): Json<PatchProjectRequest>,
855) -> Result<Json<ProjectRecord>, ApiError> {
856 let owned_env_json: Option<String> = match &req.env_config {
859 TriStateJson::Value(v) => Some(serde_json::to_string(v).map_err(|e| {
860 ApiError::BadRequest(format!("env_config must serialize to JSON: {e}"))
861 })?),
862 _ => None,
863 };
864 let env_config_patch: ProjectPatchOption<Option<String>> = match &req.env_config {
865 TriStateJson::Unset => ProjectPatchOption::Unset,
866 TriStateJson::Null => ProjectPatchOption::Set(None),
867 TriStateJson::Value(_) => ProjectPatchOption::Set(owned_env_json),
868 };
869 let mut target_base_url_patch = project_patch_for(&req.target_base_url);
870 let mut launch_profile_from_runtime: Option<ProjectLaunchProfileInput> = None;
871 let runtime_profile_patch: ProjectPatchOption<Option<String>> = match req.runtime_profile {
872 TriStateProjectRuntimeProfile::Unset => ProjectPatchOption::Unset,
873 TriStateProjectRuntimeProfile::Null => ProjectPatchOption::Set(None),
874 TriStateProjectRuntimeProfile::Value(mut profile) => {
875 match &req.target_base_url {
876 Some(Some(target)) => {
877 let target = normalize_optional_string(Some(target.as_str()));
878 if let (Some(profile_target), Some(top_level_target)) = (
879 normalize_optional_string(profile.target_base_url.as_deref()),
880 target.as_deref(),
881 ) {
882 if profile_target != top_level_target {
883 return Err(ApiError::BadRequest(
884 "runtime_profile.target_base_url must match target_base_url"
885 .to_string(),
886 ));
887 }
888 }
889 profile.target_base_url = target;
890 }
891 Some(None) => {
892 profile.target_base_url = None;
893 }
894 None => {
895 if let Some(profile_target) =
896 normalize_optional_string(profile.target_base_url.as_deref())
897 {
898 target_base_url_patch = ProjectPatchOption::Set(Some(profile_target));
899 }
900 }
901 }
902 let runtime_profile_json = serde_json::to_string(&profile).map_err(|e| {
903 ApiError::BadRequest(format!("runtime_profile must serialize to JSON: {e}"))
904 })?;
905 let target = match &req.target_base_url {
906 Some(Some(value)) => Some(value.as_str()),
907 _ => profile.target_base_url.as_deref(),
908 };
909 launch_profile_from_runtime = Some(launch_profile_input_from_runtime(&profile, target));
910 ProjectPatchOption::Set(Some(runtime_profile_json))
911 }
912 };
913 let now = now_epoch_ms();
914 let patch = ProjectPatch {
915 description: project_patch_for(&req.description),
916 target_base_url: target_base_url_patch,
917 env_config_json: env_config_patch,
918 runtime_profile_json: runtime_profile_patch,
919 updated_at: now,
920 };
921 if !s.store.projects().update(&id, &patch).await? {
922 return Err(ApiError::NotFound(format!("project `{id}` not found")));
923 }
924 if let Some(input) = launch_profile_from_runtime.as_ref() {
925 s.store.launch_profiles().upsert_default(&id, input, now).await?;
926 }
927 let row = s
928 .store
929 .projects()
930 .get(&id)
931 .await?
932 .ok_or_else(|| ApiError::Internal("project vanished after update".to_string()))?;
933 Ok(Json(row))
934}
935
936async fn start_auth_auto_setup_project(
937 State(s): State<ServerState>,
938 Path(id): Path<String>,
939 Json(req): Json<AuthSetupRequest>,
940) -> Result<Json<AuthSetupStartResponse>, ApiError> {
941 let project = s
942 .store
943 .projects()
944 .get(&id)
945 .await?
946 .ok_or_else(|| ApiError::NotFound(format!("project `{id}` not found")))?;
947 let target_base_url = auth_setup_target_base_url(&project, req.target_base_url.as_deref());
948 if let Some(url) = target_base_url.as_deref() {
949 if !is_local_http_url(url) {
950 return Err(ApiError::BadRequest(format!("target URL `{url}` must be local")));
951 }
952 }
953
954 let job = s.auth_setup_jobs.create(&id, now_epoch_ms()).await;
955 let job_id = job.id.clone();
956 let state = s.clone();
957 tokio::spawn(async move {
958 let panic_state = state.clone();
959 let panic_job_id = job_id.clone();
960 let result =
961 AssertUnwindSafe(run_auth_auto_setup_job(state, id, req, job_id)).catch_unwind().await;
962 if let Err(payload) = result {
963 let detail = panic_payload_message(payload.as_ref());
964 tracing::error!(job_id = %panic_job_id, %detail, "auth setup job panicked");
965 panic_state
966 .auth_setup_jobs
967 .fail(
968 &panic_job_id,
969 auth_setup_internal_error(format!(
970 "auth setup background task panicked: {detail}"
971 )),
972 )
973 .await;
974 }
975 });
976
977 Ok(Json(AuthSetupStartResponse { job }))
978}
979
980async fn get_auth_auto_setup_job(
981 State(s): State<ServerState>,
982 Path((project_id, job_id)): Path<(String, String)>,
983) -> Result<Json<AuthSetupJobRecord>, ApiError> {
984 let job = s
985 .auth_setup_jobs
986 .get(&job_id)
987 .await
988 .ok_or_else(|| ApiError::NotFound(format!("auth setup job `{job_id}` not found")))?;
989 if job.project_id != project_id {
990 return Err(ApiError::NotFound(format!("auth setup job `{job_id}` not found")));
991 }
992 Ok(Json(job))
993}
994
995async fn start_ai_project_setup(
996 State(s): State<ServerState>,
997 Path(id): Path<String>,
998 Json(req): Json<ProjectSetupRequest>,
999) -> Result<Json<ProjectSetupStartResponse>, ApiError> {
1000 let project = s
1001 .store
1002 .projects()
1003 .get(&id)
1004 .await?
1005 .ok_or_else(|| ApiError::NotFound(format!("project `{id}` not found")))?;
1006 let target_base_url = auth_setup_target_base_url(&project, req.target_base_url.as_deref());
1007 if let Some(url) = target_base_url.as_deref() {
1008 if !is_local_http_url(url) {
1009 return Err(ApiError::BadRequest(format!("target URL `{url}` must be local")));
1010 }
1011 }
1012
1013 let job = s.project_setup_jobs.create(&id, now_epoch_ms()).await;
1014 let job_id = job.id.clone();
1015 let state = s.clone();
1016 tokio::spawn(async move {
1017 let panic_state = state.clone();
1018 let panic_job_id = job_id.clone();
1019 let result =
1020 AssertUnwindSafe(run_ai_project_setup_job(state, id, req, job_id)).catch_unwind().await;
1021 if let Err(payload) = result {
1022 let detail = panic_payload_message(payload.as_ref());
1023 tracing::error!(job_id = %panic_job_id, %detail, "project setup job panicked");
1024 panic_state
1025 .project_setup_jobs
1026 .fail(
1027 &panic_job_id,
1028 project_setup_internal_error(format!(
1029 "project setup background task panicked: {detail}"
1030 )),
1031 )
1032 .await;
1033 }
1034 });
1035
1036 Ok(Json(ProjectSetupStartResponse { job }))
1037}
1038
1039async fn list_ai_project_setup_jobs(
1040 State(s): State<ServerState>,
1041 Path(project_id): Path<String>,
1042) -> Result<Json<ProjectSetupJobListResponse>, ApiError> {
1043 s.store
1044 .projects()
1045 .get(&project_id)
1046 .await?
1047 .ok_or_else(|| ApiError::NotFound(format!("project `{project_id}` not found")))?;
1048 let jobs = s.project_setup_jobs.list_by_project(&project_id).await;
1049 Ok(Json(ProjectSetupJobListResponse { jobs }))
1050}
1051
1052async fn get_ai_project_setup_job(
1053 State(s): State<ServerState>,
1054 Path((project_id, job_id)): Path<(String, String)>,
1055) -> Result<Json<ProjectSetupJobRecord>, ApiError> {
1056 let job = s
1057 .project_setup_jobs
1058 .get(&job_id)
1059 .await
1060 .ok_or_else(|| ApiError::NotFound(format!("project setup job `{job_id}` not found")))?;
1061 if job.project_id != project_id {
1062 return Err(ApiError::NotFound(format!("project setup job `{job_id}` not found")));
1063 }
1064 Ok(Json(job))
1065}
1066
1067async fn run_ai_project_setup_job(
1068 s: ServerState,
1069 id: String,
1070 req: ProjectSetupRequest,
1071 job_id: String,
1072) {
1073 tracing::info!(
1074 project_id = %id,
1075 job_id = %job_id,
1076 project_setup = req.project_setup,
1077 seed_setup = req.seed_setup,
1078 auth_setup = req.auth_setup,
1079 "AI project setup job started"
1080 );
1081 let result = run_ai_project_setup_once(s.clone(), &id, req, &job_id).await;
1082 match result {
1083 Ok(response) => {
1084 tracing::info!(
1085 project_id = %id,
1086 job_id = %job_id,
1087 profile_id = %response.profile.id,
1088 auth_profiles = response
1089 .project
1090 .runtime_profile
1091 .as_ref()
1092 .map(|profile| profile.auth_profiles.len())
1093 .unwrap_or(0),
1094 seed_setup = response.seed_setup.is_some(),
1095 auth_setup = response.auth_setup.is_some(),
1096 "AI project setup job finished"
1097 );
1098 s.project_setup_jobs.complete(&job_id, response).await;
1099 }
1100 Err(error) => {
1101 tracing::error!(
1102 project_id = %id,
1103 job_id = %job_id,
1104 code = %error.code,
1105 detail = %error.detail,
1106 "AI project setup job failed"
1107 );
1108 s.project_setup_jobs.fail(&job_id, error).await;
1109 }
1110 }
1111}
1112
1113async fn run_ai_project_setup_once(
1114 s: ServerState,
1115 id: &str,
1116 req: ProjectSetupRequest,
1117 job_id: &str,
1118) -> Result<ProjectSetupResponse, ProjectSetupError> {
1119 if !req.project_setup && !req.seed_setup && !req.auth_setup {
1120 return Err(project_setup_no_features_error());
1121 }
1122
1123 s.project_setup_jobs
1124 .push_phase(job_id, ProjectSetupPhase::CollectingRepos, "Collecting project repositories.")
1125 .await;
1126 let mut project = s
1127 .store
1128 .projects()
1129 .get(id)
1130 .await
1131 .map_err(project_setup_store_error)?
1132 .ok_or_else(|| project_setup_not_found_error(format!("project `{id}` not found")))?;
1133 let repos = s.store.repos().list_by_project(id).await.map_err(project_setup_store_error)?;
1134 let workspace_roots = auth_setup_workspace_roots(&repos, s.state_repos_dir.as_deref());
1135 if workspace_roots.is_empty() && (req.project_setup || req.seed_setup) {
1136 return Err(ProjectSetupError {
1137 code: "no_local_workspace".to_string(),
1138 title: "Project setup needs a local repository".to_string(),
1139 detail: "No local repo workspace was available for the agent to inspect.".to_string(),
1140 hint: Some(
1141 "Add or ingest at least one local project repository, then retry.".to_string(),
1142 ),
1143 retryable: true,
1144 });
1145 }
1146 let target_base_url = auth_setup_target_base_url(&project, req.target_base_url.as_deref());
1147 if let Some(url) = target_base_url.as_deref() {
1148 if !is_local_http_url(url) {
1149 return Err(ProjectSetupError {
1150 code: "target_not_local".to_string(),
1151 title: "Project setup target is not local".to_string(),
1152 detail: format!("target URL `{url}` must be local"),
1153 hint: Some("Use a localhost or loopback app URL for AI project setup.".to_string()),
1154 retryable: false,
1155 });
1156 }
1157 }
1158
1159 let mut launch_profile = project.default_launch_profile.clone();
1160 let mut overall_checks = Vec::new();
1161 let mut overall_warnings = Vec::new();
1162 let mut messages = Vec::new();
1163 let mut seed_setup = None;
1164 let mut auth_setup = None;
1165 let mut agent_used = false;
1166 let mut seed_roles = Vec::new();
1167 let mut seeded_objects = Vec::new();
1168
1169 if req.project_setup {
1170 let Some(agent) = s.project_setup_agent.as_ref() else {
1171 return Err(ProjectSetupError {
1172 code: "agent_runtime_unavailable".to_string(),
1173 title: "No AI project setup agent is configured".to_string(),
1174 detail: "AI project setup requires a CLI-backed agent runtime.".to_string(),
1175 hint: Some("Choose Codex or Claude Code in AI setup and make sure the CLI is installed and logged in.".to_string()),
1176 retryable: true,
1177 });
1178 };
1179
1180 s.project_setup_jobs
1181 .push_phase(
1182 job_id,
1183 ProjectSetupPhase::StartingAgent,
1184 "Starting the repository setup agent.",
1185 )
1186 .await;
1187 let agent_req = ProjectSetupAgentRequest {
1188 project_id: id.to_string(),
1189 project_name: project.name.clone(),
1190 target_base_url: target_base_url.clone(),
1191 workspace_roots: workspace_roots.clone(),
1192 existing_launch_profile: launch_profile.clone(),
1193 };
1194 s.project_setup_jobs
1195 .push_phase(
1196 job_id,
1197 ProjectSetupPhase::InspectingProject,
1198 "Agent is inspecting scripts, env files, migrations, and local dev workflow.",
1199 )
1200 .await;
1201 let mut output = agent.explore(agent_req).await.map_err(project_setup_agent_error)?;
1202 agent_used = true;
1203 validate_project_setup_profile(&mut output.profile)?;
1204
1205 s.project_setup_jobs
1206 .push_phase(job_id, ProjectSetupPhase::ApplyingProfile, "Saving launch profile.")
1207 .await;
1208 let now = now_epoch_ms();
1209 let profile = s
1210 .store
1211 .launch_profiles()
1212 .upsert_default(id, &output.profile, now)
1213 .await
1214 .map_err(project_setup_store_error)?;
1215 if project.target_base_url.is_none() {
1216 if let Some(target) = profile.target_urls.first().cloned() {
1217 let patch = ProjectPatch {
1218 description: ProjectPatchOption::Unset,
1219 target_base_url: ProjectPatchOption::Set(Some(target)),
1220 env_config_json: ProjectPatchOption::Unset,
1221 runtime_profile_json: ProjectPatchOption::Unset,
1222 updated_at: now,
1223 };
1224 s.store.projects().update(id, &patch).await.map_err(project_setup_store_error)?;
1225 }
1226 }
1227 launch_profile = Some(profile);
1228 overall_checks.extend(output.checks);
1229 overall_warnings.extend(output.warnings);
1230 messages.push(output.message);
1231 if output.verification_status == ProjectSetupVerificationStatus::NeedsReview
1232 && overall_warnings.is_empty()
1233 {
1234 overall_warnings
1235 .push("Project setup agent marked the launch profile for review.".to_string());
1236 }
1237 project = s.store.projects().get(id).await.map_err(project_setup_store_error)?.ok_or_else(
1238 || project_setup_internal_error("project vanished after AI project setup".to_string()),
1239 )?;
1240 }
1241
1242 if req.seed_setup {
1243 let Some(agent) = s.seed_setup_agent.as_ref() else {
1244 return Err(ProjectSetupError {
1245 code: "agent_runtime_unavailable".to_string(),
1246 title: "No AI seed setup agent is configured".to_string(),
1247 detail: "AI seed setup requires a CLI-backed agent runtime.".to_string(),
1248 hint: Some("Choose Codex or Claude Code in AI setup and make sure the CLI is installed and logged in.".to_string()),
1249 retryable: true,
1250 });
1251 };
1252
1253 s.project_setup_jobs
1254 .push_phase(job_id, ProjectSetupPhase::StartingAgent, "Starting the seed setup agent.")
1255 .await;
1256 let agent_req = SeedSetupAgentRequest {
1257 project_id: id.to_string(),
1258 project_name: project.name.clone(),
1259 target_base_url: target_base_url.clone(),
1260 workspace_roots: workspace_roots.clone(),
1261 launch_profile: launch_profile.clone(),
1262 };
1263 s.project_setup_jobs
1264 .push_phase(
1265 job_id,
1266 ProjectSetupPhase::InspectingSeed,
1267 "Agent is preparing deterministic local fixtures, roles, owned objects, and reset hooks.",
1268 )
1269 .await;
1270 let output = agent.explore(agent_req).await.map_err(seed_setup_agent_error)?;
1271 agent_used = true;
1272 validate_seed_setup_plan(&output.plan)?;
1273
1274 let mut input = launch_profile
1275 .as_ref()
1276 .map(project_launch_profile_to_input)
1277 .unwrap_or_else(|| blank_launch_profile_input(target_base_url.as_deref()));
1278 apply_seed_plan_to_launch_profile(&mut input, &output.plan);
1279
1280 s.project_setup_jobs
1281 .push_phase(job_id, ProjectSetupPhase::ApplyingSeed, "Saving seed and reset setup.")
1282 .await;
1283 let now = now_epoch_ms();
1284 let profile = s
1285 .store
1286 .launch_profiles()
1287 .upsert_default(id, &input, now)
1288 .await
1289 .map_err(project_setup_store_error)?;
1290 launch_profile = Some(profile.clone());
1291
1292 if apply_seed_env_to_project_runtime_profile(
1293 &s,
1294 id,
1295 &project,
1296 &output.plan,
1297 target_base_url.clone(),
1298 launch_profile.as_ref(),
1299 now,
1300 )
1301 .await?
1302 {
1303 project =
1304 s.store.projects().get(id).await.map_err(project_setup_store_error)?.ok_or_else(
1305 || {
1306 project_setup_internal_error(
1307 "project vanished after seed setup".to_string(),
1308 )
1309 },
1310 )?;
1311 }
1312
1313 let verification = ProjectSetupVerification {
1314 status: if output.plan.warnings.is_empty() {
1315 ProjectSetupVerificationStatus::Verified
1316 } else {
1317 ProjectSetupVerificationStatus::NeedsReview
1318 },
1319 checks: output.plan.checks.clone(),
1320 warnings: output.plan.warnings.clone(),
1321 };
1322 overall_checks.extend(verification.checks.clone());
1323 overall_warnings.extend(verification.warnings.clone());
1324 seed_roles = output.plan.roles.clone();
1325 seeded_objects = output.plan.seeded_objects.clone();
1326 messages.push(output.message.clone());
1327 seed_setup =
1328 Some(SeedSetupResponse { plan: output.plan, verification, message: output.message });
1329 }
1330
1331 if req.auth_setup {
1332 s.project_setup_jobs
1333 .push_phase(
1334 job_id,
1335 ProjectSetupPhase::InspectingAuth,
1336 "Running auth setup with seeded roles and owned objects.",
1337 )
1338 .await;
1339 let auth_job = s.auth_setup_jobs.create(id, now_epoch_ms()).await;
1340 let auth_req = AuthSetupRequest {
1341 target_base_url: target_base_url.clone(),
1342 roles: seed_roles.clone(),
1343 seeded_objects: seeded_objects.clone(),
1344 };
1345 let result = run_auth_auto_setup_once(s.clone(), id, auth_req, &auth_job.id).await;
1346 match result {
1347 Ok(response) => {
1348 s.auth_setup_jobs.complete(&auth_job.id, response.clone()).await;
1349 overall_checks.extend(response.verification.checks.clone());
1350 overall_warnings.extend(response.verification.warnings.clone());
1351 if response.verification.status != AuthSetupVerificationStatus::Verified {
1352 overall_warnings.push("Auth setup needs review.".to_string());
1353 }
1354 messages.push(response.message.clone());
1355 agent_used |= response.agent_used;
1356 project = response.project.clone();
1357 auth_setup = Some(response);
1358 }
1359 Err(error) => {
1360 s.auth_setup_jobs.fail(&auth_job.id, error.clone()).await;
1361 return Err(project_setup_from_auth_error(error));
1362 }
1363 }
1364 }
1365
1366 let profile = ensure_project_setup_launch_profile(
1367 &s,
1368 id,
1369 &mut project,
1370 launch_profile,
1371 target_base_url.as_deref(),
1372 )
1373 .await?;
1374 let project =
1375 s.store.projects().get(id).await.map_err(project_setup_store_error)?.ok_or_else(|| {
1376 project_setup_internal_error("project vanished after setup".to_string())
1377 })?;
1378 let verification = ProjectSetupVerification {
1379 status: if overall_warnings.is_empty() {
1380 ProjectSetupVerificationStatus::Verified
1381 } else {
1382 ProjectSetupVerificationStatus::NeedsReview
1383 },
1384 checks: overall_checks,
1385 warnings: overall_warnings,
1386 };
1387 let mut message =
1388 if messages.is_empty() { "AI setup finished.".to_string() } else { messages.join(" ") };
1389 if !verification.warnings.is_empty() {
1390 message.push_str(&format!(" Review {} warning(s).", verification.warnings.len()));
1391 }
1392 let response = ProjectSetupResponse {
1393 project,
1394 profile,
1395 agent_used,
1396 verification,
1397 seed_setup,
1398 auth_setup,
1399 message,
1400 };
1401 validate_project_setup_postconditions(&req, &response)?;
1402 Ok(response)
1403}
1404
1405async fn run_auth_auto_setup_job(
1406 s: ServerState,
1407 id: String,
1408 req: AuthSetupRequest,
1409 job_id: String,
1410) {
1411 tracing::info!(project_id = %id, job_id = %job_id, "auth setup job started");
1412 let result = run_auth_auto_setup_once(s.clone(), &id, req, &job_id).await;
1413 match result {
1414 Ok(response) => {
1415 tracing::info!(
1416 project_id = %id,
1417 job_id = %job_id,
1418 profiles = response.profiles_added + response.profiles_updated,
1419 "auth setup job finished"
1420 );
1421 s.auth_setup_jobs.complete(&job_id, response).await;
1422 }
1423 Err(error) => {
1424 tracing::error!(
1425 project_id = %id,
1426 job_id = %job_id,
1427 code = %error.code,
1428 detail = %error.detail,
1429 "auth setup job failed"
1430 );
1431 s.auth_setup_jobs.fail(&job_id, error).await;
1432 }
1433 }
1434}
1435
1436async fn run_auth_auto_setup_once(
1437 s: ServerState,
1438 id: &str,
1439 req: AuthSetupRequest,
1440 job_id: &str,
1441) -> Result<AuthSetupResponse, AuthSetupError> {
1442 s.auth_setup_jobs
1443 .push_phase(job_id, AuthSetupPhase::CollectingRepos, "Collecting project repositories.")
1444 .await;
1445 let project = s
1446 .store
1447 .projects()
1448 .get(id)
1449 .await
1450 .map_err(auth_setup_store_error)?
1451 .ok_or_else(|| auth_setup_not_found_error(format!("project `{id}` not found")))?;
1452 let target_base_url = auth_setup_target_base_url(&project, req.target_base_url.as_deref());
1453 if let Some(url) = target_base_url.as_deref() {
1454 if !is_local_http_url(url) {
1455 return Err(AuthSetupError {
1456 code: "target_not_local".to_string(),
1457 title: "Auth setup target is not local".to_string(),
1458 detail: format!("target URL `{url}` must be local"),
1459 hint: Some("Use a localhost or loopback app URL for auth setup.".to_string()),
1460 retryable: false,
1461 });
1462 }
1463 }
1464
1465 let repos = s.store.repos().list_by_project(id).await.map_err(auth_setup_store_error)?;
1466 let workspace_roots = auth_setup_workspace_roots(&repos, s.state_repos_dir.as_deref());
1467 let discovery = discover_auth_setup(&workspace_roots);
1468 s.auth_setup_jobs
1469 .push_phase(
1470 job_id,
1471 AuthSetupPhase::StartingAgent,
1472 if s.auth_setup_agent.is_some() {
1473 "Starting repository exploration agent."
1474 } else {
1475 "No exploration agent is configured; using static repository scan."
1476 },
1477 )
1478 .await;
1479 let agent_output = if let Some(agent) = s.auth_setup_agent.as_ref() {
1480 let agent_req = AuthSetupAgentRequest {
1481 project_id: id.to_string(),
1482 project_name: project.name.clone(),
1483 target_base_url: target_base_url.clone(),
1484 workspace_roots: workspace_roots.clone(),
1485 requested_roles: req.roles.clone(),
1486 seeded_objects: req.seeded_objects.clone(),
1487 existing_profiles: project
1488 .runtime_profile
1489 .as_ref()
1490 .map(|profile| profile.auth_profiles.clone())
1491 .unwrap_or_default(),
1492 static_login_paths: discovery.login_paths.clone(),
1493 static_object_routes: discovery.object_routes.clone(),
1494 files_inspected: discovery.files_inspected,
1495 };
1496 s.auth_setup_jobs
1497 .push_phase(
1498 job_id,
1499 AuthSetupPhase::InspectingAuthRoutes,
1500 "Agent is inspecting auth routes, sessions, roles, and ownership hints.",
1501 )
1502 .await;
1503 match agent.explore(agent_req).await {
1504 Ok(output) if output.profiles.is_empty() => return Err(auth_setup_no_profiles_error()),
1505 Ok(output) => Some(output),
1506 Err(err) => return Err(auth_setup_agent_error(err)),
1507 }
1508 } else {
1509 None
1510 };
1511 let mut runtime_profile = project.runtime_profile.clone().unwrap_or_else(|| {
1512 empty_runtime_profile_for_auth_setup(
1513 target_base_url.clone(),
1514 project.default_launch_profile.as_ref(),
1515 )
1516 });
1517 if runtime_profile.target_base_url.is_none() {
1518 runtime_profile.target_base_url = target_base_url.clone();
1519 }
1520 if runtime_profile.health_check_url.is_none() {
1521 runtime_profile.health_check_url = target_base_url.clone();
1522 }
1523
1524 let agent_used = agent_output.is_some();
1525 let (
1526 roles,
1527 login_paths,
1528 object_routes,
1529 mut verification,
1530 agent_message,
1531 profiles_added,
1532 profiles_updated,
1533 ) = if let Some(output) = agent_output {
1534 s.auth_setup_jobs
1535 .push_phase(
1536 job_id,
1537 AuthSetupPhase::DraftingProfiles,
1538 "Normalizing agent-generated auth profiles.",
1539 )
1540 .await;
1541 apply_agent_auth_setup_output(
1542 &mut runtime_profile.auth_profiles,
1543 output,
1544 discovery.login_paths.first().cloned(),
1545 &req.seeded_objects,
1546 )
1547 } else {
1548 s.auth_setup_jobs
1549 .push_phase(
1550 job_id,
1551 AuthSetupPhase::DraftingProfiles,
1552 "Drafting auth profiles from static repository hints.",
1553 )
1554 .await;
1555 let roles = auth_setup_roles(&req.roles, &discovery);
1556 let (profiles_added, profiles_updated) = merge_auth_setup_profiles(
1557 &mut runtime_profile.auth_profiles,
1558 &roles,
1559 discovery.login_paths.first().cloned(),
1560 &req.seeded_objects,
1561 );
1562 let verification = static_auth_setup_verification(&discovery, None);
1563 (
1564 roles,
1565 discovery.login_paths.clone(),
1566 discovery.object_routes.clone(),
1567 verification,
1568 None,
1569 profiles_added,
1570 profiles_updated,
1571 )
1572 };
1573 apply_discovered_otp_hints(&mut runtime_profile, target_base_url.as_deref(), &discovery);
1574 let auth_env_resolution =
1575 apply_discovered_auth_env_values(&mut runtime_profile, &discovery.credentials);
1576 apply_auth_env_resolution_to_verification(&mut verification, &auth_env_resolution);
1577 s.auth_setup_jobs
1578 .push_phase(
1579 job_id,
1580 AuthSetupPhase::VerifyingProfiles,
1581 "Reviewing generated profiles against discovered auth evidence.",
1582 )
1583 .await;
1584 let runtime_profile_json = serde_json::to_string(&runtime_profile).map_err(|e| {
1585 auth_setup_internal_error(format!("runtime_profile must serialize to JSON: {e}"))
1586 })?;
1587 s.auth_setup_jobs
1588 .push_phase(job_id, AuthSetupPhase::SavingProfiles, "Saving auth profiles.")
1589 .await;
1590 let now = now_epoch_ms();
1591 let patch = ProjectPatch {
1592 description: ProjectPatchOption::Unset,
1593 target_base_url: target_base_url
1594 .clone()
1595 .map(|url| ProjectPatchOption::Set(Some(url)))
1596 .unwrap_or(ProjectPatchOption::Unset),
1597 env_config_json: ProjectPatchOption::Unset,
1598 runtime_profile_json: ProjectPatchOption::Set(Some(runtime_profile_json)),
1599 updated_at: now,
1600 };
1601 if !s.store.projects().update(id, &patch).await.map_err(auth_setup_store_error)? {
1602 return Err(auth_setup_not_found_error(format!("project `{id}` not found")));
1603 }
1604 let project =
1605 s.store.projects().get(id).await.map_err(auth_setup_store_error)?.ok_or_else(|| {
1606 auth_setup_internal_error("project vanished after auth setup".to_string())
1607 })?;
1608 let message = auth_setup_response_message(
1609 agent_used,
1610 profiles_added,
1611 profiles_updated,
1612 discovery.files_inspected,
1613 &verification,
1614 agent_message,
1615 auth_env_resolution_message(&auth_env_resolution),
1616 );
1617 Ok(AuthSetupResponse {
1618 project,
1619 roles,
1620 login_paths,
1621 object_routes,
1622 agent_used,
1623 verification,
1624 profiles_added,
1625 profiles_updated,
1626 message,
1627 })
1628}
1629
1630fn auth_setup_store_error(err: nyx_agent_core::store::StoreError) -> AuthSetupError {
1631 AuthSetupError {
1632 code: "store_error".to_string(),
1633 title: "Auth setup could not read or save project data".to_string(),
1634 detail: err.to_string(),
1635 hint: Some("Retry the setup. If this repeats, restart the Nyx Agent daemon.".to_string()),
1636 retryable: true,
1637 }
1638}
1639
1640fn auth_setup_not_found_error(detail: String) -> AuthSetupError {
1641 AuthSetupError {
1642 code: "project_not_found".to_string(),
1643 title: "Project was not found".to_string(),
1644 detail,
1645 hint: Some("Refresh the project list and try again.".to_string()),
1646 retryable: false,
1647 }
1648}
1649
1650fn auth_setup_internal_error(detail: String) -> AuthSetupError {
1651 AuthSetupError {
1652 code: "internal_error".to_string(),
1653 title: "Auth setup hit an internal error".to_string(),
1654 detail,
1655 hint: Some("Retry the setup. If this repeats, check the daemon logs.".to_string()),
1656 retryable: true,
1657 }
1658}
1659
1660fn auth_setup_no_profiles_error() -> AuthSetupError {
1661 AuthSetupError {
1662 code: "agent_returned_no_profiles".to_string(),
1663 title: "The auth setup agent did not return any profiles".to_string(),
1664 detail: "The exploration agent completed but did not record a usable auth profile."
1665 .to_string(),
1666 hint: Some(
1667 "Check that the repository contains login/session code or add a role manually."
1668 .to_string(),
1669 ),
1670 retryable: true,
1671 }
1672}
1673
1674fn auth_setup_agent_error(err: AuthSetupAgentError) -> AuthSetupError {
1675 let raw = err.to_string();
1676 let lower = raw.to_ascii_lowercase();
1677 let network_like = lower.contains("network")
1678 || lower.contains("dns")
1679 || lower.contains("could not resolve")
1680 || lower.contains("connection")
1681 || lower.contains("timeout")
1682 || lower.contains("timed out")
1683 || lower.contains("transport");
1684 let unavailable = matches!(err, AuthSetupAgentError::Unavailable(_));
1685 let (code, title, hint, retryable) = if network_like {
1686 (
1687 "agent_upstream_network",
1688 "The auth setup agent could not reach its AI runtime",
1689 "Check your network connection and the configured AI CLI login, then retry.",
1690 true,
1691 )
1692 } else if unavailable {
1693 (
1694 "agent_runtime_unavailable",
1695 "The configured auth setup agent is unavailable",
1696 "Choose Codex or Claude Code in AI setup and make sure the CLI is installed and logged in.",
1697 true,
1698 )
1699 } else {
1700 (
1701 "agent_failed",
1702 "The auth setup agent failed",
1703 "Retry the job. If this repeats, inspect the daemon logs for the underlying CLI error.",
1704 true,
1705 )
1706 };
1707 AuthSetupError {
1708 code: code.to_string(),
1709 title: title.to_string(),
1710 detail: raw,
1711 hint: Some(hint.to_string()),
1712 retryable,
1713 }
1714}
1715
1716fn project_setup_store_error(err: nyx_agent_core::store::StoreError) -> ProjectSetupError {
1717 ProjectSetupError {
1718 code: "store_error".to_string(),
1719 title: "Project setup could not read or save project data".to_string(),
1720 detail: err.to_string(),
1721 hint: Some("Retry the setup. If this repeats, restart the Nyx Agent daemon.".to_string()),
1722 retryable: true,
1723 }
1724}
1725
1726fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
1727 if let Some(message) = payload.downcast_ref::<&'static str>() {
1728 return (*message).to_string();
1729 }
1730 if let Some(message) = payload.downcast_ref::<String>() {
1731 return message.clone();
1732 }
1733 "unknown panic payload".to_string()
1734}
1735
1736fn project_setup_not_found_error(detail: String) -> ProjectSetupError {
1737 ProjectSetupError {
1738 code: "project_not_found".to_string(),
1739 title: "Project was not found".to_string(),
1740 detail,
1741 hint: Some("Refresh the project list and try again.".to_string()),
1742 retryable: false,
1743 }
1744}
1745
1746fn project_setup_internal_error(detail: String) -> ProjectSetupError {
1747 ProjectSetupError {
1748 code: "internal_error".to_string(),
1749 title: "Project setup hit an internal error".to_string(),
1750 detail,
1751 hint: Some("Retry the setup. If this repeats, check the daemon logs.".to_string()),
1752 retryable: true,
1753 }
1754}
1755
1756fn project_setup_agent_error(err: ProjectSetupAgentError) -> ProjectSetupError {
1757 let raw = err.to_string();
1758 let unavailable = matches!(err, ProjectSetupAgentError::Unavailable(_));
1759 ProjectSetupError {
1760 code: if unavailable { "agent_runtime_unavailable" } else { "agent_failed" }.to_string(),
1761 title: if unavailable {
1762 "The configured project setup agent is unavailable"
1763 } else {
1764 "The project setup agent failed"
1765 }
1766 .to_string(),
1767 detail: raw,
1768 hint: Some(if unavailable {
1769 "Choose Codex or Claude Code in AI setup and make sure the CLI is installed and logged in."
1770 } else {
1771 "Retry the job. If this repeats, inspect the daemon logs for the underlying CLI error."
1772 }
1773 .to_string()),
1774 retryable: true,
1775 }
1776}
1777
1778fn seed_setup_agent_error(err: SeedSetupAgentError) -> ProjectSetupError {
1779 let raw = err.to_string();
1780 let unavailable = matches!(err, SeedSetupAgentError::Unavailable(_));
1781 ProjectSetupError {
1782 code: if unavailable { "agent_runtime_unavailable" } else { "seed_agent_failed" }
1783 .to_string(),
1784 title: if unavailable {
1785 "The configured seed setup agent is unavailable"
1786 } else {
1787 "The seed setup agent failed"
1788 }
1789 .to_string(),
1790 detail: raw,
1791 hint: Some(if unavailable {
1792 "Choose Codex or Claude Code in AI setup and make sure the CLI is installed and logged in."
1793 } else {
1794 "Retry the job. If this repeats, inspect the daemon logs for the underlying CLI error."
1795 }
1796 .to_string()),
1797 retryable: true,
1798 }
1799}
1800
1801fn project_setup_from_auth_error(err: AuthSetupError) -> ProjectSetupError {
1802 ProjectSetupError {
1803 code: format!("auth_{}", err.code),
1804 title: format!("Auth setup failed: {}", err.title),
1805 detail: err.detail,
1806 hint: err.hint,
1807 retryable: err.retryable,
1808 }
1809}
1810
1811fn project_setup_no_features_error() -> ProjectSetupError {
1812 ProjectSetupError {
1813 code: "no_setup_features_selected".to_string(),
1814 title: "No setup features were selected".to_string(),
1815 detail: "Select project setup, seed setup, auth setup, or any combination of them."
1816 .to_string(),
1817 hint: Some("Choose at least one AI setup feature and retry.".to_string()),
1818 retryable: false,
1819 }
1820}
1821
1822fn validate_project_setup_profile(
1823 profile: &mut ProjectLaunchProfileInput,
1824) -> Result<(), ProjectSetupError> {
1825 for url in &profile.target_urls {
1826 if !is_local_http_url(url) {
1827 return Err(ProjectSetupError {
1828 code: "target_not_local".to_string(),
1829 title: "AI project setup proposed a non-local target".to_string(),
1830 detail: format!("target URL `{url}` must be local"),
1831 hint: Some(
1832 "Ask the setup agent to use a localhost or loopback dev URL.".to_string(),
1833 ),
1834 retryable: true,
1835 });
1836 }
1837 }
1838 for check in &profile.health_checks {
1839 if let Some(url) = check.url.as_deref() {
1840 if !is_local_http_url(url) {
1841 return Err(ProjectSetupError {
1842 code: "health_target_not_local".to_string(),
1843 title: "AI project setup proposed a non-local health check".to_string(),
1844 detail: format!("health check URL `{url}` must be local"),
1845 hint: Some(
1846 "Ask the setup agent to use a localhost or loopback health URL."
1847 .to_string(),
1848 ),
1849 retryable: true,
1850 });
1851 }
1852 }
1853 }
1854 if profile.target_urls.is_empty()
1855 && profile.start_steps.is_empty()
1856 && profile.health_checks.is_empty()
1857 {
1858 return Err(ProjectSetupError {
1859 code: "empty_profile".to_string(),
1860 title: "AI project setup returned an empty launch profile".to_string(),
1861 detail: "The agent did not provide a target URL, start command, or health check."
1862 .to_string(),
1863 hint: Some("Retry after adding local setup docs or a package script.".to_string()),
1864 retryable: true,
1865 });
1866 }
1867 Ok(())
1868}
1869
1870fn validate_seed_setup_plan(plan: &SeedSetupPlan) -> Result<(), ProjectSetupError> {
1871 let empty = plan.seed_steps.is_empty()
1872 && plan.reset_steps.is_empty()
1873 && plan.env_vars.is_empty()
1874 && plan.roles.is_empty()
1875 && plan.seeded_objects.is_empty();
1876 if empty {
1877 return Err(ProjectSetupError {
1878 code: "empty_seed_plan".to_string(),
1879 title: "AI seed setup returned an empty plan".to_string(),
1880 detail: "The seed setup agent did not provide seed commands, reset commands, env vars, roles, or seeded objects.".to_string(),
1881 hint: Some("Retry after adding local seed docs or fixture scripts to the repository.".to_string()),
1882 retryable: true,
1883 });
1884 }
1885 for var in &plan.env_vars {
1886 if var.name.trim().is_empty() {
1887 return Err(ProjectSetupError {
1888 code: "empty_seed_env_name".to_string(),
1889 title: "AI seed setup proposed an invalid environment variable".to_string(),
1890 detail: "A seed environment variable had an empty name.".to_string(),
1891 hint: Some("Retry seed setup or add the fixture env vars manually.".to_string()),
1892 retryable: true,
1893 });
1894 }
1895 }
1896 Ok(())
1897}
1898
1899fn validate_project_setup_postconditions(
1900 req: &ProjectSetupRequest,
1901 response: &ProjectSetupResponse,
1902) -> Result<(), ProjectSetupError> {
1903 if req.project_setup {
1904 let mut profile = project_launch_profile_to_input(&response.profile);
1905 validate_project_setup_profile(&mut profile).map_err(|_| ProjectSetupError {
1906 code: "launch_profile_not_persisted".to_string(),
1907 title: "AI setup did not save a usable launch profile".to_string(),
1908 detail: "The setup job finished without a target URL, start command, or health check in the saved launch profile.".to_string(),
1909 hint: Some("Retry AI setup. If this repeats, add local setup docs or commands manually in the environment profile.".to_string()),
1910 retryable: true,
1911 })?;
1912 }
1913 if req.seed_setup && response.seed_setup.is_none() {
1914 return Err(ProjectSetupError {
1915 code: "seed_setup_not_persisted".to_string(),
1916 title: "AI setup did not save seed setup".to_string(),
1917 detail: "The setup job finished without a seed setup result.".to_string(),
1918 hint: Some("Retry AI setup with Seed setup selected.".to_string()),
1919 retryable: true,
1920 });
1921 }
1922 if req.auth_setup {
1923 if response.auth_setup.is_none() {
1924 return Err(ProjectSetupError {
1925 code: "auth_setup_not_persisted".to_string(),
1926 title: "AI setup did not save auth setup".to_string(),
1927 detail: "The setup job finished without an auth setup result.".to_string(),
1928 hint: Some("Retry AI setup with Auth setup selected.".to_string()),
1929 retryable: true,
1930 });
1931 }
1932 let auth_profiles = response
1933 .project
1934 .runtime_profile
1935 .as_ref()
1936 .map(|profile| profile.auth_profiles.len())
1937 .unwrap_or(0);
1938 if auth_profiles == 0 {
1939 return Err(ProjectSetupError {
1940 code: "auth_profiles_not_persisted".to_string(),
1941 title: "AI setup did not save auth profiles".to_string(),
1942 detail: "The setup job finished but the project still has zero runtime auth profiles.".to_string(),
1943 hint: Some("Retry auth setup after confirming the local app has deterministic test users or fixture credentials.".to_string()),
1944 retryable: true,
1945 });
1946 }
1947 }
1948 Ok(())
1949}
1950
1951fn project_launch_profile_to_input(profile: &ProjectLaunchProfile) -> ProjectLaunchProfileInput {
1952 ProjectLaunchProfileInput {
1953 name: Some(profile.name.clone()),
1954 mode: Some(profile.mode.clone()),
1955 build_steps: profile.build_steps.clone(),
1956 start_steps: profile.start_steps.clone(),
1957 seed_steps: profile.seed_steps.clone(),
1958 reset_steps: profile.reset_steps.clone(),
1959 login_steps: profile.login_steps.clone(),
1960 stop_steps: profile.stop_steps.clone(),
1961 health_checks: profile.health_checks.clone(),
1962 target_urls: profile.target_urls.clone(),
1963 env_refs: profile.env_refs.clone(),
1964 working_dirs: profile.working_dirs.clone(),
1965 }
1966}
1967
1968fn blank_launch_profile_input(target_base_url: Option<&str>) -> ProjectLaunchProfileInput {
1969 ProjectLaunchProfileInput {
1970 name: Some("AI local setup".to_string()),
1971 mode: Some("already-running".to_string()),
1972 build_steps: Vec::new(),
1973 start_steps: Vec::new(),
1974 seed_steps: Vec::new(),
1975 reset_steps: Vec::new(),
1976 login_steps: Vec::new(),
1977 stop_steps: Vec::new(),
1978 health_checks: Vec::new(),
1979 target_urls: target_base_url.map(str::to_string).into_iter().collect(),
1980 env_refs: Vec::new(),
1981 working_dirs: Vec::new(),
1982 }
1983}
1984
1985fn apply_seed_plan_to_launch_profile(input: &mut ProjectLaunchProfileInput, plan: &SeedSetupPlan) {
1986 if !plan.seed_steps.is_empty() {
1987 input.seed_steps = plan.seed_steps.clone();
1988 }
1989 if !plan.reset_steps.is_empty() {
1990 input.reset_steps = plan.reset_steps.clone();
1991 }
1992 if !plan.seed_steps.is_empty() || !plan.reset_steps.is_empty() {
1993 input.mode = Some("custom-commands".to_string());
1994 }
1995 for var in &plan.env_vars {
1996 let name = var.name.trim();
1997 if name.is_empty() {
1998 continue;
1999 }
2000 if !input.env_refs.iter().any(|entry| entry.kind == "env-var" && entry.value == name) {
2001 input.env_refs.push(nyx_agent_types::product::LaunchEnvRef {
2002 kind: "env-var".to_string(),
2003 value: name.to_string(),
2004 secret: var.secret,
2005 });
2006 }
2007 }
2008}
2009
2010async fn apply_seed_env_to_project_runtime_profile(
2011 s: &ServerState,
2012 id: &str,
2013 project: &ProjectRecord,
2014 plan: &SeedSetupPlan,
2015 target_base_url: Option<String>,
2016 launch_profile: Option<&ProjectLaunchProfile>,
2017 now: i64,
2018) -> Result<bool, ProjectSetupError> {
2019 if plan.env_vars.is_empty() {
2020 return Ok(false);
2021 }
2022
2023 let mut runtime_profile = project.runtime_profile.clone().unwrap_or_else(|| {
2024 empty_runtime_profile_for_auth_setup(target_base_url.clone(), launch_profile)
2025 });
2026 if runtime_profile.target_base_url.is_none() {
2027 runtime_profile.target_base_url = target_base_url.clone();
2028 }
2029 if runtime_profile.health_check_url.is_none() {
2030 runtime_profile.health_check_url = target_base_url.clone();
2031 }
2032 let changed = merge_runtime_env_vars(&mut runtime_profile.env_vars, &plan.env_vars);
2033 if !changed {
2034 return Ok(false);
2035 }
2036
2037 let runtime_profile_json = serde_json::to_string(&runtime_profile).map_err(|e| {
2038 project_setup_internal_error(format!("runtime_profile must serialize to JSON: {e}"))
2039 })?;
2040 let patch = ProjectPatch {
2041 description: ProjectPatchOption::Unset,
2042 target_base_url: target_base_url
2043 .map(|url| ProjectPatchOption::Set(Some(url)))
2044 .unwrap_or(ProjectPatchOption::Unset),
2045 env_config_json: ProjectPatchOption::Unset,
2046 runtime_profile_json: ProjectPatchOption::Set(Some(runtime_profile_json)),
2047 updated_at: now,
2048 };
2049 if !s.store.projects().update(id, &patch).await.map_err(project_setup_store_error)? {
2050 return Err(project_setup_not_found_error(format!("project `{id}` not found")));
2051 }
2052 Ok(true)
2053}
2054
2055fn merge_runtime_env_vars(
2056 existing: &mut Vec<ProjectRuntimeEnvVar>,
2057 incoming: &[ProjectRuntimeEnvVar],
2058) -> bool {
2059 let mut changed = false;
2060 for var in incoming {
2061 let name = var.name.trim();
2062 if name.is_empty() {
2063 continue;
2064 }
2065 if let Some(current) = existing.iter_mut().find(|current| current.name == name) {
2066 if current.value != var.value || current.secret != var.secret || current.name != name {
2067 current.name = name.to_string();
2068 current.value = var.value.clone();
2069 current.secret = var.secret;
2070 changed = true;
2071 }
2072 } else {
2073 existing.push(ProjectRuntimeEnvVar {
2074 name: name.to_string(),
2075 value: var.value.clone(),
2076 secret: var.secret,
2077 });
2078 changed = true;
2079 }
2080 }
2081 changed
2082}
2083
2084async fn ensure_project_setup_launch_profile(
2085 s: &ServerState,
2086 id: &str,
2087 project: &mut ProjectRecord,
2088 launch_profile: Option<ProjectLaunchProfile>,
2089 target_base_url: Option<&str>,
2090) -> Result<ProjectLaunchProfile, ProjectSetupError> {
2091 if let Some(profile) = launch_profile {
2092 return Ok(profile);
2093 }
2094
2095 let input = project
2096 .runtime_profile
2097 .as_ref()
2098 .map(|profile| launch_profile_input_from_runtime(profile, target_base_url))
2099 .unwrap_or_else(|| blank_launch_profile_input(target_base_url));
2100 let now = now_epoch_ms();
2101 let profile = s
2102 .store
2103 .launch_profiles()
2104 .upsert_default(id, &input, now)
2105 .await
2106 .map_err(project_setup_store_error)?;
2107 if project.target_base_url.is_none() {
2108 if let Some(target) = profile.target_urls.first().cloned() {
2109 let patch = ProjectPatch {
2110 description: ProjectPatchOption::Unset,
2111 target_base_url: ProjectPatchOption::Set(Some(target)),
2112 env_config_json: ProjectPatchOption::Unset,
2113 runtime_profile_json: ProjectPatchOption::Unset,
2114 updated_at: now,
2115 };
2116 s.store.projects().update(id, &patch).await.map_err(project_setup_store_error)?;
2117 *project =
2118 s.store.projects().get(id).await.map_err(project_setup_store_error)?.ok_or_else(
2119 || project_setup_internal_error("project vanished after setup".to_string()),
2120 )?;
2121 }
2122 }
2123 Ok(profile)
2124}
2125
2126fn project_patch_for(opt: &Option<Option<String>>) -> ProjectPatchOption<Option<String>> {
2127 match opt {
2128 None => ProjectPatchOption::Unset,
2129 Some(None) => ProjectPatchOption::Set(None),
2130 Some(Some(v)) => ProjectPatchOption::Set(Some(v.clone())),
2131 }
2132}
2133
2134fn normalize_create_target_base_url(
2135 target_base_url: Option<String>,
2136 runtime_profile: &mut Option<ProjectRuntimeProfile>,
2137) -> Result<Option<String>, ApiError> {
2138 let target_base_url = normalize_optional_string(target_base_url.as_deref());
2139 let profile_target = runtime_profile
2140 .as_ref()
2141 .and_then(|profile| normalize_optional_string(profile.target_base_url.as_deref()));
2142
2143 if let (Some(top_level), Some(profile_target)) = (&target_base_url, &profile_target) {
2144 if top_level != profile_target {
2145 return Err(ApiError::BadRequest(
2146 "runtime_profile.target_base_url must match target_base_url".to_string(),
2147 ));
2148 }
2149 }
2150
2151 let resolved = target_base_url.or(profile_target);
2152 if let Some(profile) = runtime_profile.as_mut() {
2153 profile.target_base_url = resolved.clone();
2154 }
2155 Ok(resolved)
2156}
2157
2158fn normalize_optional_string(value: Option<&str>) -> Option<String> {
2159 value.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string)
2160}
2161
2162fn auth_setup_target_base_url(project: &ProjectRecord, requested: Option<&str>) -> Option<String> {
2163 normalize_optional_string(requested)
2164 .or_else(|| {
2165 project
2166 .runtime_profile
2167 .as_ref()
2168 .and_then(|profile| normalize_optional_string(profile.target_base_url.as_deref()))
2169 })
2170 .or_else(|| normalize_optional_string(project.target_base_url.as_deref()))
2171 .or_else(|| {
2172 project.default_launch_profile.as_ref().and_then(|profile| {
2173 profile
2174 .target_urls
2175 .first()
2176 .and_then(|url| normalize_optional_string(Some(url.as_str())))
2177 })
2178 })
2179}
2180
2181fn empty_runtime_profile_for_auth_setup(
2182 target_base_url: Option<String>,
2183 launch: Option<&nyx_agent_types::product::ProjectLaunchProfile>,
2184) -> ProjectRuntimeProfile {
2185 let launch_target = launch
2186 .and_then(|profile| profile.target_urls.first())
2187 .and_then(|url| normalize_optional_string(Some(url.as_str())));
2188 let target = target_base_url.or(launch_target);
2189 ProjectRuntimeProfile {
2190 build_commands: Vec::new(),
2191 start_commands: Vec::new(),
2192 health_check_url: target.clone(),
2193 health_check_command: None,
2194 target_base_url: target,
2195 allowed_hosts: Vec::new(),
2196 env_vars: Vec::new(),
2197 auth_profiles: Vec::new(),
2198 env_file: None,
2199 timeout_seconds: None,
2200 }
2201}
2202
2203#[derive(Debug, Default)]
2204struct AuthSetupDiscovery {
2205 login_paths: Vec<String>,
2206 object_routes: Vec<String>,
2207 dev_mail_paths: Vec<String>,
2208 credentials: AuthSetupCredentialDiscovery,
2209 files_inspected: usize,
2210 admin_signal: bool,
2211 otp_signal: bool,
2212}
2213
2214#[derive(Debug, Clone, Default)]
2215struct AuthSetupCredentialDiscovery {
2216 exact_env: HashMap<String, String>,
2217 by_role: HashMap<String, AuthSetupRoleCredentials>,
2218}
2219
2220#[derive(Debug, Clone, Default)]
2221struct AuthSetupRoleCredentials {
2222 email: Option<String>,
2223 username: Option<String>,
2224 password: Option<String>,
2225 bearer_token: Option<String>,
2226 cookie: Option<String>,
2227}
2228
2229#[derive(Debug, Clone, Default)]
2230struct AuthSetupEnvResolution {
2231 values_added: usize,
2232 values_filled: usize,
2233 refs_resolved: Vec<String>,
2234 refs_missing: Vec<String>,
2235}
2236
2237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2238enum AuthSetupCredentialKind {
2239 Email,
2240 Username,
2241 Password,
2242 BearerToken,
2243 Cookie,
2244 ExactOnly,
2245}
2246
2247fn auth_setup_workspace_roots(
2248 repos: &[RepoRecord],
2249 state_repos_dir: Option<&FsPath>,
2250) -> Vec<PathBuf> {
2251 let mut seen = BTreeSet::new();
2252 let mut out = Vec::new();
2253 for repo in repos {
2254 if matches!(repo.source_kind.as_str(), "local" | "local-path") {
2255 let path = PathBuf::from(&repo.source_url_or_path);
2256 if path.is_dir() && seen.insert(path.clone()) {
2257 out.push(path);
2258 }
2259 }
2260 if let Some(root) = state_repos_dir {
2261 let path = root.join(&repo.name);
2262 if path.is_dir() && seen.insert(path.clone()) {
2263 out.push(path);
2264 }
2265 }
2266 }
2267 out
2268}
2269
2270fn discover_auth_setup(workspace_paths: &[PathBuf]) -> AuthSetupDiscovery {
2271 let mut discovery = AuthSetupDiscovery::default();
2272 let path_re =
2273 Regex::new(r#"(?i)["'`](/[^"'`\s]*?(?:login|signin|sign-in|session|auth)[^"'`\s]*)["'`]"#)
2274 .expect("auth setup path regex");
2275 let object_re = Regex::new(
2276 r#"(?i)["'`](/[^"'`\s]*(?:projects|invoices|accounts|documents|orders|users|tenants|orgs)[^"'`\s]*/(?::[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\}|[0-9A-Fa-f-]{4,})[^"'`\s]*)["'`]"#,
2277 )
2278 .expect("auth setup object-route regex");
2279 let dev_mail_re =
2280 Regex::new(r#"(?i)["'`](/[^"'`\s]*(?:dev[-_]mail|mailpit|mailhog|mailbox)[^"'`\s]*)["'`]"#)
2281 .expect("auth setup dev-mail path regex");
2282 for root in workspace_paths {
2283 discover_auth_setup_in_root(root, &path_re, &object_re, &dev_mail_re, &mut discovery);
2284 }
2285 discovery.login_paths = dedupe_setup_paths(discovery.login_paths);
2286 discovery.object_routes = dedupe_setup_paths(discovery.object_routes);
2287 discovery.dev_mail_paths = dedupe_setup_paths(discovery.dev_mail_paths);
2288 discovery
2289}
2290
2291fn discover_auth_setup_in_root(
2292 root: &FsPath,
2293 path_re: &Regex,
2294 object_re: &Regex,
2295 dev_mail_re: &Regex,
2296 discovery: &mut AuthSetupDiscovery,
2297) {
2298 let mut stack = vec![(root.to_path_buf(), 0usize)];
2299 while let Some((path, depth)) = stack.pop() {
2300 if discovery.files_inspected >= 1_000 || depth > 8 {
2301 break;
2302 }
2303 let Ok(meta) = std::fs::symlink_metadata(&path) else {
2304 continue;
2305 };
2306 if meta.file_type().is_symlink() {
2307 continue;
2308 }
2309 if meta.is_dir() {
2310 if should_skip_auth_setup_dir(&path) {
2311 continue;
2312 }
2313 if let Ok(entries) = std::fs::read_dir(&path) {
2314 for entry in entries.flatten() {
2315 stack.push((entry.path(), depth + 1));
2316 }
2317 }
2318 continue;
2319 }
2320 if !meta.is_file() || meta.len() > 256 * 1024 || !is_auth_setup_scannable_file(&path) {
2321 continue;
2322 }
2323 let Ok(text) = std::fs::read_to_string(&path) else {
2324 continue;
2325 };
2326 discovery.files_inspected += 1;
2327 let lower = text.to_ascii_lowercase();
2328 if lower.contains("/admin") || lower.contains("requireadmin") || lower.contains("is_admin")
2329 {
2330 discovery.admin_signal = true;
2331 }
2332 if lower.contains("otp")
2333 || lower.contains("one-time")
2334 || lower.contains("one time")
2335 || lower.contains("login code")
2336 || lower.contains("magic code")
2337 || lower.contains("verification code")
2338 || lower.contains("dev-mail")
2339 || lower.contains("dev_mail")
2340 || lower.contains("mailpit")
2341 || lower.contains("mailhog")
2342 {
2343 discovery.otp_signal = true;
2344 }
2345 for cap in path_re.captures_iter(&text) {
2346 if let Some(path) = cap.get(1).map(|m| m.as_str()) {
2347 if auth_setup_path_is_login_candidate(path) {
2348 discovery.login_paths.push(path.to_string());
2349 }
2350 }
2351 }
2352 for cap in dev_mail_re.captures_iter(&text) {
2353 if let Some(path) = cap.get(1).map(|m| m.as_str()) {
2354 discovery.dev_mail_paths.push(path.to_string());
2355 }
2356 }
2357 for cap in object_re.captures_iter(&text) {
2358 if let Some(path) = cap.get(1).map(|m| m.as_str()) {
2359 discovery.object_routes.push(path.to_string());
2360 }
2361 }
2362 discover_auth_setup_credentials_in_text(&text, &mut discovery.credentials);
2363 }
2364}
2365
2366fn should_skip_auth_setup_dir(path: &FsPath) -> bool {
2367 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
2368 return false;
2369 };
2370 matches!(
2371 name,
2372 ".git" | "node_modules" | "target" | "dist" | "build" | ".next" | "coverage" | "vendor"
2373 )
2374}
2375
2376fn is_auth_setup_extension(ext: &str) -> bool {
2377 matches!(
2378 ext.to_ascii_lowercase().as_str(),
2379 "js" | "jsx"
2380 | "ts"
2381 | "tsx"
2382 | "mjs"
2383 | "cjs"
2384 | "rs"
2385 | "py"
2386 | "rb"
2387 | "go"
2388 | "php"
2389 | "java"
2390 | "kt"
2391 | "cs"
2392 | "html"
2393 | "vue"
2394 | "svelte"
2395 | "json"
2396 | "jsonl"
2397 | "toml"
2398 | "yaml"
2399 | "yml"
2400 | "env"
2401 )
2402}
2403
2404fn is_auth_setup_scannable_file(path: &FsPath) -> bool {
2405 if path.extension().and_then(|e| e.to_str()).is_some_and(is_auth_setup_extension) {
2406 return true;
2407 }
2408 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
2409 return false;
2410 };
2411 let lower = name.to_ascii_lowercase();
2412 lower == ".env"
2413 || lower.starts_with(".env.")
2414 || lower.ends_with(".env")
2415 || matches!(lower.as_str(), "seed" | "seeds" | "fixtures")
2416}
2417
2418fn discover_auth_setup_credentials_in_text(
2419 text: &str,
2420 credentials: &mut AuthSetupCredentialDiscovery,
2421) {
2422 let env_re = Regex::new(
2423 r#"(?m)(?:^|[\s,{])["']?([A-Z][A-Z0-9_]*(?:EMAIL|USERNAME|PASSWORD|TOKEN|COOKIE)[A-Z0-9_]*)["']?\s*[:=]\s*["']?([^"'\r\n#;,]+)["']?"#,
2424 )
2425 .expect("auth setup credential env regex");
2426 for cap in env_re.captures_iter(text) {
2427 let Some(name) = cap.get(1).map(|m| m.as_str().trim()) else {
2428 continue;
2429 };
2430 let Some(raw_value) = cap.get(2).map(|m| m.as_str()) else {
2431 continue;
2432 };
2433 let Some(kind) = credential_kind_for_env_name(name) else {
2434 continue;
2435 };
2436 let Some(value) = normalize_credential_literal(raw_value, kind) else {
2437 continue;
2438 };
2439 credentials.exact_env.entry(name.to_string()).or_insert_with(|| value.clone());
2440 if let Some(role_slug) = role_slug_from_env_name(name) {
2441 insert_role_credential(credentials, &role_slug, kind, value);
2442 }
2443 }
2444
2445 let keyed_object_re =
2446 Regex::new(r#"(?is)([A-Za-z][A-Za-z0-9_-]{1,48})\s*:\s*\{([^{}]{0,1600})\}"#)
2447 .expect("auth setup keyed credential object regex");
2448 for cap in keyed_object_re.captures_iter(text) {
2449 let Some(key) = cap.get(1).map(|m| m.as_str()) else {
2450 continue;
2451 };
2452 let Some(body) = cap.get(2).map(|m| m.as_str()) else {
2453 continue;
2454 };
2455 discover_auth_setup_credentials_in_object(Some(key), body, credentials);
2456 }
2457
2458 let object_re =
2459 Regex::new(r#"(?is)\{([^{}]{0,1600})\}"#).expect("auth setup credential object regex");
2460 for cap in object_re.captures_iter(text) {
2461 let Some(body) = cap.get(1).map(|m| m.as_str()) else {
2462 continue;
2463 };
2464 discover_auth_setup_credentials_in_object(None, body, credentials);
2465 }
2466}
2467
2468fn discover_auth_setup_credentials_in_object(
2469 parent_key: Option<&str>,
2470 body: &str,
2471 credentials: &mut AuthSetupCredentialDiscovery,
2472) {
2473 let email = extract_literal_field(body, &["email", "email_address", "emailAddress"])
2474 .and_then(|v| normalize_credential_literal(&v, AuthSetupCredentialKind::Email));
2475 let username = extract_literal_field(body, &["username", "user_name", "login"])
2476 .and_then(|v| normalize_credential_literal(&v, AuthSetupCredentialKind::Username));
2477 let password = extract_literal_field(body, &["password", "pass", "plainPassword"])
2478 .and_then(|v| normalize_credential_literal(&v, AuthSetupCredentialKind::Password));
2479 if password.is_none() && email.is_none() && username.is_none() {
2480 return;
2481 }
2482 let role = extract_literal_field(body, &["role", "type", "kind"]);
2483 let role_slug = role
2484 .as_deref()
2485 .and_then(credential_role_slug)
2486 .or_else(|| parent_key.and_then(credential_role_slug))
2487 .or_else(|| email.as_deref().and_then(role_slug_from_email))
2488 .or_else(|| username.as_deref().and_then(credential_role_slug));
2489 let Some(role_slug) = role_slug else {
2490 return;
2491 };
2492 if let Some(value) = email {
2493 insert_role_credential(credentials, &role_slug, AuthSetupCredentialKind::Email, value);
2494 }
2495 if let Some(value) = username {
2496 insert_role_credential(credentials, &role_slug, AuthSetupCredentialKind::Username, value);
2497 }
2498 if let Some(value) = password {
2499 insert_role_credential(credentials, &role_slug, AuthSetupCredentialKind::Password, value);
2500 }
2501}
2502
2503fn extract_literal_field(body: &str, fields: &[&str]) -> Option<String> {
2504 for field in fields {
2505 let field_re = Regex::new(&format!(
2506 r#"(?i)["']?{}["']?\s*[:=]\s*["']([^"'\r\n]+)["']"#,
2507 regex::escape(field)
2508 ))
2509 .ok()?;
2510 if let Some(value) =
2511 field_re.captures(body).and_then(|cap| cap.get(1).map(|m| m.as_str().trim()))
2512 {
2513 if !value.is_empty() {
2514 return Some(value.to_string());
2515 }
2516 }
2517 }
2518 None
2519}
2520
2521fn normalize_credential_literal(value: &str, kind: AuthSetupCredentialKind) -> Option<String> {
2522 let value = value.trim().trim_matches(',').trim();
2523 if value.is_empty() || value.len() > 512 {
2524 return None;
2525 }
2526 let lower = value.to_ascii_lowercase();
2527 if lower.contains("process.env")
2528 || lower.contains("import.meta.env")
2529 || lower.contains("dotenv")
2530 || value.contains("${")
2531 || value.contains("{{")
2532 || value.contains('<')
2533 || value.contains('>')
2534 || lower.contains("replace_me")
2535 || lower.contains("changeme")
2536 || lower.contains("todo")
2537 {
2538 return None;
2539 }
2540 if kind == AuthSetupCredentialKind::Email && !value.contains('@') {
2541 return None;
2542 }
2543 if kind == AuthSetupCredentialKind::Password
2544 && (lower.contains("bcrypt") || lower.contains("argon2") || value.starts_with("$2"))
2545 {
2546 return None;
2547 }
2548 Some(value.to_string())
2549}
2550
2551fn credential_kind_for_env_name(name: &str) -> Option<AuthSetupCredentialKind> {
2552 let upper = name.to_ascii_uppercase();
2553 if upper.ends_with("_EMAIL") {
2554 Some(AuthSetupCredentialKind::Email)
2555 } else if upper.ends_with("_USERNAME") || upper.ends_with("_USER") || upper.ends_with("_LOGIN")
2556 {
2557 Some(AuthSetupCredentialKind::Username)
2558 } else if upper.ends_with("_PASSWORD") || upper.ends_with("_PASS") {
2559 Some(AuthSetupCredentialKind::Password)
2560 } else if upper.ends_with("_TOKEN") || upper.ends_with("_BEARER_TOKEN") {
2561 Some(AuthSetupCredentialKind::BearerToken)
2562 } else if upper.ends_with("_COOKIE") || upper.ends_with("_SESSION_COOKIE") {
2563 Some(AuthSetupCredentialKind::Cookie)
2564 } else {
2565 None
2566 }
2567}
2568
2569fn role_slug_from_env_name(name: &str) -> Option<String> {
2570 let mut stem = name.trim().trim_start_matches("NYX_AGENT_").to_string();
2571 for suffix in [
2572 "_SESSION_COOKIE",
2573 "_BEARER_TOKEN",
2574 "_PASSWORD",
2575 "_USERNAME",
2576 "_COOKIE",
2577 "_EMAIL",
2578 "_LOGIN",
2579 "_TOKEN",
2580 "_PASS",
2581 "_USER",
2582 ] {
2583 if stem.to_ascii_uppercase().ends_with(suffix) {
2584 let new_len = stem.len().saturating_sub(suffix.len());
2585 stem.truncate(new_len);
2586 break;
2587 }
2588 }
2589 credential_role_slug(&stem)
2590}
2591
2592fn role_slug_from_email(email: &str) -> Option<String> {
2593 let local = email.split('@').next()?.split('+').next().unwrap_or_default();
2594 credential_role_slug(local)
2595}
2596
2597fn credential_role_slug(value: &str) -> Option<String> {
2598 let value = value.trim();
2599 if value.is_empty() || credential_role_slug_is_generic(value) {
2600 return None;
2601 }
2602 let mut out = String::new();
2603 let mut prev_lower_or_digit = false;
2604 for ch in value.chars() {
2605 if ch.is_ascii_alphanumeric() {
2606 if ch.is_ascii_uppercase() && prev_lower_or_digit && !out.ends_with('_') {
2607 out.push('_');
2608 }
2609 out.push(ch.to_ascii_uppercase());
2610 prev_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
2611 } else {
2612 if !out.ends_with('_') {
2613 out.push('_');
2614 }
2615 prev_lower_or_digit = false;
2616 }
2617 }
2618 let out = out.trim_matches('_').to_string();
2619 if out.is_empty() || credential_role_slug_is_generic(&out) {
2620 None
2621 } else {
2622 Some(out)
2623 }
2624}
2625
2626fn credential_role_slug_is_generic(value: &str) -> bool {
2627 matches!(
2628 value.to_ascii_lowercase().as_str(),
2629 "user"
2630 | "users"
2631 | "account"
2632 | "accounts"
2633 | "profile"
2634 | "profiles"
2635 | "credential"
2636 | "credentials"
2637 | "auth"
2638 | "login"
2639 | "data"
2640 | "test"
2641 | "tests"
2642 | "test_user"
2643 | "test_users"
2644 )
2645}
2646
2647fn insert_role_credential(
2648 credentials: &mut AuthSetupCredentialDiscovery,
2649 role_slug: &str,
2650 kind: AuthSetupCredentialKind,
2651 value: String,
2652) {
2653 let entry = credentials.by_role.entry(role_slug.to_string()).or_default();
2654 let slot = match kind {
2655 AuthSetupCredentialKind::Email => &mut entry.email,
2656 AuthSetupCredentialKind::Username => &mut entry.username,
2657 AuthSetupCredentialKind::Password => &mut entry.password,
2658 AuthSetupCredentialKind::BearerToken => &mut entry.bearer_token,
2659 AuthSetupCredentialKind::Cookie => &mut entry.cookie,
2660 AuthSetupCredentialKind::ExactOnly => return,
2661 };
2662 if slot.as_deref().is_none_or(str::is_empty) {
2663 *slot = Some(value);
2664 }
2665}
2666
2667fn auth_setup_path_is_login_candidate(path: &str) -> bool {
2668 let lower = path.to_ascii_lowercase();
2669 lower.contains("login")
2670 || lower.contains("signin")
2671 || lower.contains("sign-in")
2672 || lower.contains("/session")
2673 || lower.contains("/auth")
2674}
2675
2676fn dedupe_setup_paths(paths: Vec<String>) -> Vec<String> {
2677 let mut seen = BTreeSet::new();
2678 let mut out = Vec::new();
2679 for path in paths {
2680 let trimmed = path.trim();
2681 if trimmed.is_empty() || trimmed.contains("..") {
2682 continue;
2683 }
2684 let normalized = trimmed.trim_end_matches('/').to_string();
2685 if seen.insert(normalized.clone()) {
2686 out.push(normalized);
2687 }
2688 }
2689 out.sort_by_key(|p| {
2690 let lower = p.to_ascii_lowercase();
2691 (!lower.contains("login") && !lower.contains("signin"), !lower.contains("/api/"), p.len())
2692 });
2693 out
2694}
2695
2696fn auth_setup_roles(requested: &[String], discovery: &AuthSetupDiscovery) -> Vec<String> {
2697 let mut roles =
2698 requested.iter().filter_map(|role| normalize_role_name(role)).collect::<Vec<_>>();
2699 if roles.is_empty() {
2700 roles.extend(["user_a".to_string(), "user_b".to_string()]);
2701 if discovery.admin_signal {
2702 roles.push("admin".to_string());
2703 }
2704 }
2705 let mut seen = BTreeSet::new();
2706 roles.retain(|role| seen.insert(role.clone()));
2707 roles
2708}
2709
2710fn normalize_role_name(role: &str) -> Option<String> {
2711 let role = role.trim();
2712 if role.is_empty() || role.eq_ignore_ascii_case("anonymous") {
2713 return None;
2714 }
2715 Some(role.to_string())
2716}
2717
2718#[allow(clippy::type_complexity)]
2719fn apply_agent_auth_setup_output(
2720 profiles: &mut Vec<ProjectAuthProfile>,
2721 output: AuthSetupAgentOutput,
2722 fallback_login_path: Option<String>,
2723 seeded_objects: &[ProjectAuthOwnedObject],
2724) -> (Vec<String>, Vec<String>, Vec<String>, AuthSetupVerification, Option<String>, usize, usize) {
2725 let roles = if output.roles.is_empty() {
2726 output
2727 .profiles
2728 .iter()
2729 .filter_map(|profile| normalize_role_name(&profile.role))
2730 .collect::<Vec<_>>()
2731 } else {
2732 output.roles.clone()
2733 };
2734 let login_paths = output.login_paths.clone();
2735 let object_routes = output.object_routes.clone();
2736 let verification = output.verification.clone();
2737 let message = Some(output.message);
2738 let (profiles_added, profiles_updated) = merge_auth_setup_profile_records(
2739 profiles,
2740 output.profiles,
2741 fallback_login_path,
2742 seeded_objects,
2743 );
2744 (roles, login_paths, object_routes, verification, message, profiles_added, profiles_updated)
2745}
2746
2747fn merge_auth_setup_profile_records(
2748 profiles: &mut Vec<ProjectAuthProfile>,
2749 candidates: Vec<ProjectAuthProfile>,
2750 fallback_login_path: Option<String>,
2751 seeded_objects: &[ProjectAuthOwnedObject],
2752) -> (usize, usize) {
2753 let mut added = 0usize;
2754 let mut updated = 0usize;
2755 for candidate in candidates {
2756 let Some(candidate) = finalize_auth_setup_candidate(
2757 candidate,
2758 fallback_login_path.as_deref(),
2759 seeded_objects,
2760 ) else {
2761 continue;
2762 };
2763 if let Some(existing) = profiles.iter_mut().find(|profile| profile.role == candidate.role) {
2764 if merge_auth_setup_candidate(existing, candidate) {
2765 updated += 1;
2766 }
2767 } else {
2768 profiles.push(candidate);
2769 added += 1;
2770 }
2771 }
2772 (added, updated)
2773}
2774
2775fn finalize_auth_setup_candidate(
2776 mut profile: ProjectAuthProfile,
2777 fallback_login_path: Option<&str>,
2778 seeded_objects: &[ProjectAuthOwnedObject],
2779) -> Option<ProjectAuthProfile> {
2780 profile.role = normalize_role_name(&profile.role)?;
2781 normalize_auth_setup_identity_refs(&mut profile);
2782 normalize_auth_setup_otp_mode(&mut profile);
2783 if profile.mode == ProjectAuthMode::Anonymous {
2784 profile.mode = ProjectAuthMode::AiAuto;
2785 }
2786 if profile.label.as_deref().is_none_or(|label| label.trim().is_empty()) {
2787 profile.label = Some(format!("AI setup {}", profile.role));
2788 }
2789 if profile.login_url.as_deref().is_none_or(|url| url.trim().is_empty()) {
2790 profile.login_url = fallback_login_path.map(str::to_string);
2791 }
2792 if !auth_setup_profile_has_secret_ref(&profile) {
2793 let role_env = env_role_slug(&profile.role);
2794 profile.username_env = Some(format!("NYX_AGENT_{role_env}_USERNAME"));
2795 profile.password_env = Some(format!("NYX_AGENT_{role_env}_PASSWORD"));
2796 }
2797 if profile.owned_objects.is_empty() {
2798 profile.owned_objects = seeded_objects.to_vec();
2799 }
2800 Some(profile)
2801}
2802
2803fn normalize_auth_setup_identity_refs(profile: &mut ProjectAuthProfile) {
2804 let Some(username_env) = profile.username_env.as_deref().map(str::trim) else {
2805 return;
2806 };
2807 if !profile.login_email_env.as_deref().is_none_or(|v| v.trim().is_empty()) {
2808 return;
2809 }
2810 if credential_kind_for_env_name(username_env) == Some(AuthSetupCredentialKind::Email) {
2811 profile.login_email_env = Some(username_env.to_string());
2812 profile.username_env = None;
2813 }
2814}
2815
2816fn normalize_auth_setup_otp_mode(profile: &mut ProjectAuthProfile) {
2817 if profile
2818 .otp_source
2819 .as_ref()
2820 .is_some_and(|source| source.kind == ProjectOtpSourceKind::Mailbox)
2821 {
2822 profile.mode = ProjectAuthMode::OtpEmailMailbox;
2823 }
2824}
2825
2826fn merge_auth_setup_candidate(
2827 existing: &mut ProjectAuthProfile,
2828 candidate: ProjectAuthProfile,
2829) -> bool {
2830 let before = existing.clone();
2831 existing.mode = candidate.mode;
2832 merge_option(&mut existing.label, candidate.label);
2833 merge_option(&mut existing.session_cache_ttl_seconds, candidate.session_cache_ttl_seconds);
2834 merge_option(&mut existing.session_import_path, candidate.session_import_path);
2835 merge_option(&mut existing.login_url, candidate.login_url);
2836 merge_option(&mut existing.username, candidate.username);
2837 merge_option(&mut existing.username_env, candidate.username_env);
2838 merge_option(&mut existing.login_email_env, candidate.login_email_env);
2839 merge_option(&mut existing.password_env, candidate.password_env);
2840 merge_option(&mut existing.password_secret_ref, candidate.password_secret_ref);
2841 merge_option(&mut existing.cookie_env, candidate.cookie_env);
2842 merge_option(&mut existing.bearer_token_env, candidate.bearer_token_env);
2843 if !candidate.headers.is_empty() {
2844 existing.headers = candidate.headers;
2845 }
2846 merge_option(&mut existing.otp_source, candidate.otp_source);
2847 if !candidate.post_login_assertions.is_empty() {
2848 existing.post_login_assertions = candidate.post_login_assertions;
2849 }
2850 merge_option(&mut existing.post_login_assertion, candidate.post_login_assertion);
2851 merge_option(&mut existing.custom_command, candidate.custom_command);
2852 if !candidate.owned_objects.is_empty() {
2853 existing.owned_objects = candidate.owned_objects;
2854 }
2855 *existing != before
2856}
2857
2858fn merge_option<T>(slot: &mut Option<T>, candidate: Option<T>) {
2859 if candidate.is_some() {
2860 *slot = candidate;
2861 }
2862}
2863
2864fn auth_setup_profile_has_secret_ref(profile: &ProjectAuthProfile) -> bool {
2865 profile.session_import_path.is_some()
2866 || profile.username_env.is_some()
2867 || profile.login_email_env.is_some()
2868 || profile.password_env.is_some()
2869 || profile.password_secret_ref.is_some()
2870 || profile.cookie_env.is_some()
2871 || profile.bearer_token_env.is_some()
2872 || !profile.headers.is_empty()
2873 || profile.custom_command.is_some()
2874}
2875
2876fn apply_discovered_otp_hints(
2877 runtime_profile: &mut ProjectRuntimeProfile,
2878 target_base_url: Option<&str>,
2879 discovery: &AuthSetupDiscovery,
2880) {
2881 if !discovery.otp_signal && discovery.dev_mail_paths.is_empty() {
2882 return;
2883 }
2884 let mailbox_url =
2885 discovery.dev_mail_paths.first().and_then(|path| absolute_local_url(target_base_url, path));
2886 for profile in &mut runtime_profile.auth_profiles {
2887 if profile.mode != ProjectAuthMode::AiAuto
2888 && profile.mode != ProjectAuthMode::OtpEmailMailbox
2889 {
2890 continue;
2891 }
2892 if mailbox_url.is_some() {
2893 profile.mode = ProjectAuthMode::OtpEmailMailbox;
2894 let email_env = profile
2895 .login_email_env
2896 .clone()
2897 .or_else(|| profile.username_env.clone())
2898 .or_else(|| Some(format!("NYX_AGENT_{}_EMAIL", env_role_slug(&profile.role))));
2899 let source = profile.otp_source.get_or_insert_with(|| ProjectOtpSourceConfig {
2900 kind: ProjectOtpSourceKind::Mailbox,
2901 mailbox_url: None,
2902 email_env: None,
2903 subject_contains: Some("code".to_string()),
2904 body_regex: Some(r"\b(\d{4,8})\b".to_string()),
2905 imap_url_env: None,
2906 imap_username_env: None,
2907 imap_password_env: None,
2908 });
2909 source.kind = ProjectOtpSourceKind::Mailbox;
2910 if source.mailbox_url.as_deref().is_none_or(|url| url.trim().is_empty()) {
2911 source.mailbox_url = mailbox_url.clone();
2912 }
2913 if source.email_env.as_deref().is_none_or(|env| env.trim().is_empty()) {
2914 source.email_env = email_env;
2915 }
2916 if source.subject_contains.as_deref().is_none_or(|value| value.trim().is_empty()) {
2917 source.subject_contains = Some("code".to_string());
2918 }
2919 if source.body_regex.as_deref().is_none_or(|value| value.trim().is_empty()) {
2920 source.body_regex = Some(r"\b(\d{4,8})\b".to_string());
2921 }
2922 }
2923 }
2924}
2925
2926fn absolute_local_url(target_base_url: Option<&str>, path: &str) -> Option<String> {
2927 let path = path.trim();
2928 if path.starts_with("http://") || path.starts_with("https://") {
2929 return Some(path.to_string());
2930 }
2931 let target = reqwest::Url::parse(target_base_url?).ok()?;
2932 let mut url = target.join(path).ok()?;
2933 if !url.path().ends_with('/') {
2934 let next = format!("{}/", url.path());
2935 url.set_path(&next);
2936 }
2937 Some(url.to_string())
2938}
2939
2940fn apply_discovered_auth_env_values(
2941 runtime_profile: &mut ProjectRuntimeProfile,
2942 credentials: &AuthSetupCredentialDiscovery,
2943) -> AuthSetupEnvResolution {
2944 let mut report = AuthSetupEnvResolution::default();
2945 let auth_profiles = runtime_profile.auth_profiles.clone();
2946 for profile in &auth_profiles {
2947 let role_slug = env_role_slug(&profile.role);
2948 maybe_apply_auth_env_value(
2949 &mut runtime_profile.env_vars,
2950 profile.username_env.as_deref(),
2951 &role_slug,
2952 AuthSetupCredentialKind::Username,
2953 credentials,
2954 &mut report,
2955 );
2956 maybe_apply_auth_env_value(
2957 &mut runtime_profile.env_vars,
2958 profile.login_email_env.as_deref(),
2959 &role_slug,
2960 AuthSetupCredentialKind::Email,
2961 credentials,
2962 &mut report,
2963 );
2964 maybe_apply_auth_env_value(
2965 &mut runtime_profile.env_vars,
2966 profile.password_env.as_deref(),
2967 &role_slug,
2968 AuthSetupCredentialKind::Password,
2969 credentials,
2970 &mut report,
2971 );
2972 maybe_apply_auth_env_value(
2973 &mut runtime_profile.env_vars,
2974 profile.bearer_token_env.as_deref(),
2975 &role_slug,
2976 AuthSetupCredentialKind::BearerToken,
2977 credentials,
2978 &mut report,
2979 );
2980 maybe_apply_auth_env_value(
2981 &mut runtime_profile.env_vars,
2982 profile.cookie_env.as_deref(),
2983 &role_slug,
2984 AuthSetupCredentialKind::Cookie,
2985 credentials,
2986 &mut report,
2987 );
2988 for header in &profile.headers {
2989 maybe_apply_auth_env_value(
2990 &mut runtime_profile.env_vars,
2991 header.value_env.as_deref(),
2992 &role_slug,
2993 AuthSetupCredentialKind::ExactOnly,
2994 credentials,
2995 &mut report,
2996 );
2997 }
2998 if let Some(source) = &profile.otp_source {
2999 maybe_apply_auth_env_value(
3000 &mut runtime_profile.env_vars,
3001 source.email_env.as_deref(),
3002 &role_slug,
3003 AuthSetupCredentialKind::Email,
3004 credentials,
3005 &mut report,
3006 );
3007 }
3008 }
3009
3010 let resolved_env = runtime_env_values(&runtime_profile.env_vars);
3011 let mut seen = BTreeSet::new();
3012 for profile in &runtime_profile.auth_profiles {
3013 for env in auth_setup_env_refs(profile) {
3014 if !seen.insert(env.clone()) {
3015 continue;
3016 }
3017 if resolved_env.get(&env).is_some_and(|value| !value.is_empty())
3018 || std::env::var_os(&env).is_some()
3019 {
3020 report.refs_resolved.push(env);
3021 } else {
3022 report.refs_missing.push(env);
3023 }
3024 }
3025 }
3026 report.refs_resolved.sort();
3027 report.refs_missing.sort();
3028 report
3029}
3030
3031fn maybe_apply_auth_env_value(
3032 env_vars: &mut Vec<ProjectRuntimeEnvVar>,
3033 env_name: Option<&str>,
3034 role_slug: &str,
3035 kind: AuthSetupCredentialKind,
3036 credentials: &AuthSetupCredentialDiscovery,
3037 report: &mut AuthSetupEnvResolution,
3038) {
3039 let Some(env_name) = env_name.map(str::trim).filter(|name| !name.is_empty()) else {
3040 return;
3041 };
3042 let Some(value) = credential_value_for_env(env_name, role_slug, kind, credentials) else {
3043 return;
3044 };
3045 let secret = matches!(
3046 kind,
3047 AuthSetupCredentialKind::Password
3048 | AuthSetupCredentialKind::BearerToken
3049 | AuthSetupCredentialKind::Cookie
3050 | AuthSetupCredentialKind::ExactOnly
3051 );
3052 upsert_runtime_env_value(env_vars, env_name, &value, secret, report);
3053}
3054
3055fn credential_value_for_env(
3056 env_name: &str,
3057 role_slug: &str,
3058 kind: AuthSetupCredentialKind,
3059 credentials: &AuthSetupCredentialDiscovery,
3060) -> Option<String> {
3061 if let Some(value) = credentials.exact_env.get(env_name).filter(|value| !value.is_empty()) {
3062 return Some(value.clone());
3063 }
3064 let role_credentials = credentials.by_role.get(role_slug)?;
3065 match kind {
3066 AuthSetupCredentialKind::Email => role_credentials.email.clone(),
3067 AuthSetupCredentialKind::Username => {
3068 role_credentials.username.clone().or_else(|| role_credentials.email.clone())
3069 }
3070 AuthSetupCredentialKind::Password => role_credentials.password.clone(),
3071 AuthSetupCredentialKind::BearerToken => role_credentials.bearer_token.clone(),
3072 AuthSetupCredentialKind::Cookie => role_credentials.cookie.clone(),
3073 AuthSetupCredentialKind::ExactOnly => None,
3074 }
3075}
3076
3077fn upsert_runtime_env_value(
3078 env_vars: &mut Vec<ProjectRuntimeEnvVar>,
3079 name: &str,
3080 value: &str,
3081 secret: bool,
3082 report: &mut AuthSetupEnvResolution,
3083) {
3084 if let Some(existing) = env_vars.iter_mut().find(|var| var.name.trim() == name) {
3085 if existing.value.is_empty() {
3086 existing.value = value.to_string();
3087 existing.secret = existing.secret || secret;
3088 report.values_filled += 1;
3089 } else if secret && !existing.secret {
3090 existing.secret = true;
3091 }
3092 return;
3093 }
3094 env_vars.push(ProjectRuntimeEnvVar {
3095 name: name.to_string(),
3096 value: value.to_string(),
3097 secret,
3098 });
3099 report.values_added += 1;
3100}
3101
3102fn runtime_env_values(env_vars: &[ProjectRuntimeEnvVar]) -> HashMap<String, String> {
3103 env_vars
3104 .iter()
3105 .filter_map(|var| {
3106 let name = var.name.trim();
3107 if name.is_empty() {
3108 None
3109 } else {
3110 Some((name.to_string(), var.value.clone()))
3111 }
3112 })
3113 .collect()
3114}
3115
3116fn auth_setup_env_refs(profile: &ProjectAuthProfile) -> Vec<String> {
3117 let mut refs = Vec::new();
3118 refs.extend(profile.username_env.iter().cloned());
3119 refs.extend(profile.login_email_env.iter().cloned());
3120 refs.extend(profile.password_env.iter().cloned());
3121 refs.extend(profile.cookie_env.iter().cloned());
3122 refs.extend(profile.bearer_token_env.iter().cloned());
3123 refs.extend(profile.headers.iter().filter_map(|header| header.value_env.clone()));
3124 if let Some(source) = &profile.otp_source {
3125 refs.extend(source.email_env.iter().cloned());
3126 refs.extend(source.imap_url_env.iter().cloned());
3127 refs.extend(source.imap_username_env.iter().cloned());
3128 refs.extend(source.imap_password_env.iter().cloned());
3129 }
3130 refs.into_iter().map(|env| env.trim().to_string()).filter(|env| !env.is_empty()).collect()
3131}
3132
3133fn apply_auth_env_resolution_to_verification(
3134 verification: &mut AuthSetupVerification,
3135 report: &AuthSetupEnvResolution,
3136) {
3137 let saved = report.values_added + report.values_filled;
3138 if saved > 0 {
3139 verification
3140 .checks
3141 .push(format!("Saved {saved} auth credential env value(s) from repo-local hints."));
3142 }
3143 if !report.refs_resolved.is_empty() {
3144 verification.checks.push(format!(
3145 "Resolved {} auth env ref(s) for generated profiles.",
3146 report.refs_resolved.len()
3147 ));
3148 }
3149 if !report.refs_missing.is_empty() {
3150 verification
3151 .warnings
3152 .push(format!("Missing auth env value(s): {}.", report.refs_missing.join(", ")));
3153 verification.status = AuthSetupVerificationStatus::NeedsReview;
3154 }
3155}
3156
3157fn auth_env_resolution_message(report: &AuthSetupEnvResolution) -> Option<String> {
3158 if report.refs_missing.is_empty() {
3159 return None;
3160 }
3161 Some(format!("Auth setup still needs value(s) for {}.", report.refs_missing.join(", ")))
3162}
3163
3164fn static_auth_setup_verification(
3165 discovery: &AuthSetupDiscovery,
3166 fallback_warning: Option<String>,
3167) -> AuthSetupVerification {
3168 let mut checks = Vec::new();
3169 let mut warnings = Vec::new();
3170 if discovery.files_inspected > 0 {
3171 checks.push(format!(
3172 "Static repo scan inspected {} source file(s).",
3173 discovery.files_inspected
3174 ));
3175 } else {
3176 warnings.push("No local repo files were available for auth setup.".to_string());
3177 }
3178 if discovery.login_paths.is_empty() {
3179 warnings.push("No login or session route was discovered.".to_string());
3180 } else {
3181 checks.push(format!("Discovered login/session path {}.", discovery.login_paths[0]));
3182 }
3183 if discovery.object_routes.is_empty() {
3184 warnings.push("No object ownership routes were discovered.".to_string());
3185 } else {
3186 checks.push(format!(
3187 "Discovered {} object ownership route hint(s).",
3188 discovery.object_routes.len()
3189 ));
3190 }
3191 if !discovery.dev_mail_paths.is_empty() {
3192 checks.push(format!("Discovered dev-mail route {}.", discovery.dev_mail_paths[0]));
3193 warnings.push(
3194 "Detected OTP/dev-mail auth; profile setup recorded a mailbox OTP source, but live OTP browser capture is not implemented yet."
3195 .to_string(),
3196 );
3197 } else if discovery.otp_signal {
3198 warnings.push(
3199 "Detected OTP-like auth code hints, but no local dev-mail mailbox route was discovered."
3200 .to_string(),
3201 );
3202 }
3203 if let Some(warning) = fallback_warning {
3204 warnings.push(warning);
3205 }
3206 AuthSetupVerification {
3207 status: if warnings.is_empty() {
3208 AuthSetupVerificationStatus::Verified
3209 } else if discovery.files_inspected == 0 {
3210 AuthSetupVerificationStatus::Skipped
3211 } else {
3212 AuthSetupVerificationStatus::NeedsReview
3213 },
3214 checks,
3215 warnings,
3216 }
3217}
3218
3219fn auth_setup_response_message(
3220 agent_used: bool,
3221 profiles_added: usize,
3222 profiles_updated: usize,
3223 files_inspected: usize,
3224 verification: &AuthSetupVerification,
3225 agent_message: Option<String>,
3226 fallback_warning: Option<String>,
3227) -> String {
3228 if let Some(message) = agent_message.filter(|message| !message.trim().is_empty()) {
3229 if let Some(warning) = fallback_warning {
3230 return format!("{message} {warning}");
3231 }
3232 return message;
3233 }
3234 let changed = profiles_added + profiles_updated;
3235 let verification_phrase = match verification.status {
3236 AuthSetupVerificationStatus::Verified => "verification passed",
3237 AuthSetupVerificationStatus::NeedsReview => "verification needs review",
3238 AuthSetupVerificationStatus::Skipped => "verification skipped",
3239 };
3240 let mut message = if agent_used {
3241 if changed == 0 {
3242 format!("Auth exploration agent kept the existing role profiles unchanged; {verification_phrase}.")
3243 } else {
3244 format!(
3245 "Auth exploration agent saved {changed} repo-specific role profile(s); {verification_phrase}."
3246 )
3247 }
3248 } else if changed == 0 {
3249 format!("Auth setup kept the existing role profiles unchanged; {verification_phrase}.")
3250 } else {
3251 format!(
3252 "Auth setup saved {changed} role profile(s) from {files_inspected} inspected source file(s); {verification_phrase}."
3253 )
3254 };
3255 if let Some(warning) = fallback_warning {
3256 message.push(' ');
3257 message.push_str(&warning);
3258 }
3259 message
3260}
3261
3262fn merge_auth_setup_profiles(
3263 profiles: &mut Vec<ProjectAuthProfile>,
3264 roles: &[String],
3265 login_path: Option<String>,
3266 seeded_objects: &[ProjectAuthOwnedObject],
3267) -> (usize, usize) {
3268 let mut added = 0usize;
3269 let mut updated = 0usize;
3270 for role in roles {
3271 if let Some(existing) = profiles.iter_mut().find(|profile| profile.role == *role) {
3272 if fill_auth_setup_profile(existing, login_path.as_deref(), seeded_objects) {
3273 updated += 1;
3274 }
3275 } else {
3276 profiles.push(auth_setup_profile(role, login_path.as_deref(), seeded_objects));
3277 added += 1;
3278 }
3279 }
3280 (added, updated)
3281}
3282
3283fn auth_setup_profile(
3284 role: &str,
3285 login_path: Option<&str>,
3286 seeded_objects: &[ProjectAuthOwnedObject],
3287) -> ProjectAuthProfile {
3288 let role_env = env_role_slug(role);
3289 ProjectAuthProfile {
3290 role: role.to_string(),
3291 role_aliases: Vec::new(),
3292 mode: ProjectAuthMode::AiAuto,
3293 label: Some(format!("AI setup {role}")),
3294 tenant: None,
3295 session_cache_ttl_seconds: None,
3296 session_import_path: None,
3297 login_url: login_path.map(str::to_string),
3298 username: None,
3299 username_env: Some(format!("NYX_AGENT_{role_env}_USERNAME")),
3300 login_email_env: None,
3301 password_env: Some(format!("NYX_AGENT_{role_env}_PASSWORD")),
3302 password_secret_ref: None,
3303 cookie_env: None,
3304 bearer_token_env: None,
3305 headers: Vec::new(),
3306 otp_source: None,
3307 post_login_assertions: Vec::new(),
3308 post_login_assertion: None,
3309 custom_command: None,
3310 owned_objects: seeded_objects.to_vec(),
3311 }
3312}
3313
3314fn fill_auth_setup_profile(
3315 profile: &mut ProjectAuthProfile,
3316 login_path: Option<&str>,
3317 seeded_objects: &[ProjectAuthOwnedObject],
3318) -> bool {
3319 let mut changed = false;
3320 if profile.login_url.as_deref().is_none_or(|v| v.trim().is_empty()) {
3321 if let Some(login_path) = login_path {
3322 profile.login_url = Some(login_path.to_string());
3323 changed = true;
3324 }
3325 }
3326 let role_env = env_role_slug(&profile.role);
3327 if profile.username_env.as_deref().is_none_or(|v| v.trim().is_empty())
3328 && profile.username.as_deref().is_none_or(|v| v.trim().is_empty())
3329 && profile.login_email_env.as_deref().is_none_or(|v| v.trim().is_empty())
3330 {
3331 profile.username_env = Some(format!("NYX_AGENT_{role_env}_USERNAME"));
3332 changed = true;
3333 }
3334 if profile.password_env.as_deref().is_none_or(|v| v.trim().is_empty()) {
3335 profile.password_env = Some(format!("NYX_AGENT_{role_env}_PASSWORD"));
3336 changed = true;
3337 }
3338 if profile.owned_objects.is_empty() && !seeded_objects.is_empty() {
3339 profile.owned_objects = seeded_objects.to_vec();
3340 changed = true;
3341 }
3342 changed
3343}
3344
3345fn env_role_slug(role: &str) -> String {
3346 let mut out = String::new();
3347 for ch in role.chars() {
3348 if ch.is_ascii_alphanumeric() {
3349 out.push(ch.to_ascii_uppercase());
3350 } else if !out.ends_with('_') {
3351 out.push('_');
3352 }
3353 }
3354 let out = out.trim_matches('_').to_string();
3355 if out.is_empty() {
3356 "ROLE".to_string()
3357 } else {
3358 out
3359 }
3360}
3361
3362fn launch_profile_input_from_runtime(
3363 profile: &ProjectRuntimeProfile,
3364 fallback_target: Option<&str>,
3365) -> ProjectLaunchProfileInput {
3366 let build_steps: Vec<nyx_agent_types::product::LaunchStep> =
3367 profile.build_commands.iter().map(runtime_command_to_launch_step).collect();
3368 let start_steps: Vec<nyx_agent_types::product::LaunchStep> =
3369 profile.start_commands.iter().map(runtime_command_to_launch_step).collect();
3370 let mut health_checks = Vec::new();
3371 if let Some(url) = normalize_optional_string(profile.health_check_url.as_deref()) {
3372 health_checks.push(nyx_agent_types::product::LaunchHealthCheck {
3373 kind: "http".to_string(),
3374 url: Some(url),
3375 host: None,
3376 port: None,
3377 command: None,
3378 timeout_seconds: profile.timeout_seconds,
3379 });
3380 }
3381 if let Some(cmd) = &profile.health_check_command {
3382 health_checks.push(nyx_agent_types::product::LaunchHealthCheck {
3383 kind: "command".to_string(),
3384 url: None,
3385 host: None,
3386 port: None,
3387 command: Some(runtime_command_to_launch_step(cmd)),
3388 timeout_seconds: cmd.timeout_seconds.or(profile.timeout_seconds),
3389 });
3390 }
3391 let mut target_urls = Vec::new();
3392 if let Some(target) = normalize_optional_string(profile.target_base_url.as_deref())
3393 .or_else(|| normalize_optional_string(fallback_target))
3394 {
3395 target_urls.push(target);
3396 }
3397 let mut env_refs = Vec::new();
3398 if let Some(env_file) = normalize_optional_string(profile.env_file.as_deref()) {
3399 env_refs.push(nyx_agent_types::product::LaunchEnvRef {
3400 kind: "env-file".to_string(),
3401 value: env_file,
3402 secret: true,
3403 });
3404 }
3405 for var in &profile.env_vars {
3406 if var.name.trim().is_empty() {
3407 continue;
3408 }
3409 env_refs.push(nyx_agent_types::product::LaunchEnvRef {
3410 kind: "env-var".to_string(),
3411 value: var.name.trim().to_string(),
3412 secret: var.secret,
3413 });
3414 }
3415 let mode = if build_steps.is_empty() && start_steps.is_empty() {
3416 "already-running"
3417 } else {
3418 "custom-commands"
3419 };
3420 ProjectLaunchProfileInput {
3421 name: Some("local dev".to_string()),
3422 mode: Some(mode.to_string()),
3423 build_steps,
3424 start_steps,
3425 seed_steps: Vec::new(),
3426 reset_steps: Vec::new(),
3427 login_steps: Vec::new(),
3428 stop_steps: Vec::new(),
3429 health_checks,
3430 target_urls,
3431 env_refs,
3432 working_dirs: Vec::new(),
3433 }
3434}
3435
3436fn runtime_command_to_launch_step(
3437 cmd: &nyx_agent_types::project::ProjectRuntimeCommand,
3438) -> nyx_agent_types::product::LaunchStep {
3439 nyx_agent_types::product::LaunchStep {
3440 command: cmd.command.clone(),
3441 repo_id: None,
3442 repo_name: cmd.repo_name.clone(),
3443 working_directory: cmd.working_directory.clone(),
3444 timeout_seconds: cmd.timeout_seconds,
3445 stdin: None,
3446 }
3447}
3448
3449async fn delete_project(
3450 State(s): State<ServerState>,
3451 Path(id): Path<String>,
3452) -> Result<StatusBody, ApiError> {
3453 let affected = s.store.projects().delete(&id).await?;
3454 if affected == 0 {
3455 return Err(ApiError::NotFound(format!("project `{id}` not found")));
3456 }
3457 Ok(StatusBody::ok(format!("deleted {affected} project row(s); repos cascaded")))
3458}
3459
3460fn uuid_like(name: &str, now_ms: i64) -> String {
3464 let slug: String = name
3465 .chars()
3466 .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
3467 .collect();
3468 let trimmed: String = slug
3469 .split('-')
3470 .filter(|s| !s.is_empty())
3471 .collect::<Vec<_>>()
3472 .join("-")
3473 .chars()
3474 .take(32)
3475 .collect();
3476 format!("{trimmed}-{now_ms:x}")
3477}
3478
3479async fn require_project(s: &ServerState, project_id: &str) -> Result<ProjectRecord, ApiError> {
3480 s.store
3481 .projects()
3482 .get(project_id)
3483 .await?
3484 .ok_or_else(|| ApiError::NotFound(format!("project `{project_id}` not found")))
3485}
3486
3487async fn require_project_integration(
3488 s: &ServerState,
3489 project_id: &str,
3490 integration_id: &str,
3491) -> Result<ProjectIntegrationRecord, ApiError> {
3492 let row =
3493 s.store.integrations().get(integration_id).await?.ok_or_else(|| {
3494 ApiError::NotFound(format!("integration `{integration_id}` not found"))
3495 })?;
3496 if row.project_id != project_id {
3497 return Err(ApiError::NotFound(format!(
3498 "integration `{integration_id}` not found in project `{project_id}`"
3499 )));
3500 }
3501 Ok(row)
3502}
3503
3504fn validate_integration_name(raw: &str) -> Result<String, ApiError> {
3505 let name = raw.trim();
3506 if name.is_empty() {
3507 return Err(ApiError::BadRequest("integration name is required".to_string()));
3508 }
3509 if name.len() > 80 {
3510 return Err(ApiError::BadRequest(
3511 "integration name must be 80 characters or less".to_string(),
3512 ));
3513 }
3514 Ok(name.to_string())
3515}
3516
3517fn validate_integration_events(
3518 events: &[nyx_agent_types::integration::ProjectIntegrationEvent],
3519) -> Result<(), ApiError> {
3520 if events.is_empty() {
3521 return Err(ApiError::BadRequest("select at least one integration event".to_string()));
3522 }
3523 Ok(())
3524}
3525
3526async fn get_default_launch_profile(
3527 State(s): State<ServerState>,
3528 Path(project_id): Path<String>,
3529) -> Result<Json<nyx_agent_types::product::ProjectLaunchProfile>, ApiError> {
3530 require_project(&s, &project_id).await?;
3531 s.store.launch_profiles().get_default(&project_id).await?.map(Json).ok_or_else(|| {
3532 ApiError::NotFound(format!("default launch profile for project `{project_id}` not found"))
3533 })
3534}
3535
3536async fn patch_default_launch_profile(
3537 State(s): State<ServerState>,
3538 Path(project_id): Path<String>,
3539 Json(input): Json<ProjectLaunchProfileInput>,
3540) -> Result<Json<nyx_agent_types::product::ProjectLaunchProfile>, ApiError> {
3541 require_project(&s, &project_id).await?;
3542 validate_launch_profile_input(&input)?;
3543 let row = s.store.launch_profiles().upsert_default(&project_id, &input, now_epoch_ms()).await?;
3544 Ok(Json(row))
3545}
3546
3547fn validate_launch_profile_input(input: &ProjectLaunchProfileInput) -> Result<(), ApiError> {
3548 let mode = input.mode.as_deref().unwrap_or("custom-commands");
3549 if !matches!(mode, "already-running" | "custom-commands" | "docker-compose" | "devcontainer") {
3550 return Err(ApiError::BadRequest(format!("unknown launch profile mode `{mode}`")));
3551 }
3552 for url in &input.target_urls {
3553 if !is_local_http_url(url) {
3554 return Err(ApiError::BadRequest(format!(
3555 "target URL `{url}` must be a local http:// or https:// URL"
3556 )));
3557 }
3558 }
3559 for check in &input.health_checks {
3560 if let Some(url) = check.url.as_deref() {
3561 if !is_local_http_url(url) {
3562 return Err(ApiError::BadRequest(format!(
3563 "health check URL `{url}` must be local"
3564 )));
3565 }
3566 }
3567 }
3568 Ok(())
3569}
3570
3571fn is_local_http_url(raw: &str) -> bool {
3572 local_http_url(raw).is_some()
3573}
3574
3575fn local_http_url(raw: &str) -> Option<reqwest::Url> {
3576 let url = reqwest::Url::parse(raw.trim()).ok()?;
3577 if !matches!(url.scheme(), "http" | "https") {
3578 return None;
3579 }
3580 let host = url.host_str()?;
3581 let allowed = host.eq_ignore_ascii_case("localhost")
3582 || host
3583 .parse::<std::net::Ipv4Addr>()
3584 .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
3585 || host.parse::<std::net::Ipv6Addr>().is_ok_and(|addr| addr.is_loopback());
3586 allowed.then_some(url)
3587}
3588
3589async fn list_project_repos(
3592 State(s): State<ServerState>,
3593 Path(project_id): Path<String>,
3594) -> Result<Json<Vec<RepoRecord>>, ApiError> {
3595 require_project(&s, &project_id).await?;
3596 let rows = s.store.repos().list_by_project(&project_id).await?;
3597 Ok(Json(rows))
3598}
3599
3600async fn create_project_repo(
3601 State(s): State<ServerState>,
3602 Path(project_id): Path<String>,
3603 Json(req): Json<CreateRepoRequest>,
3604) -> Result<Json<RepoRecord>, ApiError> {
3605 require_project(&s, &project_id).await?;
3606 if req.name.trim().is_empty() {
3607 return Err(ApiError::BadRequest("name is required".to_string()));
3608 }
3609 if !matches!(req.source_kind.as_str(), "git" | "local-path" | "github" | "gitlab" | "local") {
3610 return Err(ApiError::BadRequest(format!("unknown source_kind `{}`", req.source_kind)));
3611 }
3612 if !req.i_own_this {
3613 return Err(ApiError::BadRequest(
3614 "i_own_this must be set to true before the daemon will accept a repo".to_string(),
3615 ));
3616 }
3617 validate_git_auth_ref(&req.source_kind, req.auth_ref.as_deref())?;
3618 let now = now_epoch_ms();
3619 let existing = s.store.repos().get_by_project_and_name(&project_id, &req.name).await?;
3620 if let Some(row) = &existing {
3623 if row.project_id != project_id {
3624 return Err(ApiError::BadRequest(format!(
3625 "repo `{}` already belongs to project `{}`",
3626 row.name, row.project_id
3627 )));
3628 }
3629 }
3630 let rec = RepoRecord {
3631 id: existing.as_ref().map(|r| r.id.clone()).unwrap_or_else(|| {
3632 format!("repo-{}", uuid_like(&format!("{project_id}-{}", req.name), now))
3633 }),
3634 name: req.name,
3635 project_id: project_id.clone(),
3636 source_kind: req.source_kind,
3637 source_url_or_path: req.source_url_or_path,
3638 branch: req.branch,
3639 auth_ref: req.auth_ref,
3640 i_own_this: req.i_own_this,
3641 last_scan_run_id: existing.as_ref().and_then(|r| r.last_scan_run_id.clone()),
3642 last_scan_finished_at: existing.as_ref().and_then(|r| r.last_scan_finished_at),
3643 created_at: existing.as_ref().map(|r| r.created_at).unwrap_or(now),
3644 updated_at: now,
3645 };
3646 s.store.repos().upsert(&rec).await?;
3647 Ok(Json(rec))
3648}
3649
3650async fn get_project_repo(
3651 State(s): State<ServerState>,
3652 Path((project_id, name)): Path<(String, String)>,
3653) -> Result<Json<RepoRecord>, ApiError> {
3654 require_project(&s, &project_id).await?;
3655 let row = s
3656 .store
3657 .repos()
3658 .get_by_project_and_name(&project_id, &name)
3659 .await?
3660 .ok_or_else(|| ApiError::NotFound(format!("repo `{name}` not found")))?;
3661 if row.project_id != project_id {
3662 return Err(ApiError::NotFound(format!(
3663 "repo `{name}` not found in project `{project_id}`"
3664 )));
3665 }
3666 Ok(Json(row))
3667}
3668
3669async fn patch_project_repo(
3670 State(s): State<ServerState>,
3671 Path((project_id, name)): Path<(String, String)>,
3672 Json(req): Json<PatchRepoRequest>,
3673) -> Result<Json<RepoRecord>, ApiError> {
3674 require_project(&s, &project_id).await?;
3675 let existing = s
3676 .store
3677 .repos()
3678 .get_by_project_and_name(&project_id, &name)
3679 .await?
3680 .ok_or_else(|| ApiError::NotFound(format!("repo `{name}` not found")))?;
3681 if existing.project_id != project_id {
3682 return Err(ApiError::NotFound(format!(
3683 "repo `{name}` not found in project `{project_id}`"
3684 )));
3685 }
3686 if let Some(kind) = req.source_kind.as_deref() {
3687 if !matches!(kind, "git" | "local-path" | "github" | "gitlab" | "local") {
3688 return Err(ApiError::BadRequest(format!("unknown source_kind `{kind}`")));
3689 }
3690 }
3691 if let Some(false) = req.i_own_this {
3692 return Err(ApiError::BadRequest(
3693 "i_own_this cannot be cleared via PATCH; remove the repo instead".to_string(),
3694 ));
3695 }
3696 let effective_source_kind = req.source_kind.as_deref().unwrap_or(existing.source_kind.as_str());
3697 let effective_auth_ref: Option<&str> = match &req.auth_ref {
3698 None => existing.auth_ref.as_deref(),
3699 Some(None) => None,
3700 Some(Some(v)) => Some(v.as_str()),
3701 };
3702 validate_git_auth_ref(effective_source_kind, effective_auth_ref)?;
3703 let now = now_epoch_ms();
3704 let rec = RepoRecord {
3705 id: existing.id,
3706 name: existing.name,
3707 project_id: existing.project_id,
3708 source_kind: req.source_kind.unwrap_or(existing.source_kind),
3709 source_url_or_path: req.source_url_or_path.unwrap_or(existing.source_url_or_path),
3710 branch: match req.branch {
3711 None => existing.branch,
3712 Some(next) => next,
3713 },
3714 auth_ref: match req.auth_ref {
3715 None => existing.auth_ref,
3716 Some(next) => next,
3717 },
3718 i_own_this: req.i_own_this.unwrap_or(existing.i_own_this),
3719 last_scan_run_id: existing.last_scan_run_id,
3720 last_scan_finished_at: existing.last_scan_finished_at,
3721 created_at: existing.created_at,
3722 updated_at: now,
3723 };
3724 s.store.repos().upsert(&rec).await?;
3725 let row = s
3726 .store
3727 .repos()
3728 .get_by_project_and_name(&project_id, &name)
3729 .await?
3730 .ok_or_else(|| ApiError::Internal("repo vanished after update".to_string()))?;
3731 Ok(Json(row))
3732}
3733
3734fn validate_git_auth_ref(source_kind: &str, auth_ref: Option<&str>) -> Result<(), ApiError> {
3740 if !matches!(source_kind, "git" | "github" | "gitlab") {
3741 return Ok(());
3742 }
3743 let Some(raw) = auth_ref else {
3744 return Ok(());
3745 };
3746 parse_git_auth(raw).map_err(|err| match err {
3747 IngestError::AuthMalformed { raw } => ApiError::BadRequest(format!(
3748 "auth_ref `{raw}` is malformed; expected `ssh-key:<path>`, `token-env:<VAR>`, or \
3749 `gh-app:<id>`"
3750 )),
3751 IngestError::AuthUnknownScheme { scheme } => ApiError::BadRequest(format!(
3752 "auth_ref scheme `{scheme}` is not supported; use `ssh-key`, `token-env`, or `gh-app`"
3753 )),
3754 other => ApiError::BadRequest(format!("auth_ref failed validation: {other}")),
3755 })?;
3756 Ok(())
3757}
3758
3759async fn delete_project_repo(
3760 State(s): State<ServerState>,
3761 Path((project_id, name)): Path<(String, String)>,
3762) -> Result<StatusBody, ApiError> {
3763 require_project(&s, &project_id).await?;
3764 let existing = s
3765 .store
3766 .repos()
3767 .get_by_project_and_name(&project_id, &name)
3768 .await?
3769 .ok_or_else(|| ApiError::NotFound(format!("repo `{name}` not found")))?;
3770 if existing.project_id != project_id {
3771 return Err(ApiError::NotFound(format!(
3772 "repo `{name}` not found in project `{project_id}`"
3773 )));
3774 }
3775 let affected = s.store.repos().delete_by_project_and_name(&project_id, &name).await?;
3776 if affected == 0 {
3777 return Err(ApiError::NotFound(format!("repo `{name}` not found")));
3778 }
3779 let mut workspace_msg = String::new();
3780 if let Some(root) = &s.state_repos_dir {
3781 let target = root.join(&name);
3782 if target.is_dir() {
3783 match std::fs::remove_dir_all(&target) {
3784 Ok(()) => {
3785 workspace_msg = format!(" (workspace {} removed)", target.display());
3786 }
3787 Err(err) => {
3788 tracing::warn!(
3789 repo = %name,
3790 path = %target.display(),
3791 error = %err,
3792 "failed to remove repo workspace; row was still deleted",
3793 );
3794 workspace_msg =
3795 format!(" (workspace {} could not be removed: {err})", target.display());
3796 }
3797 }
3798 }
3799 }
3800 Ok(StatusBody::ok(format!("deleted {affected} row(s){workspace_msg}")))
3801}
3802
3803async fn test_repo_connectivity(
3811 State(s): State<ServerState>,
3812 Path(project_id): Path<String>,
3813 Json(req): Json<TestRepoRequest>,
3814) -> Result<Json<TestRepoResponse>, ApiError> {
3815 require_project(&s, &project_id).await?;
3816 match req.source_kind.as_str() {
3817 "git" | "github" | "gitlab" => {
3818 let url = req.source_url_or_path.trim();
3819 if url.is_empty() {
3820 return Err(ApiError::BadRequest("source_url_or_path is required".to_string()));
3821 }
3822 let branch = req.branch.as_deref().map(str::trim).filter(|s| !s.is_empty());
3823 let (ok, message) = git_ls_remote_probe(url, branch).await;
3824 Ok(Json(TestRepoResponse { ok, message, on_disk_git_remote: None }))
3825 }
3826 "local-path" | "local" => {
3827 let path = std::path::Path::new(&req.source_url_or_path);
3828 if !path.exists() {
3829 return Ok(Json(TestRepoResponse {
3830 ok: false,
3831 message: format!("path `{}` does not exist", path.display()),
3832 on_disk_git_remote: None,
3833 }));
3834 }
3835 if !path.is_dir() {
3836 return Ok(Json(TestRepoResponse {
3837 ok: false,
3838 message: format!("path `{}` is not a directory", path.display()),
3839 on_disk_git_remote: None,
3840 }));
3841 }
3842 let remote = read_local_git_remote(path);
3843 let message = match &remote {
3844 Some(url) => format!(
3845 "path readable; on-disk `.git/config` remote = `{url}`. Confirm before adding.",
3846 ),
3847 None => "path readable; no `.git/config` remote on disk (untracked directory)."
3848 .to_string(),
3849 };
3850 Ok(Json(TestRepoResponse { ok: true, message, on_disk_git_remote: remote }))
3851 }
3852 other => Err(ApiError::BadRequest(format!("unknown source_kind `{other}`"))),
3853 }
3854}
3855
3856const GIT_PROBE_TIMEOUT: Duration = Duration::from_secs(15);
3857
3858async fn git_ls_remote_probe(url: &str, branch: Option<&str>) -> (bool, String) {
3859 let mut cmd = tokio::process::Command::new("git");
3860 cmd.arg("-c").arg("credential.helper=").arg("ls-remote").arg("--exit-code").arg(url);
3861 if let Some(b) = branch {
3862 cmd.arg(format!("refs/heads/{b}"));
3863 }
3864 cmd.env("GIT_TERMINAL_PROMPT", "0");
3867 cmd.env("GIT_CONFIG_GLOBAL", "/dev/null");
3868 cmd.env("GIT_CONFIG_SYSTEM", "/dev/null");
3869 cmd.stdout(std::process::Stdio::piped());
3870 cmd.stderr(std::process::Stdio::piped());
3871 cmd.stdin(std::process::Stdio::null());
3872 cmd.kill_on_drop(true);
3877
3878 let child = match cmd.spawn() {
3879 Ok(c) => c,
3880 Err(err) => return (false, format!("could not spawn git: {err}")),
3881 };
3882 let wait = child.wait_with_output();
3883 match tokio::time::timeout(GIT_PROBE_TIMEOUT, wait).await {
3884 Ok(Ok(output)) => {
3885 if output.status.success() {
3886 let line_count = output.stdout.iter().filter(|b| **b == b'\n').count();
3887 (
3888 true,
3889 match branch {
3890 Some(b) => format!("ls-remote reached upstream; branch `{b}` exists"),
3891 None => format!("ls-remote reached upstream ({line_count} refs visible)"),
3892 },
3893 )
3894 } else if output.status.code() == Some(2) {
3895 (
3896 false,
3897 match branch {
3898 Some(b) => format!("upstream reachable but branch `{b}` does not exist"),
3899 None => "upstream reachable but has no refs".to_string(),
3900 },
3901 )
3902 } else {
3903 let stderr = String::from_utf8_lossy(&output.stderr);
3904 let trimmed = stderr.trim();
3905 (
3906 false,
3907 if trimmed.is_empty() {
3908 format!("git ls-remote exited with status {}", output.status)
3909 } else {
3910 format!("git ls-remote failed: {trimmed}")
3911 },
3912 )
3913 }
3914 }
3915 Ok(Err(err)) => (false, format!("git wait failed: {err}")),
3916 Err(_) => {
3917 (false, format!("git ls-remote timed out after {}s", GIT_PROBE_TIMEOUT.as_secs()))
3918 }
3919 }
3920}
3921
3922fn read_local_git_remote(path: &std::path::Path) -> Option<String> {
3923 let cfg = path.join(".git").join("config");
3924 let raw = std::fs::read_to_string(&cfg).ok()?;
3925 parse_git_config_remote(&raw)
3926}
3927
3928fn parse_git_config_remote(raw: &str) -> Option<String> {
3932 let mut in_origin = false;
3933 for line in raw.lines() {
3934 let line = line.trim();
3935 if line.starts_with('#') || line.starts_with(';') || line.is_empty() {
3936 continue;
3937 }
3938 if line.starts_with('[') {
3939 in_origin = line == "[remote \"origin\"]";
3940 continue;
3941 }
3942 if in_origin {
3943 if let Some(rest) = line.strip_prefix("url") {
3944 if let Some(eq) = rest.find('=') {
3945 return Some(rest[eq + 1..].trim().to_string());
3946 }
3947 }
3948 }
3949 }
3950 None
3951}
3952
3953#[derive(Debug, Deserialize)]
3956pub struct ScanQuery {
3957 #[serde(default)]
3958 pub repo: Option<String>,
3959}
3960
3961#[derive(Debug, Serialize)]
3962struct ScanResponse {
3963 run_id: String,
3964}
3965
3966async fn scan_project(
3967 State(s): State<ServerState>,
3968 Path(project_id): Path<String>,
3969 Query(q): Query<ScanQuery>,
3970) -> Result<Json<ScanResponse>, ApiError> {
3971 require_project(&s, &project_id).await?;
3972 let run_id = s.scan.trigger(ScanTriggerSource::Manual, Some(project_id), q.repo, None).await?;
3976 Ok(Json(ScanResponse { run_id }))
3977}
3978
3979async fn start_pentest_project(
3980 State(s): State<ServerState>,
3981 Path(project_id): Path<String>,
3982 body: Option<Json<StartPentestRequest>>,
3983) -> Result<Json<StartPentestResponse>, ApiError> {
3984 let project = require_project(&s, &project_id).await?;
3985 let profile = project.default_launch_profile.ok_or_else(|| {
3986 ApiError::BadRequest(
3987 "configure a default launch profile before starting a pentest".to_string(),
3988 )
3989 })?;
3990 if profile.readiness != "Ready" {
3991 return Err(ApiError::BadRequest(format!(
3992 "default launch profile is not ready ({})",
3993 profile.readiness
3994 )));
3995 }
3996 let request = body.map(|Json(body)| body).unwrap_or_default();
3997 if request.allow_state_changing_live_probes && !request.exploit_mode_enabled {
3998 return Err(ApiError::BadRequest(
3999 "state-changing live probes require exploit mode to be enabled".to_string(),
4000 ));
4001 }
4002 for template_id in &request.business_logic_template_ids {
4003 if business_logic_template_by_id(template_id).is_none() {
4004 return Err(ApiError::BadRequest(format!(
4005 "unknown business-logic template id `{template_id}`"
4006 )));
4007 }
4008 }
4009 let run_id = s
4010 .scan
4011 .trigger(
4012 ScanTriggerSource::Manual,
4013 Some(project_id),
4014 None,
4015 Some(ScanRunOverrides {
4016 exploit_mode_enabled: request.exploit_mode_enabled,
4017 allow_state_changing_live_probes: request.allow_state_changing_live_probes,
4018 exploit_dry_run: request.exploit_dry_run,
4019 browser_checks_enabled: request.browser_checks_enabled,
4020 business_logic_templates_enabled: request.business_logic_templates_enabled,
4021 research_mode_enabled: request.research_mode_enabled,
4022 unsafe_attack_agent_enabled: request.unsafe_attack_agent_enabled,
4023 business_logic_template_ids: if request.business_logic_template_ids.is_empty() {
4024 None
4025 } else {
4026 Some(request.business_logic_template_ids)
4027 },
4028 }),
4029 )
4030 .await?;
4031 Ok(Json(StartPentestResponse { run_id }))
4032}
4033
4034async fn list_project_integrations(
4037 State(s): State<ServerState>,
4038 Path(project_id): Path<String>,
4039) -> Result<Json<Vec<ProjectIntegrationRecord>>, ApiError> {
4040 require_project(&s, &project_id).await?;
4041 Ok(Json(s.store.integrations().list_by_project(&project_id).await?))
4042}
4043
4044async fn create_project_integration(
4045 State(s): State<ServerState>,
4046 Path(project_id): Path<String>,
4047 Json(req): Json<CreateProjectIntegrationRequest>,
4048) -> Result<Json<ProjectIntegrationRecord>, ApiError> {
4049 require_project(&s, &project_id).await?;
4050 let name = validate_integration_name(&req.name)?;
4051 validate_integration_events(&req.events)?;
4052 crate::integrations::validate_min_severity(req.min_severity.as_deref())
4053 .map_err(ApiError::BadRequest)?;
4054 let prepared =
4055 crate::integrations::prepare_config(&req.config).map_err(ApiError::BadRequest)?;
4056 let now = now_epoch_ms();
4057 let id = format!("int-{}", uuid_like(&format!("{project_id}-{name}"), now));
4058 let row = s
4059 .store
4060 .integrations()
4061 .create(ProjectIntegrationInsert {
4062 id,
4063 project_id,
4064 kind: prepared.kind,
4065 name,
4066 enabled: req.enabled,
4067 events: req.events,
4068 min_severity: req.min_severity,
4069 config_json: prepared.config_json,
4070 target: prepared.target,
4071 now_ms: now,
4072 })
4073 .await?;
4074 Ok(Json(row))
4075}
4076
4077async fn get_project_integration(
4078 State(s): State<ServerState>,
4079 Path((project_id, integration_id)): Path<(String, String)>,
4080) -> Result<Json<ProjectIntegrationRecord>, ApiError> {
4081 require_project(&s, &project_id).await?;
4082 let row = require_project_integration(&s, &project_id, &integration_id).await?;
4083 Ok(Json(row))
4084}
4085
4086async fn patch_project_integration(
4087 State(s): State<ServerState>,
4088 Path((project_id, integration_id)): Path<(String, String)>,
4089 Json(req): Json<PatchProjectIntegrationRequest>,
4090) -> Result<Json<ProjectIntegrationRecord>, ApiError> {
4091 require_project(&s, &project_id).await?;
4092 require_project_integration(&s, &project_id, &integration_id).await?;
4093 if let Some(events) = &req.events {
4094 validate_integration_events(events)?;
4095 }
4096 if let Some(min) = &req.min_severity {
4097 crate::integrations::validate_min_severity(min.as_deref()).map_err(ApiError::BadRequest)?;
4098 }
4099 let (config_json, target) = if let Some(config) = &req.config {
4100 let prepared = crate::integrations::prepare_config(config).map_err(ApiError::BadRequest)?;
4101 (Some(prepared.config_json), Some(prepared.target))
4102 } else {
4103 (None, None)
4104 };
4105 let name = req.name.as_deref().map(validate_integration_name).transpose()?;
4106 let row = s
4107 .store
4108 .integrations()
4109 .update(
4110 &integration_id,
4111 ProjectIntegrationPatch {
4112 name,
4113 enabled: req.enabled,
4114 events: req.events,
4115 min_severity: req.min_severity,
4116 config_json,
4117 target,
4118 updated_at: now_epoch_ms(),
4119 },
4120 )
4121 .await?
4122 .ok_or_else(|| ApiError::NotFound(format!("integration `{integration_id}` not found")))?;
4123 if row.project_id != project_id {
4124 return Err(ApiError::NotFound(format!(
4125 "integration `{integration_id}` not found in project `{project_id}`"
4126 )));
4127 }
4128 Ok(Json(row))
4129}
4130
4131async fn delete_project_integration(
4132 State(s): State<ServerState>,
4133 Path((project_id, integration_id)): Path<(String, String)>,
4134) -> Result<StatusBody, ApiError> {
4135 require_project(&s, &project_id).await?;
4136 require_project_integration(&s, &project_id, &integration_id).await?;
4137 let affected = s.store.integrations().delete(&integration_id).await?;
4138 Ok(StatusBody::ok(format!("deleted {affected} integration row(s)")))
4139}
4140
4141async fn test_project_integration(
4142 State(s): State<ServerState>,
4143 Path((project_id, integration_id)): Path<(String, String)>,
4144) -> Result<Json<TestProjectIntegrationResponse>, ApiError> {
4145 require_project(&s, &project_id).await?;
4146 let row =
4147 s.store.integrations().get_stored(&integration_id).await?.ok_or_else(|| {
4148 ApiError::NotFound(format!("integration `{integration_id}` not found"))
4149 })?;
4150 if row.public.project_id != project_id {
4151 return Err(ApiError::NotFound(format!(
4152 "integration `{integration_id}` not found in project `{project_id}`"
4153 )));
4154 }
4155 match crate::integrations::IntegrationDispatcher::new().send_test(&s.store, &row).await {
4156 Ok(()) => {
4157 let _ = s
4158 .store
4159 .integrations()
4160 .record_delivery(&integration_id, now_epoch_ms(), "ok", None)
4161 .await;
4162 Ok(Json(TestProjectIntegrationResponse {
4163 ok: true,
4164 message: "test delivery sent".to_string(),
4165 }))
4166 }
4167 Err(err) => {
4168 let _ = s
4169 .store
4170 .integrations()
4171 .record_delivery(&integration_id, now_epoch_ms(), "error", Some(&err))
4172 .await;
4173 Err(ApiError::BadRequest(format!("test delivery failed: {err}")))
4174 }
4175 }
4176}
4177
4178#[derive(Debug, Deserialize)]
4181pub struct RunsQuery {
4182 #[serde(default)]
4183 pub status: Option<String>,
4184 #[serde(default)]
4185 pub project_id: Option<String>,
4186}
4187
4188async fn list_runs(
4189 State(s): State<ServerState>,
4190 Query(q): Query<RunsQuery>,
4191) -> Result<Json<Vec<RunRecord>>, ApiError> {
4192 let status = q.status.as_deref().unwrap_or("Running");
4193 let rows = if let Some(project_id) = q.project_id.as_deref() {
4194 require_project(&s, project_id).await?;
4195 s.store.runs().list_by_status_for_project(status, project_id).await?
4196 } else {
4197 s.store.runs().list_by_status(status).await?
4198 };
4199 Ok(Json(rows))
4200}
4201
4202async fn get_run(
4203 State(s): State<ServerState>,
4204 Path(id): Path<String>,
4205) -> Result<Json<RunRecord>, ApiError> {
4206 s.store
4207 .runs()
4208 .get(&id)
4209 .await?
4210 .map(Json)
4211 .ok_or_else(|| ApiError::NotFound(format!("run `{id}` not found")))
4212}
4213
4214async fn environment_runs_for_run(
4215 State(s): State<ServerState>,
4216 Path(id): Path<String>,
4217) -> Result<Json<Vec<nyx_agent_types::product::EnvironmentRunRecord>>, ApiError> {
4218 require_run(&s, &id).await?;
4219 Ok(Json(s.store.environment_runs().list_by_run(&id).await?))
4220}
4221
4222async fn run_business_logic(
4223 State(s): State<ServerState>,
4224 Path(id): Path<String>,
4225) -> Result<Json<BusinessLogicRunSummary>, ApiError> {
4226 let run = require_run(&s, &id).await?;
4227 let rows = s.store.business_logic_template_runs().list_by_run(&id).await?;
4228 let candidates_generated = rows.iter().map(|row| row.generated_count).sum();
4229 let templates_skipped = rows.iter().filter(|row| row.skipped_count > 0).count() as u32;
4230 let dry_run = rows.iter().any(|row| row.dry_run);
4231 Ok(Json(BusinessLogicRunSummary {
4232 run_id: run.id,
4233 templates_considered: rows.len() as u32,
4234 candidates_generated,
4235 templates_skipped,
4236 dry_run,
4237 templates: rows,
4238 }))
4239}
4240
4241async fn run_event_log(
4242 State(s): State<ServerState>,
4243 Path(id): Path<String>,
4244) -> Result<Response, ApiError> {
4245 require_run(&s, &id).await?;
4246 let logs_dir = s
4247 .state_logs_dir
4248 .as_ref()
4249 .ok_or_else(|| ApiError::Internal("logs directory is not configured".to_string()))?;
4250 let path = run_event_log_path(logs_dir, &id);
4251 let file = match tokio::fs::File::open(&path).await {
4252 Ok(file) => file,
4253 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
4254 return Err(ApiError::NotFound(format!("event log for run `{id}` not found")));
4255 }
4256 Err(err) => {
4257 return Err(ApiError::Internal(format!(
4258 "open run event log `{}`: {err}",
4259 path.display()
4260 )));
4261 }
4262 };
4263
4264 let stream = async_stream::stream! {
4265 let mut file = file;
4266 let mut buf = vec![0_u8; 16 * 1024];
4267 loop {
4268 match file.read(&mut buf).await {
4269 Ok(0) => break,
4270 Ok(n) => yield Ok::<Bytes, std::io::Error>(Bytes::copy_from_slice(&buf[..n])),
4271 Err(err) => {
4272 yield Err(err);
4273 break;
4274 }
4275 }
4276 }
4277 };
4278 let filename = format!("{}.events.jsonl", safe_run_log_segment(&id));
4279 Response::builder()
4280 .status(StatusCode::OK)
4281 .header(header::CONTENT_TYPE, "application/x-ndjson")
4282 .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""))
4283 .body(Body::from_stream(stream))
4284 .map_err(|err| ApiError::Internal(format!("build run event log response: {err}")))
4285}
4286
4287async fn verification_attempts_for_run(
4288 State(s): State<ServerState>,
4289 Path(id): Path<String>,
4290) -> Result<Json<Vec<nyx_agent_types::product::VerificationAttemptRecord>>, ApiError> {
4291 require_run(&s, &id).await?;
4292 Ok(Json(s.store.verification_attempts().list_by_run(&id).await?))
4293}
4294
4295async fn authz_matrix_for_run(
4296 State(s): State<ServerState>,
4297 Path(id): Path<String>,
4298) -> Result<Json<Vec<nyx_agent_types::product::AuthzMatrixEntryRecord>>, ApiError> {
4299 require_run(&s, &id).await?;
4300 Ok(Json(s.store.authz_matrix().list_by_run(&id).await?))
4301}
4302
4303async fn exploration_memory_for_run(
4304 State(s): State<ServerState>,
4305 Path(id): Path<String>,
4306) -> Result<Json<Vec<nyx_agent_types::product::ExplorationMemoryRecord>>, ApiError> {
4307 require_run(&s, &id).await?;
4308 Ok(Json(s.store.exploration_memory().list_by_run(&id).await?))
4309}
4310
4311#[derive(Debug, Deserialize)]
4312struct SignalsQuery {
4313 #[serde(default)]
4314 meaningful_only: bool,
4315}
4316
4317async fn signals_for_run(
4318 State(s): State<ServerState>,
4319 Path(id): Path<String>,
4320 Query(q): Query<SignalsQuery>,
4321) -> Result<Json<Vec<nyx_agent_types::product::NyxSignalRecord>>, ApiError> {
4322 require_run(&s, &id).await?;
4323 Ok(Json(s.store.nyx_signals().list_by_run(&id, q.meaningful_only).await?))
4324}
4325
4326async fn candidates_for_run(
4327 State(s): State<ServerState>,
4328 Path(id): Path<String>,
4329) -> Result<Json<Vec<nyx_agent_types::product::PentestCandidateRecord>>, ApiError> {
4330 require_run(&s, &id).await?;
4331 Ok(Json(s.store.pentest_candidates().list_by_run(&id).await?))
4332}
4333
4334async fn route_model_for_run(
4335 State(s): State<ServerState>,
4336 Path(id): Path<String>,
4337) -> Result<Json<nyx_agent_types::product::RouteModelRecord>, ApiError> {
4338 require_run(&s, &id).await?;
4339 s.store
4340 .route_models()
4341 .get_by_run(&id)
4342 .await?
4343 .map(Json)
4344 .ok_or_else(|| ApiError::NotFound(format!("route model for run `{id}` not found")))
4345}
4346
4347async fn run_vulnerabilities(
4348 State(s): State<ServerState>,
4349 Path(id): Path<String>,
4350) -> Result<Json<Vec<nyx_agent_types::product::VerifiedVulnerabilityRecord>>, ApiError> {
4351 require_run(&s, &id).await?;
4352 Ok(Json(s.store.verified_vulnerabilities().list_by_run_including_triaged(&id).await?))
4353}
4354
4355async fn project_vulnerabilities(
4356 State(s): State<ServerState>,
4357 Path(project_id): Path<String>,
4358) -> Result<Json<Vec<nyx_agent_types::product::VerifiedVulnerabilityRecord>>, ApiError> {
4359 require_project(&s, &project_id).await?;
4360 Ok(Json(
4361 s.store.verified_vulnerabilities().list_by_project_including_triaged(&project_id).await?,
4362 ))
4363}
4364
4365async fn list_vulnerabilities(
4366 State(s): State<ServerState>,
4367) -> Result<Json<Vec<nyx_agent_types::product::VerifiedVulnerabilityRecord>>, ApiError> {
4368 Ok(Json(s.store.verified_vulnerabilities().list_all_including_triaged().await?))
4369}
4370
4371async fn get_vulnerability(
4372 State(s): State<ServerState>,
4373 Path(id): Path<String>,
4374) -> Result<Json<nyx_agent_types::product::VerifiedVulnerabilityRecord>, ApiError> {
4375 s.store
4376 .verified_vulnerabilities()
4377 .get(&id)
4378 .await?
4379 .map(Json)
4380 .ok_or_else(|| ApiError::NotFound(format!("vulnerability `{id}` not found")))
4381}
4382
4383#[derive(Debug, Serialize)]
4384struct RemediationStartResponse {
4385 job: crate::state::RemediationJobRecord,
4386}
4387
4388async fn start_vulnerability_fix(
4389 State(s): State<ServerState>,
4390 Path(id): Path<String>,
4391) -> Result<Json<RemediationStartResponse>, ApiError> {
4392 let vulnerability = s
4393 .store
4394 .verified_vulnerabilities()
4395 .get(&id)
4396 .await?
4397 .ok_or_else(|| ApiError::NotFound(format!("vulnerability `{id}` not found")))?;
4398 let agent = s.remediation_agent.clone().ok_or_else(|| {
4399 ApiError::BadRequest(
4400 "no remediation agent is configured; select Codex or Claude Code as the AI runtime"
4401 .to_string(),
4402 )
4403 })?;
4404 let repos = s.store.repos().list_by_project(&vulnerability.project_id).await?;
4405 let workspace_roots = remediation_workspace_roots(&repos, s.state_repos_dir.as_deref());
4406 if workspace_roots.is_empty() {
4407 return Err(ApiError::BadRequest(
4408 "no writable local repository workspace is available for this project".to_string(),
4409 ));
4410 }
4411
4412 let job = s
4413 .remediation_jobs
4414 .create(&vulnerability.id, &vulnerability.project_id, now_epoch_ms())
4415 .await;
4416 let job_id = job.id.clone();
4417 let jobs = s.remediation_jobs.clone();
4418 tokio::spawn(async move {
4419 jobs.push_phase(&job_id, "preparing", "Preparing vulnerability context.").await;
4420 let request = RemediationAgentRequest { vulnerability, workspace_roots };
4421 jobs.push_phase(&job_id, "editing", "Fix agent is editing the local repository.").await;
4422 match agent.fix(request).await {
4423 Ok(output) => jobs.complete(&job_id, output).await,
4424 Err(err) => jobs.fail(&job_id, remediation_error_to_job_error(err)).await,
4425 }
4426 });
4427
4428 Ok(Json(RemediationStartResponse { job }))
4429}
4430
4431async fn get_vulnerability_fix_job(
4432 State(s): State<ServerState>,
4433 Path((id, job_id)): Path<(String, String)>,
4434) -> Result<Json<crate::state::RemediationJobRecord>, ApiError> {
4435 let job = s
4436 .remediation_jobs
4437 .get(&job_id)
4438 .await
4439 .ok_or_else(|| ApiError::NotFound(format!("fix job `{job_id}` not found")))?;
4440 if job.vulnerability_id != id {
4441 return Err(ApiError::NotFound(format!(
4442 "fix job `{job_id}` not found for vulnerability `{id}`"
4443 )));
4444 }
4445 Ok(Json(job))
4446}
4447
4448fn remediation_error_to_job_error(err: crate::state::RemediationAgentError) -> RemediationJobError {
4449 match err {
4450 crate::state::RemediationAgentError::Unavailable(detail) => {
4451 RemediationJobError { title: "Fix agent unavailable".to_string(), detail }
4452 }
4453 crate::state::RemediationAgentError::Failed(detail) => {
4454 RemediationJobError { title: "Fix agent failed".to_string(), detail }
4455 }
4456 }
4457}
4458
4459fn remediation_workspace_roots(
4460 repos: &[RepoRecord],
4461 state_repos_dir: Option<&FsPath>,
4462) -> Vec<PathBuf> {
4463 let mut seen = BTreeSet::new();
4464 let mut out = Vec::new();
4465 for repo in repos {
4466 if matches!(repo.source_kind.as_str(), "local" | "local-path") {
4467 push_workspace_root(&mut out, &mut seen, PathBuf::from(&repo.source_url_or_path));
4468 }
4469 if let Some(root) = state_repos_dir {
4470 let legacy = root.join(&repo.name);
4471 push_workspace_root(&mut out, &mut seen, legacy.join("checkout"));
4472 push_workspace_root(&mut out, &mut seen, legacy);
4473 if let Some(state_root) = root.parent() {
4474 let project_scoped = state_root
4475 .join("projects")
4476 .join(&repo.project_id)
4477 .join("repos")
4478 .join(&repo.name);
4479 push_workspace_root(&mut out, &mut seen, project_scoped.join("checkout"));
4480 push_workspace_root(&mut out, &mut seen, project_scoped);
4481 }
4482 }
4483 }
4484 out
4485}
4486
4487fn push_workspace_root(out: &mut Vec<PathBuf>, seen: &mut BTreeSet<PathBuf>, path: PathBuf) {
4488 if path.is_dir() && seen.insert(path.clone()) {
4489 out.push(path);
4490 }
4491}
4492
4493async fn require_run(s: &ServerState, id: &str) -> Result<RunRecord, ApiError> {
4494 s.store.runs().get(id).await?.ok_or_else(|| ApiError::NotFound(format!("run `{id}` not found")))
4495}
4496
4497#[derive(Debug, Deserialize)]
4498struct VulnerabilityStatusPatch {
4499 status: String,
4500}
4501
4502#[derive(Debug, Deserialize)]
4503struct BulkVulnerabilityStatusPatch {
4504 ids: Vec<String>,
4505 status: String,
4506}
4507
4508async fn update_vulnerability_status(
4509 State(s): State<ServerState>,
4510 Path(id): Path<String>,
4511 Json(req): Json<VulnerabilityStatusPatch>,
4512) -> Result<Json<nyx_agent_types::product::VerifiedVulnerabilityRecord>, ApiError> {
4513 let status = normalize_vulnerability_status(&req.status)?;
4514 let row = s
4515 .store
4516 .verified_vulnerabilities()
4517 .set_status(&id, status)
4518 .await?
4519 .ok_or_else(|| ApiError::NotFound(format!("vulnerability `{id}` not found")))?;
4520 Ok(Json(row))
4521}
4522
4523async fn bulk_update_vulnerability_status(
4524 State(s): State<ServerState>,
4525 Json(req): Json<BulkVulnerabilityStatusPatch>,
4526) -> Result<Json<Vec<nyx_agent_types::product::VerifiedVulnerabilityRecord>>, ApiError> {
4527 if req.ids.is_empty() {
4528 return Err(ApiError::BadRequest(
4529 "ids must contain at least one vulnerability".to_string(),
4530 ));
4531 }
4532 let status = normalize_vulnerability_status(&req.status)?;
4533 let mut ids = Vec::new();
4534 let mut seen = HashSet::new();
4535 for raw in req.ids {
4536 let id = raw.trim();
4537 if id.is_empty() {
4538 continue;
4539 }
4540 if seen.insert(id.to_string()) {
4541 ids.push(id.to_string());
4542 }
4543 }
4544 if ids.is_empty() {
4545 return Err(ApiError::BadRequest(
4546 "ids must contain at least one vulnerability".to_string(),
4547 ));
4548 }
4549 for id in &ids {
4550 if s.store.verified_vulnerabilities().get(id).await?.is_none() {
4551 return Err(ApiError::NotFound(format!("vulnerability `{id}` not found")));
4552 }
4553 }
4554 let mut updated = Vec::with_capacity(ids.len());
4555 for id in ids {
4556 let Some(row) = s.store.verified_vulnerabilities().set_status(&id, status).await? else {
4557 return Err(ApiError::NotFound(format!("vulnerability `{id}` not found")));
4558 };
4559 updated.push(row);
4560 }
4561 Ok(Json(updated))
4562}
4563
4564fn normalize_vulnerability_status(raw: &str) -> Result<&'static str, ApiError> {
4565 let mut normalized = raw.trim().to_ascii_lowercase();
4566 normalized.retain(|ch| !matches!(ch, ' ' | '-' | '_'));
4567 match normalized.as_str() {
4568 "open" => Ok("Open"),
4569 "inprogress" | "investigating" => Ok("InProgress"),
4570 "fixed" | "resolved" => Ok("Fixed"),
4571 "falsepositive" => Ok("FalsePositive"),
4572 "acceptedrisk" | "accepted" => Ok("AcceptedRisk"),
4573 _ => Err(ApiError::BadRequest(format!("unknown vulnerability status `{raw}`"))),
4574 }
4575}
4576
4577#[derive(Debug, Deserialize)]
4584pub struct FindingsQuery {
4585 #[serde(default)]
4586 pub project_id: Option<String>,
4587 #[serde(default)]
4588 pub repo: Option<String>,
4589 #[serde(default)]
4590 pub run_id: Option<String>,
4591 #[serde(default)]
4592 pub cap: Option<String>,
4593 #[serde(default)]
4594 pub origin: Option<String>,
4595 #[serde(default)]
4596 pub status: Option<String>,
4597 #[serde(default)]
4598 pub severity: Option<String>,
4599 #[serde(default)]
4600 pub triage_state: Option<String>,
4601 #[serde(default)]
4602 pub chain_id: Option<String>,
4603 #[serde(default)]
4604 pub include_quarantine: bool,
4605}
4606
4607async fn list_findings(
4608 State(s): State<ServerState>,
4609 Query(q): Query<FindingsQuery>,
4610) -> Result<Json<Vec<FindingRecord>>, ApiError> {
4611 if let Some(project_id) = q.project_id.as_deref() {
4612 require_project(&s, project_id).await?;
4613 }
4614 let filter = FindingFilter {
4615 project_id: q.project_id.as_deref(),
4616 repo: q.repo.as_deref(),
4617 run_id: q.run_id.as_deref(),
4618 cap: q.cap.as_deref(),
4619 origin: q.origin.as_deref(),
4620 status: q.status.as_deref(),
4621 severity: q.severity.as_deref(),
4622 triage_state: q.triage_state.as_deref(),
4623 chain_id: q.chain_id.as_deref(),
4624 include_quarantine: q.include_quarantine,
4625 limit: None,
4626 };
4627 let rows = s.store.findings().list_filtered(&filter).await?;
4628 Ok(Json(rows))
4629}
4630
4631async fn get_finding(
4632 State(s): State<ServerState>,
4633 Path(id): Path<String>,
4634) -> Result<Json<FindingRecord>, ApiError> {
4635 s.store
4636 .findings()
4637 .get(&id)
4638 .await?
4639 .map(Json)
4640 .ok_or_else(|| ApiError::NotFound(format!("finding `{id}` not found")))
4641}
4642
4643#[derive(Debug, Deserialize, Default)]
4652pub struct RunFindingsQuery {
4653 #[serde(default)]
4654 pub repo: Option<String>,
4655 #[serde(default)]
4656 pub cap: Option<String>,
4657 #[serde(default)]
4658 pub origin: Option<String>,
4659 #[serde(default)]
4660 pub status: Option<String>,
4661 #[serde(default)]
4662 pub severity: Option<String>,
4663 #[serde(default)]
4664 pub triage_state: Option<String>,
4665 #[serde(default)]
4666 pub chain_id: Option<String>,
4667}
4668
4669async fn findings_for_run(
4670 State(s): State<ServerState>,
4671 Path(run_id): Path<String>,
4672 Query(q): Query<RunFindingsQuery>,
4673) -> Result<Json<RunFindingsResponse>, ApiError> {
4674 let run = s
4675 .store
4676 .runs()
4677 .get(&run_id)
4678 .await?
4679 .ok_or_else(|| ApiError::NotFound(format!("run `{run_id}` not found")))?;
4680 let started_at = run.started_at;
4681 let prior_run_id = s.store.runs().prior_run_id(&run_id, started_at).await?;
4682
4683 let filter = FindingFilter {
4684 project_id: None,
4685 run_id: Some(&run_id),
4686 repo: q.repo.as_deref(),
4687 cap: q.cap.as_deref(),
4688 origin: q.origin.as_deref(),
4689 status: q.status.as_deref(),
4690 severity: q.severity.as_deref(),
4691 triage_state: q.triage_state.as_deref(),
4692 chain_id: q.chain_id.as_deref(),
4693 include_quarantine: false,
4694 limit: None,
4695 };
4696 let current_rows = s.store.findings().list_filtered(&filter).await?;
4697
4698 let prior_membership: HashMap<String, String> = match prior_run_id.as_deref() {
4699 Some(prior_id) => {
4700 s.store.findings().list_run_membership(prior_id).await?.into_iter().collect()
4701 }
4702 None => HashMap::new(),
4703 };
4704 let prior_known = !prior_membership.is_empty();
4705 let current_ids: HashSet<String> = current_rows.iter().map(|r| r.id.clone()).collect();
4706
4707 let mut items: Vec<FindingWithDiff> = current_rows
4708 .into_iter()
4709 .map(|record| {
4710 let diff_status =
4711 classify_current_row(&record, &prior_membership, prior_known, started_at);
4712 FindingWithDiff { record, diff_status }
4713 })
4714 .collect();
4715
4716 if !prior_membership.is_empty() {
4722 let closed_ids: Vec<&String> = prior_membership
4723 .iter()
4724 .filter_map(|(fid, prior_status)| {
4725 if current_ids.contains(fid) {
4726 None
4727 } else if prior_status.eq_ignore_ascii_case("Closed") {
4728 None
4730 } else {
4731 Some(fid)
4732 }
4733 })
4734 .collect();
4735 for fid in closed_ids {
4736 let Some(record) = s.store.findings().get(fid).await? else {
4737 continue;
4738 };
4739 if !row_passes_filter(&record, &q) {
4740 continue;
4741 }
4742 items.push(FindingWithDiff { record, diff_status: FindingDiffStatus::Closed });
4743 }
4744 }
4745
4746 Ok(Json(RunFindingsResponse { run_id, prior_run_id, items }))
4747}
4748
4749fn classify_current_row(
4750 record: &FindingRecord,
4751 prior_membership: &HashMap<String, String>,
4752 prior_known: bool,
4753 run_started_at: i64,
4754) -> FindingDiffStatus {
4755 if let Some(prior_status) = prior_membership.get(&record.id) {
4756 if prior_status.eq_ignore_ascii_case(&record.status) {
4757 return FindingDiffStatus::Unchanged;
4758 }
4759 return FindingDiffStatus::Regressed;
4760 }
4761 if prior_known {
4763 return FindingDiffStatus::New;
4766 }
4767 if record.first_seen >= run_started_at {
4771 FindingDiffStatus::New
4772 } else {
4773 FindingDiffStatus::Unchanged
4774 }
4775}
4776
4777fn row_passes_filter(record: &FindingRecord, q: &RunFindingsQuery) -> bool {
4782 if record.status.eq_ignore_ascii_case("Quarantine") {
4783 return false;
4784 }
4785 if let Some(repo) = q.repo.as_deref() {
4786 if record.repo != repo {
4787 return false;
4788 }
4789 }
4790 if let Some(cap) = q.cap.as_deref() {
4791 if record.cap != cap {
4792 return false;
4793 }
4794 }
4795 if let Some(origin) = q.origin.as_deref() {
4796 if record.finding_origin != origin {
4797 return false;
4798 }
4799 }
4800 if let Some(status) = q.status.as_deref() {
4801 if record.status != status {
4802 return false;
4803 }
4804 }
4805 if let Some(severity) = q.severity.as_deref() {
4806 if record.severity != severity {
4807 return false;
4808 }
4809 }
4810 if let Some(triage) = q.triage_state.as_deref() {
4811 if record.triage_state != triage {
4812 return false;
4813 }
4814 }
4815 if let Some(chain_id) = q.chain_id.as_deref() {
4816 if record.chain_id.as_deref() != Some(chain_id) {
4817 return false;
4818 }
4819 }
4820 true
4821}
4822
4823#[derive(Debug, Deserialize)]
4826struct ChainListQuery {
4827 run_id: Option<String>,
4828 #[serde(default)]
4829 include_proposed: bool,
4830}
4831
4832async fn list_chains(
4833 State(s): State<ServerState>,
4834 Query(q): Query<ChainListQuery>,
4835) -> Result<Json<Vec<ChainRecord>>, ApiError> {
4836 let run_id = q
4837 .run_id
4838 .ok_or_else(|| ApiError::BadRequest("missing `run_id` query parameter".to_string()))?;
4839 let mut rows = s.store.chains().list_by_run(&run_id).await?;
4840 if !q.include_proposed {
4841 rows.retain(|row| row.status == "Verified");
4842 }
4843 Ok(Json(rows))
4844}
4845
4846async fn get_chain(
4847 State(s): State<ServerState>,
4848 Path(id): Path<String>,
4849) -> Result<Json<ChainRecord>, ApiError> {
4850 s.store
4851 .chains()
4852 .get(&id)
4853 .await?
4854 .map(Json)
4855 .ok_or_else(|| ApiError::NotFound(format!("chain `{id}` not found")))
4856}
4857
4858#[derive(Debug, Deserialize, Default)]
4861struct QuarantineQuery {
4862 #[serde(default)]
4863 project_id: Option<String>,
4864}
4865
4866async fn list_quarantine(
4867 State(s): State<ServerState>,
4868 Query(q): Query<QuarantineQuery>,
4869) -> Result<Json<Vec<QuarantineItem>>, ApiError> {
4870 if let Some(project_id) = q.project_id.as_deref() {
4871 require_project(&s, project_id).await?;
4872 }
4873 let mut out: Vec<QuarantineItem> = Vec::new();
4874 let filter = FindingFilter {
4875 project_id: q.project_id.as_deref(),
4876 status: Some("Quarantine"),
4877 include_quarantine: true,
4878 ..Default::default()
4879 };
4880 let findings = s.store.findings().list_filtered(&filter).await?;
4881 for f in findings {
4882 out.push(QuarantineItem {
4883 kind: QuarantineKind::Finding,
4884 id: f.id,
4885 run_id: f.run_id,
4886 repo: f.repo,
4887 path: f.path,
4888 line: f.line,
4889 cap: f.cap,
4890 rule: Some(f.rule),
4891 severity: Some(f.severity),
4892 finding_origin: Some(f.finding_origin),
4893 prompt_version: f.prompt_version,
4894 attack_provenance: f.attack_provenance,
4895 rationale: None,
4896 verdict_blob: f.verdict_blob,
4897 last_seen: Some(f.last_seen),
4898 });
4899 }
4900 let pending = if let Some(project_id) = q.project_id.as_deref() {
4901 s.store.candidate_findings().list_pending_by_project(project_id).await?
4902 } else {
4903 s.store.candidate_findings().list_pending().await?
4904 };
4905 for c in pending {
4906 out.push(QuarantineItem {
4907 kind: QuarantineKind::Candidate,
4908 id: c.id,
4909 run_id: c.run_id,
4910 repo: c.repo,
4911 path: c.path,
4912 line: c.line,
4913 cap: c.cap,
4914 rule: c.rule_hint,
4915 severity: None,
4916 finding_origin: Some("AiExploration".to_string()),
4917 prompt_version: c.prompt_version,
4918 attack_provenance: None,
4919 rationale: c.rationale,
4920 verdict_blob: None,
4921 last_seen: None,
4922 });
4923 }
4924 out.sort_by_key(|b| std::cmp::Reverse(b.last_seen.unwrap_or(0)));
4927 Ok(Json(out))
4928}
4929
4930async fn promote_quarantine(
4931 State(s): State<ServerState>,
4932 Path(id): Path<String>,
4933) -> Result<Json<QuarantineItem>, ApiError> {
4934 if id.starts_with("cand-") {
4935 let cand = s
4936 .store
4937 .candidate_findings()
4938 .get(&id)
4939 .await?
4940 .ok_or_else(|| ApiError::NotFound(format!("candidate `{id}` not found")))?;
4941 if cand.status != CandidateStatus::Pending.as_str() {
4942 return Err(ApiError::BadRequest(format!(
4943 "candidate `{id}` is not pending (status = `{}`)",
4944 cand.status
4945 )));
4946 }
4947 promote_candidate_to_finding(&s, &cand).await?;
4948 Ok(Json(candidate_to_quarantine_item(&cand)))
4949 } else {
4950 let row = manual_promote_finding_row(&s, &id).await?;
4955 Ok(Json(finding_to_quarantine_item(&row)))
4956 }
4957}
4958
4959async fn dismiss_quarantine(
4960 State(s): State<ServerState>,
4961 Path(id): Path<String>,
4962) -> Result<Json<QuarantineItem>, ApiError> {
4963 if id.starts_with("cand-") {
4964 let cand = s
4965 .store
4966 .candidate_findings()
4967 .get(&id)
4968 .await?
4969 .ok_or_else(|| ApiError::NotFound(format!("candidate `{id}` not found")))?;
4970 if cand.status != CandidateStatus::Pending.as_str() {
4971 return Err(ApiError::BadRequest(format!(
4972 "candidate `{id}` is not pending (status = `{}`)",
4973 cand.status
4974 )));
4975 }
4976 s.store.candidate_findings().set_status(&id, CandidateStatus::Dismissed.as_str()).await?;
4977 Ok(Json(candidate_to_quarantine_item(&cand)))
4978 } else {
4979 let row = manual_dismiss_finding_row(&s, &id).await?;
4980 Ok(Json(finding_to_quarantine_item(&row)))
4981 }
4982}
4983
4984async fn manual_promote_finding_row(s: &ServerState, id: &str) -> Result<FindingRecord, ApiError> {
4985 let existing = require_quarantined_finding(s, id).await?;
4986 let blob = serde_json::to_string(&json!({
4987 "kind": "ManualPromote",
4988 "from": "quarantine",
4989 "prev_provenance": existing.attack_provenance,
4990 "prev_verdict_blob": existing.verdict_blob,
4991 }))
4992 .map_err(|e| ApiError::Internal(format!("serialize manual-promote blob: {e}")))?;
4993 s.store.findings().manual_promote(id, "Open", &blob).await?;
4994 s.store
4995 .findings()
4996 .get(id)
4997 .await?
4998 .ok_or_else(|| ApiError::Internal("finding vanished after promote".to_string()))
4999}
5000
5001async fn manual_dismiss_finding_row(s: &ServerState, id: &str) -> Result<FindingRecord, ApiError> {
5002 let existing = require_quarantined_finding(s, id).await?;
5003 let blob = serde_json::to_string(&json!({
5004 "kind": "ManualDismiss",
5005 "from": "quarantine",
5006 "prev_provenance": existing.attack_provenance,
5007 "prev_verdict_blob": existing.verdict_blob,
5008 }))
5009 .map_err(|e| ApiError::Internal(format!("serialize manual-dismiss blob: {e}")))?;
5010 s.store.findings().manual_dismiss(id, &blob).await?;
5011 s.store
5012 .findings()
5013 .get(id)
5014 .await?
5015 .ok_or_else(|| ApiError::Internal("finding vanished after dismiss".to_string()))
5016}
5017
5018async fn require_quarantined_finding(s: &ServerState, id: &str) -> Result<FindingRecord, ApiError> {
5019 let existing = s
5020 .store
5021 .findings()
5022 .get(id)
5023 .await?
5024 .ok_or_else(|| ApiError::NotFound(format!("finding `{id}` not found")))?;
5025 if existing.status != "Quarantine" {
5026 return Err(ApiError::BadRequest(format!(
5027 "finding `{id}` is not in Quarantine (status = `{}`)",
5028 existing.status
5029 )));
5030 }
5031 Ok(existing)
5032}
5033
5034async fn promote_candidate_to_finding(
5035 s: &ServerState,
5036 cand: &CandidateFindingRecord,
5037) -> Result<(), ApiError> {
5038 let line = cand.line.unwrap_or(-1);
5039 let rule = cand.rule_hint.clone().unwrap_or_else(|| format!("ai-exploration:{}", cand.cap));
5040 let id = nyx_agent_core::store::finding_id_hash(
5041 &cand.repo,
5042 &cand.path,
5043 Some(line),
5044 &cand.cap,
5045 &rule,
5046 );
5047 let now = now_epoch_ms();
5048 let verdict_blob = serde_json::to_string(&json!({
5049 "kind": "ManualPromote",
5050 "from": "candidate",
5051 "candidate_id": cand.id,
5052 "rationale": cand.rationale,
5053 }))
5054 .map_err(|e| ApiError::Internal(format!("serialize verdict blob: {e}")))?;
5055 let rec = FindingRecord {
5056 id,
5057 run_id: cand.run_id.clone(),
5058 repo: cand.repo.clone(),
5059 path: cand.path.clone(),
5060 line: cand.line,
5061 cap: cand.cap.clone(),
5062 rule,
5063 severity: "High".to_string(),
5064 status: "Open".to_string(),
5069 finding_origin: "AiExploration".to_string(),
5070 first_seen: now,
5071 last_seen: now,
5072 superseded_by: None,
5073 triage_state: "Open".to_string(),
5074 triage_assigned_to: None,
5075 verdict_blob: Some(verdict_blob),
5076 repro_path: None,
5077 attack_provenance: Some("ManualPromote".to_string()),
5078 prompt_version: cand.prompt_version.clone(),
5079 chain_id: None,
5080 spec_id: None,
5081 };
5082 s.store.findings().upsert(&rec).await?;
5083 s.store.candidate_findings().set_status(&cand.id, CandidateStatus::Promoted.as_str()).await?;
5084 Ok(())
5085}
5086
5087fn finding_to_quarantine_item(f: &FindingRecord) -> QuarantineItem {
5088 QuarantineItem {
5089 kind: QuarantineKind::Finding,
5090 id: f.id.clone(),
5091 run_id: f.run_id.clone(),
5092 repo: f.repo.clone(),
5093 path: f.path.clone(),
5094 line: f.line,
5095 cap: f.cap.clone(),
5096 rule: Some(f.rule.clone()),
5097 severity: Some(f.severity.clone()),
5098 finding_origin: Some(f.finding_origin.clone()),
5099 prompt_version: f.prompt_version.clone(),
5100 attack_provenance: f.attack_provenance.clone(),
5101 rationale: None,
5102 verdict_blob: f.verdict_blob.clone(),
5103 last_seen: Some(f.last_seen),
5104 }
5105}
5106
5107fn candidate_to_quarantine_item(c: &CandidateFindingRecord) -> QuarantineItem {
5108 QuarantineItem {
5109 kind: QuarantineKind::Candidate,
5110 id: c.id.clone(),
5111 run_id: c.run_id.clone(),
5112 repo: c.repo.clone(),
5113 path: c.path.clone(),
5114 line: c.line,
5115 cap: c.cap.clone(),
5116 rule: c.rule_hint.clone(),
5117 severity: None,
5118 finding_origin: Some("AiExploration".to_string()),
5119 prompt_version: c.prompt_version.clone(),
5120 attack_provenance: None,
5121 rationale: c.rationale.clone(),
5122 verdict_blob: None,
5123 last_seen: None,
5124 }
5125}
5126
5127async fn traces_for_finding(
5136 State(s): State<ServerState>,
5137 Path(id): Path<String>,
5138) -> Result<Json<Vec<AgentTraceRow>>, ApiError> {
5139 let rows = if id.starts_with("cand-") {
5145 s.store.agent_traces().list_for_candidate(&id).await?
5146 } else {
5147 s.store.agent_traces().list_for_finding(&id).await?
5148 };
5149 Ok(Json(rows.into_iter().map(AgentTraceRow::from).collect()))
5150}
5151
5152async fn get_trace(
5153 State(s): State<ServerState>,
5154 Path(id): Path<String>,
5155) -> Result<Json<AgentTraceRow>, ApiError> {
5156 s.store
5157 .agent_traces()
5158 .get(&id)
5159 .await?
5160 .map(AgentTraceRow::from)
5161 .map(Json)
5162 .ok_or_else(|| ApiError::NotFound(format!("trace `{id}` not found")))
5163}
5164
5165#[derive(Debug, Deserialize)]
5168pub struct EventsQuery {
5169 #[serde(default)]
5170 pub run_id: Option<String>,
5171}
5172
5173async fn events_ws(
5174 State(s): State<ServerState>,
5175 Query(q): Query<EventsQuery>,
5176 ws: WebSocketUpgrade,
5177) -> Response {
5178 let rx = s.events.subscribe();
5184 let filter = q.run_id.clone();
5185 let replay = if let Some(run_id) = filter.as_deref() {
5186 s.replay.snapshot(run_id).await
5187 } else {
5188 Vec::new()
5189 };
5190 ws.on_upgrade(move |socket| handle_events_ws(socket, rx, filter, replay))
5191}
5192
5193async fn handle_events_ws(
5194 socket: WebSocket,
5195 mut rx: tokio::sync::broadcast::Receiver<AgentEvent>,
5196 run_filter: Option<String>,
5197 replay: Vec<AgentEvent>,
5198) {
5199 let (mut tx, mut rx_socket) = socket.split();
5200 for ev in replay {
5201 match serde_json::to_string(&ev) {
5202 Ok(payload) => {
5203 if tx.send(Message::Text(payload.into())).await.is_err() {
5204 return;
5205 }
5206 }
5207 Err(err) => {
5208 tracing::warn!(error = %err, "failed to serialize replay AgentEvent");
5209 }
5210 }
5211 }
5212 loop {
5213 tokio::select! {
5214 biased;
5215 client_msg = rx_socket.next() => {
5216 match client_msg {
5217 Some(Ok(Message::Close(_))) | None => break,
5218 Some(Ok(Message::Ping(payload))) => {
5219 if tx.send(Message::Pong(payload)).await.is_err() {
5220 break;
5221 }
5222 }
5223 Some(Ok(_)) => {
5224 }
5227 Some(Err(_)) => break,
5228 }
5229 }
5230 event = rx.recv() => {
5231 match event {
5232 Ok(ev) => {
5233 if !run_matches(&ev, run_filter.as_deref()) {
5234 continue;
5235 }
5236 match serde_json::to_string(&ev) {
5237 Ok(payload) => {
5238 if tx.send(Message::Text(payload.into())).await.is_err() {
5239 break;
5240 }
5241 }
5242 Err(err) => {
5243 tracing::warn!(error = %err, "failed to serialize AgentEvent");
5244 }
5245 }
5246 }
5247 Err(RecvError::Lagged(skipped)) => {
5248 let warning = json!({
5249 "kind": "Lagged",
5250 "skipped": skipped,
5251 });
5252 if tx.send(Message::Text(warning.to_string().into())).await.is_err() {
5253 break;
5254 }
5255 }
5256 Err(RecvError::Closed) => break,
5257 }
5258 }
5259 }
5260 }
5261}
5262
5263fn run_matches(ev: &AgentEvent, run_filter: Option<&str>) -> bool {
5264 let Some(want) = run_filter else { return true };
5265 match ev {
5266 AgentEvent::Run { data } => {
5267 let id = match data {
5268 RunEvent::Heartbeat { .. } => return true,
5269 RunEvent::RunStarted { run_id, .. }
5270 | RunEvent::ProjectStarted { run_id, .. }
5271 | RunEvent::PhaseStarted { run_id, .. }
5272 | RunEvent::PhaseFinished { run_id, .. }
5273 | RunEvent::EnvironmentStatus { run_id, .. }
5274 | RunEvent::AuthSessionStatus { run_id, .. }
5275 | RunEvent::LiveVerificationCapabilities { run_id, .. }
5276 | RunEvent::RepoStarted { run_id, .. }
5277 | RunEvent::RepoStaticDone { run_id, .. }
5278 | RunEvent::RepoDynamicDone { run_id, .. }
5279 | RunEvent::RepoFailed { run_id, .. }
5280 | RunEvent::RepoIngestFailed { run_id, .. }
5281 | RunEvent::RepoFinished { run_id, .. }
5282 | RunEvent::ProjectFinished { run_id, .. }
5283 | RunEvent::RunFinished { run_id, .. } => run_id.as_str(),
5284 };
5285 id == want
5286 }
5287 AgentEvent::Ai { data: AiEvent::BudgetTick { run_id, .. } } => run_id == want,
5288 AgentEvent::Sandbox { data } => {
5289 let run_id = match data {
5290 SandboxEvent::VerifierStarted { run_id, .. }
5291 | SandboxEvent::VerifierFinished { run_id, .. } => run_id.as_str(),
5292 };
5293 run_id == want
5294 }
5295 _ => true,
5296 }
5297}
5298
5299async fn run_summary(
5302 State(s): State<ServerState>,
5303 Path(id): Path<String>,
5304) -> Result<Json<RunCard>, ApiError> {
5305 let card = build_run_card(s.store.pool(), &id).await.map_err(run_card_to_api)?;
5306 Ok(Json(card))
5307}
5308
5309async fn run_summary_markdown(
5310 State(s): State<ServerState>,
5311 Path(id): Path<String>,
5312) -> Result<Response, ApiError> {
5313 let card = build_run_card(s.store.pool(), &id).await.map_err(run_card_to_api)?;
5314 let body = render_run_card_markdown(&card);
5315 Ok((StatusCode::OK, [(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], body)
5316 .into_response())
5317}
5318
5319async fn run_summary_html(
5320 State(s): State<ServerState>,
5321 Path(id): Path<String>,
5322) -> Result<Response, ApiError> {
5323 let card = build_run_card(s.store.pool(), &id).await.map_err(run_card_to_api)?;
5324 let body = render_run_card_html(&card);
5325 Ok((StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], body).into_response())
5326}
5327
5328fn run_card_to_api(err: RunCardError) -> ApiError {
5329 match err {
5330 RunCardError::NotFound(id) => ApiError::NotFound(format!("run `{id}` not found")),
5331 RunCardError::Store(e) => ApiError::Store(e),
5332 RunCardError::Sqlx(e) => ApiError::Internal(format!("sqlx: {e}")),
5333 }
5334}
5335
5336async fn create_repro_bundle(
5339 State(s): State<ServerState>,
5340 Path(id): Path<String>,
5341) -> Result<Json<BundleManifest>, ApiError> {
5342 let out_dir = s
5343 .state_bundles_dir
5344 .as_ref()
5345 .cloned()
5346 .ok_or_else(|| ApiError::Internal("bundle output dir is not configured".to_string()))?;
5347 let manifest =
5348 build_bundle(&s.store, &id, &out_dir, now_epoch_ms()).await.map_err(bundle_to_api)?;
5349 Ok(Json(manifest))
5350}
5351
5352async fn download_repro_bundle(
5353 State(s): State<ServerState>,
5354 Path(id): Path<String>,
5355) -> Result<Response, ApiError> {
5356 let bundles = s.store.repro_bundles().list_for_finding(&id).await?;
5357 let row = if let Some(latest) = bundles.last().cloned() {
5361 latest
5362 } else {
5363 let out_dir =
5364 s.state_bundles_dir.as_ref().cloned().ok_or_else(|| {
5365 ApiError::Internal("bundle output dir is not configured".to_string())
5366 })?;
5367 let manifest =
5368 build_bundle(&s.store, &id, &out_dir, now_epoch_ms()).await.map_err(bundle_to_api)?;
5369 s.store
5370 .repro_bundles()
5371 .list_for_finding(&id)
5372 .await?
5373 .into_iter()
5374 .find(|r| r.path == manifest.bundle_path.display().to_string())
5375 .ok_or_else(|| ApiError::Internal("bundle row vanished after build".to_string()))?
5376 };
5377
5378 let safe_path = ensure_bundle_path_inside_root(&row.path, s.state_bundles_dir.as_deref())?;
5379 let bytes = std::fs::read(&safe_path)
5380 .map_err(|e| ApiError::Internal(format!("read {}: {e}", safe_path.display())))?;
5381 let filename = format!("{id}.tar");
5382 Ok((
5383 StatusCode::OK,
5384 [
5385 (header::CONTENT_TYPE, "application/x-tar".to_string()),
5386 (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\"")),
5387 ("X-Nyx-Agent-Bundle-Sha256".parse().unwrap(), row.sha256),
5388 ],
5389 Body::from(bytes),
5390 )
5391 .into_response())
5392}
5393
5394fn ensure_bundle_path_inside_root(
5400 path: &str,
5401 bundles_dir: Option<&std::path::Path>,
5402) -> Result<std::path::PathBuf, ApiError> {
5403 let root = bundles_dir
5404 .ok_or_else(|| ApiError::Internal("bundle output dir is not configured".to_string()))?;
5405 let canonical_root = root
5406 .canonicalize()
5407 .map_err(|e| ApiError::Internal(format!("canonicalize bundles root: {e}")))?;
5408 let canonical_path = std::path::Path::new(path)
5409 .canonicalize()
5410 .map_err(|e| ApiError::Internal(format!("canonicalize bundle path `{path}`: {e}")))?;
5411 if !canonical_path.starts_with(&canonical_root) {
5412 return Err(ApiError::Internal("bundle path escapes configured root".to_string()));
5413 }
5414 Ok(canonical_path)
5415}
5416
5417fn bundle_to_api(err: BundleError) -> ApiError {
5418 match err {
5419 BundleError::FindingNotFound(id) => ApiError::NotFound(format!("finding `{id}` not found")),
5420 BundleError::Tar(e) => ApiError::Internal(format!("bundle tar write: {e}")),
5421 BundleError::Store(e) => ApiError::Store(e),
5422 BundleError::Io { path, source } => {
5423 ApiError::Internal(format!("bundle io at {}: {source}", path.display()))
5424 }
5425 }
5426}
5427
5428const REPLAY_WALL_CLOCK_TIMEOUT_SECS: u64 = 120;
5433const REPLAY_REAP_GRACE_SECS: u64 = 5;
5435
5436async fn replay_repro_bundle(
5437 State(s): State<ServerState>,
5438 Path(id): Path<String>,
5439) -> Result<Sse<impl Stream<Item = Result<SseEvent, std::convert::Infallible>>>, ApiError> {
5440 let bundles = s.store.repro_bundles().list_for_finding(&id).await?;
5442 let bundle_path: std::path::PathBuf = match bundles.last() {
5443 Some(row) => ensure_bundle_path_inside_root(&row.path, s.state_bundles_dir.as_deref())?,
5444 None => {
5445 let out_dir = s.state_bundles_dir.as_ref().cloned().ok_or_else(|| {
5446 ApiError::Internal("bundle output dir is not configured".to_string())
5447 })?;
5448 let manifest = build_bundle(&s.store, &id, &out_dir, now_epoch_ms())
5449 .await
5450 .map_err(bundle_to_api)?;
5451 ensure_bundle_path_inside_root(
5452 &manifest.bundle_path.display().to_string(),
5453 s.state_bundles_dir.as_deref(),
5454 )?
5455 }
5456 };
5457
5458 let extract_root = match tempfile::tempdir() {
5459 Ok(t) => t,
5460 Err(e) => return Err(ApiError::Internal(format!("tempdir: {e}"))),
5461 };
5462 let extract_path = extract_root.path().to_path_buf();
5463 let tar_bytes = std::fs::read(&bundle_path)
5464 .map_err(|e| ApiError::Internal(format!("read {}: {e}", bundle_path.display())))?;
5465 if let Some(expected) = bundles.last().map(|r| r.sha256.as_str()) {
5469 if !verify_bundle_sha256(&tar_bytes, expected) {
5470 return Err(ApiError::Internal(format!(
5471 "bundle integrity check failed for {}: stored sha256 does not match on-disk bytes",
5472 bundle_path.display()
5473 )));
5474 }
5475 }
5476 extract_ustar(&tar_bytes, &extract_path)
5477 .map_err(|e| ApiError::Internal(format!("extract bundle: {e}")))?;
5478 let repro_sh = extract_path.join(&id).join("repro.sh");
5479 if !repro_sh.exists() {
5480 return Err(ApiError::Internal(format!(
5481 "bundle did not contain repro.sh at {}",
5482 repro_sh.display()
5483 )));
5484 }
5485
5486 let started_at = now_epoch_ms();
5487 let store = s.store.clone();
5488 let bundle_id_for_status = bundles.last().map(|r| r.id.clone());
5489 let finding_id = id.clone();
5490 let events = s.events.clone();
5491 let bundle_path_str = bundle_path.display().to_string();
5492 let stream = async_stream::stream! {
5493 let _ = events.send(AgentEvent::Repro {
5494 data: ReproEvent::ReplayStarted {
5495 finding_id: finding_id.clone(),
5496 bundle_path: bundle_path_str.clone(),
5497 started_at_ms: started_at,
5498 },
5499 });
5500 yield Ok(SseEvent::default()
5501 .event("start")
5502 .data(serde_json::json!({
5503 "finding_id": finding_id,
5504 "bundle_path": bundle_path_str,
5505 "started_at_ms": started_at,
5506 }).to_string()));
5507
5508 let mut cmd = tokio::process::Command::new("bash");
5509 cmd.arg(&repro_sh);
5510 cmd.stdout(std::process::Stdio::piped());
5511 cmd.stderr(std::process::Stdio::piped());
5512 cmd.stdin(std::process::Stdio::null());
5513 cmd.kill_on_drop(true);
5514 let mut child = match cmd.spawn() {
5515 Ok(c) => c,
5516 Err(e) => {
5517 let msg = format!("spawn bash: {e}");
5518 let _ = events.send(AgentEvent::Repro {
5519 data: ReproEvent::ReplayError {
5520 finding_id: finding_id.clone(),
5521 message: msg.clone(),
5522 },
5523 });
5524 yield Ok(SseEvent::default().event("error").data(msg));
5525 yield Ok(SseEvent::default().event("end").data("error"));
5526 return;
5527 }
5528 };
5529 let stdout = child.stdout.take().expect("piped stdout");
5530 let stderr = child.stderr.take().expect("piped stderr");
5531 let mut stdout_lines = tokio::io::AsyncBufReadExt::lines(
5532 tokio::io::BufReader::new(stdout),
5533 );
5534 let mut stderr_lines = tokio::io::AsyncBufReadExt::lines(
5535 tokio::io::BufReader::new(stderr),
5536 );
5537 let deadline = tokio::time::Instant::now()
5542 + std::time::Duration::from_secs(REPLAY_WALL_CLOCK_TIMEOUT_SECS);
5543 let mut stdout_done = false;
5544 let mut stderr_done = false;
5545 let mut timed_out = false;
5546 while (!stdout_done || !stderr_done) && !timed_out {
5547 tokio::select! {
5548 _ = tokio::time::sleep_until(deadline) => {
5549 let _ = child.start_kill();
5550 let msg = format!(
5551 "replay exceeded {REPLAY_WALL_CLOCK_TIMEOUT_SECS}s wall-clock timeout; killed"
5552 );
5553 let _ = events.send(AgentEvent::Repro {
5554 data: ReproEvent::ReplayError {
5555 finding_id: finding_id.clone(),
5556 message: msg.clone(),
5557 },
5558 });
5559 yield Ok(SseEvent::default().event("error").data(msg));
5560 timed_out = true;
5561 }
5562 line = stdout_lines.next_line(), if !stdout_done => {
5563 match line {
5564 Ok(Some(text)) => {
5565 let _ = events.send(AgentEvent::Repro {
5566 data: ReproEvent::ReplayStdout {
5567 finding_id: finding_id.clone(),
5568 line: text.clone(),
5569 },
5570 });
5571 yield Ok(SseEvent::default().event("stdout").data(text));
5572 }
5573 Ok(None) => stdout_done = true,
5574 Err(e) => {
5575 let msg = format!("stdout read: {e}");
5576 let _ = events.send(AgentEvent::Repro {
5577 data: ReproEvent::ReplayError {
5578 finding_id: finding_id.clone(),
5579 message: msg.clone(),
5580 },
5581 });
5582 yield Ok(SseEvent::default().event("error").data(msg));
5583 stdout_done = true;
5584 }
5585 }
5586 }
5587 line = stderr_lines.next_line(), if !stderr_done => {
5588 match line {
5589 Ok(Some(text)) => {
5590 let _ = events.send(AgentEvent::Repro {
5591 data: ReproEvent::ReplayStderr {
5592 finding_id: finding_id.clone(),
5593 line: text.clone(),
5594 },
5595 });
5596 yield Ok(SseEvent::default().event("stderr").data(text));
5597 }
5598 Ok(None) => stderr_done = true,
5599 Err(e) => {
5600 let msg = format!("stderr read: {e}");
5601 let _ = events.send(AgentEvent::Repro {
5602 data: ReproEvent::ReplayError {
5603 finding_id: finding_id.clone(),
5604 message: msg.clone(),
5605 },
5606 });
5607 yield Ok(SseEvent::default().event("error").data(msg));
5608 stderr_done = true;
5609 }
5610 }
5611 }
5612 }
5613 }
5614 let status = match tokio::time::timeout(
5617 std::time::Duration::from_secs(REPLAY_REAP_GRACE_SECS),
5618 child.wait(),
5619 )
5620 .await
5621 {
5622 Ok(Ok(status)) => status,
5623 Ok(Err(e)) => {
5624 let msg = format!("wait: {e}");
5625 let _ = events.send(AgentEvent::Repro {
5626 data: ReproEvent::ReplayError {
5627 finding_id: finding_id.clone(),
5628 message: msg.clone(),
5629 },
5630 });
5631 yield Ok(SseEvent::default().event("error").data(msg));
5632 yield Ok(SseEvent::default().event("end").data("error"));
5633 return;
5634 }
5635 Err(_) => {
5636 let msg = format!(
5637 "child not reaped within {REPLAY_REAP_GRACE_SECS}s after kill"
5638 );
5639 let _ = events.send(AgentEvent::Repro {
5640 data: ReproEvent::ReplayError {
5641 finding_id: finding_id.clone(),
5642 message: msg.clone(),
5643 },
5644 });
5645 yield Ok(SseEvent::default().event("error").data(msg));
5646 yield Ok(SseEvent::default().event("end").data("error"));
5647 return;
5648 }
5649 };
5650 let exit_code = status.code().unwrap_or(-1);
5651 let finished_at = now_epoch_ms();
5652 let verdict = if exit_code == 0 { "Pass" } else { "Fail" };
5653 if let Some(bid) = bundle_id_for_status.as_deref() {
5654 if let Err(e) = store
5655 .repro_bundles()
5656 .record_replay(bid, finished_at, verdict)
5657 .await
5658 {
5659 tracing::warn!(error = %e, "failed to record replay status");
5660 }
5661 }
5662 drop(extract_root);
5664 let _ = events.send(AgentEvent::Repro {
5665 data: ReproEvent::ReplayFinished {
5666 finding_id: finding_id.clone(),
5667 status: verdict.to_string(),
5668 exit_code,
5669 started_at_ms: started_at,
5670 finished_at_ms: finished_at,
5671 duration_ms: finished_at - started_at,
5672 },
5673 });
5674 yield Ok(SseEvent::default()
5675 .event("end")
5676 .data(serde_json::json!({
5677 "exit_code": exit_code,
5678 "status": verdict,
5679 "started_at_ms": started_at,
5680 "finished_at_ms": finished_at,
5681 "duration_ms": finished_at - started_at,
5682 }).to_string()));
5683 };
5684 Ok(Sse::new(stream).keep_alive(Default::default()))
5685}
5686
5687fn extract_ustar(bytes: &[u8], dest: &std::path::Path) -> std::io::Result<()> {
5692 let mut archive = tar::Archive::new(std::io::Cursor::new(bytes));
5693 archive.set_preserve_permissions(true);
5698 archive.set_overwrite(true);
5699 for entry in archive.entries()? {
5700 let mut entry = entry?;
5701 let path = entry.path()?.into_owned();
5702 let safe = sanitise_tar_path(&path).ok_or_else(|| {
5703 std::io::Error::new(std::io::ErrorKind::InvalidData, "unsafe tar path")
5704 })?;
5705 let target = dest.join(safe);
5706 entry.unpack(&target)?;
5707 }
5708 Ok(())
5709}
5710
5711fn sanitise_tar_path(name: &std::path::Path) -> Option<std::path::PathBuf> {
5714 if name.is_absolute() {
5715 return None;
5716 }
5717 let mut out = std::path::PathBuf::new();
5718 for component in name.components() {
5719 match component {
5720 std::path::Component::Normal(s) => out.push(s),
5721 std::path::Component::CurDir => {}
5722 _ => return None,
5723 }
5724 }
5725 Some(out)
5726}
5727
5728#[derive(Debug, Serialize)]
5731struct StatusBody {
5732 ok: bool,
5733 message: String,
5734}
5735
5736impl StatusBody {
5737 fn ok(message: impl Into<String>) -> Self {
5738 Self { ok: true, message: message.into() }
5739 }
5740}
5741
5742impl IntoResponse for StatusBody {
5743 fn into_response(self) -> Response {
5744 Json(self).into_response()
5745 }
5746}