Skip to main content

nyx_agent_api/
router.rs

1//! Axum router exposing the loopback HTTP and WebSocket surface.
2//!
3//! Routes live under `/api/v1/`; the WebSocket lives at
4//! `/api/v1/events`. Subscribers connect with an optional
5//! `?run_id=<id>` query parameter to filter the broadcast stream to a
6//! single run; without the filter every `AgentEvent` lands on the
7//! socket.
8
9use 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
86/// Build the production router with every `/api/v1/...` route attached.
87pub 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
185/// Bearer-token gate. Skipped entirely when [`AuthConfig::token`] is
186/// unset (the `--headless` path), and skipped on a per-route basis for
187/// `/health` plus the read-only setup status endpoint. The mutating
188/// wizard endpoints stay open only while setup is still pending. Once
189/// `nyx-agent.toml` exists, setup writes require the bearer token like
190/// every other mutation endpoint so an attacker cannot overwrite the
191/// operator's config.
192async 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    // `/webhook/git` carries its own HMAC auth so bypass the bearer
219    // gate; the handler refuses on bad signature.
220    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
363// ---- /setup -----------------------------------------------------------------
364
365async 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    // Persist secrets first so a failure there does not orphan a
459    // half-written config file. The keychain may legitimately reject
460    // calls in non-interactive environments (e.g. CI); surface that as
461    // a 500 with the precise reason.
462    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
559/// Lightweight check pass invoked by the wizard's step 3 to surface
560/// problems before the operator commits a config. Reports a list of
561/// per-check results rather than a single pass/fail so the UI can
562/// render targeted remediation hints.
563async 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    // Auto stays advisory because the chosen backend depends on the lane
754    // (chain vs fast) and is resolved at scan dispatch time. Every other
755    // variant routes through `nyx_agent_sandbox::probe` so the wizard's
756    // readiness tile shares its source of truth with the doctor and the
757    // run-time auto-selector.
758    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
775// ---- /projects --------------------------------------------------------------
776
777async 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    // Serialise the optional env_config value once so the patch borrow
857    // can reference an owned String that outlives the call.
858    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
3460/// Lightweight stable id helper. Concatenates a slug of `name` with the
3461/// supplied epoch ms so collisions across rapid creates are vanishingly
3462/// rare without pulling in a UUID crate dependency.
3463fn 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
3589// ---- /projects/:project_id/repos --------------------------------------------
3590
3591async 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    // Refuse re-POST against a different project so an operator cannot
3621    // silently re-home an existing repo via a same-name create call.
3622    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
3734/// Refuse a repo create/patch whose `auth_ref` would fail the same grammar
3735/// the ingestion crate expects (`ssh-key:<path>` / `token-env:<VAR>` /
3736/// `gh-app:<id>`). Validation runs only when the effective `source_kind`
3737/// is `git` / `github` / `gitlab`; other source kinds ignore `auth_ref`,
3738/// so we do not block on a stale value left over from a kind switch.
3739fn 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
3803// ---- /repos/test ------------------------------------------------------------
3804
3805/// Lightweight probe wired to the wizard's "test connectivity" button.
3806/// Performs only a read-only side effect (`git ls-remote` for git
3807/// sources, `stat` + read of `.git/config` for local-path sources). The
3808/// `project_id` from the route is validated to exist but otherwise does
3809/// not affect the probe (the call is stateless).
3810async 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    // Match ingestion-path env hardening: no terminal prompts, no user
3865    // git config bleed.
3866    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    // Otherwise a timed-out probe leaks the underlying `git ls-remote`
3873    // process: `tokio::time::timeout` drops the future that owns the
3874    // `Child`, but `tokio::process::Child` does not kill on drop by
3875    // default.
3876    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
3928/// Tiny line-oriented parser for the `[remote "origin"]` block's `url =`
3929/// key. Sufficient for the inspection use case; falls back gracefully on
3930/// exotic `include = path` configs (those return `None`).
3931fn 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// ---- /projects/:project_id/scan --------------------------------------------
3954
3955#[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    // A `?repo=...` filter scopes the trigger to a single repo; the
3973    // dispatcher / config-resolver downstream is responsible for
3974    // rejecting unknown names so this handler stays a thin pass-through.
3975    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
4034// ---- /projects/:project_id/integrations -----------------------------------
4035
4036async 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// ---- /runs ------------------------------------------------------------------
4179
4180#[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// ---- /findings --------------------------------------------------------------
4578
4579/// Composite filter for `GET /api/v1/findings`. Every field is
4580/// optional; combining them ANDs server-side. Quarantined rows are
4581/// hidden by default; the Quarantine view passes
4582/// `include_quarantine=true`.
4583#[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// `FindingDiffStatus`, `FindingWithDiff`, and `RunFindingsResponse`
4644// live in `nyx_agent_types::api`; re-imported at the top of this file.
4645
4646/// Composite filter for `GET /api/v1/runs/:id/findings`. Mirrors the
4647/// `FindingsQuery` shape minus `run_id` (taken from the path) and
4648/// `include_quarantine` (the run-scoped view always excludes
4649/// quarantined rows; the dedicated `/quarantine` endpoint covers that
4650/// surface).
4651#[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    // Findings observed in the prior run but absent from the current
4717    // run: surface as `Closed`. Their row body is the latest-known
4718    // shape (fetched by id from `findings`), filtered by the same
4719    // user-supplied facets so `?repo=X` does not bleed Closed rows
4720    // from other repos.
4721    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                    // Already closed in the prior run; not a regression.
4729                    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    // No prior membership row for this finding.
4762    if prior_known {
4763        // The prior run produced `run_findings` rows; the absence is
4764        // authoritative; this finding is new in the current run.
4765        return FindingDiffStatus::New;
4766    }
4767    // Pre-migration prior run (or no prior run at all). Fall back to
4768    // the legacy first-seen heuristic so freshly-observed rows still
4769    // surface as `New` and older rows wallpaper as `Unchanged`.
4770    if record.first_seen >= run_started_at {
4771        FindingDiffStatus::New
4772    } else {
4773        FindingDiffStatus::Unchanged
4774    }
4775}
4776
4777/// Same predicate `FindingStore::list_filtered` runs at the DB level,
4778/// applied in-memory to a row fetched by id. Used for the `Closed`
4779/// path where the row does not live in the current run's filtered
4780/// projection. Mirrors every facet `RunFindingsQuery` accepts.
4781fn 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// ---- /chains ----------------------------------------------------------------
4824
4825#[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// ---- /quarantine ------------------------------------------------------------
4859
4860#[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    // Most-recently-stamped findings first; candidates fall in after
4925    // (no `last_seen`).
4926    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        // Findings-table quarantine: flip status to 'Open' so the row
4951        // reappears in the Findings browser. The operator's manual
4952        // promote skips the dynamic-confirm gate by design (acceptance:
4953        // "Manually promoting it moves it to Findings.").
4954        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        // Manual promote skips the dynamic-verifier gate; mark Open
5065        // (not Verified) so the operator's intent is preserved
5066        // without claiming the row has been confirmed by the
5067        // sandbox-replayed differential.
5068        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
5127// ---- /traces ----------------------------------------------------------------
5128//
5129// `AgentTraceRow` lives in `nyx_agent_types::api`; the `From<AgentTraceRecord>`
5130// projection it carries drops the persistence-only `verifier_blob` field
5131// so the FE shape stays minimal. Lift `verifier_blob` onto the wire here
5132// when the trace viewer (Phase 24) starts rendering Verifier-row
5133// inputs/outputs without joining `findings.verdict_blob`.
5134
5135async fn traces_for_finding(
5136    State(s): State<ServerState>,
5137    Path(id): Path<String>,
5138) -> Result<Json<Vec<AgentTraceRow>>, ApiError> {
5139    // Candidate ids carry a `cand-` prefix (see
5140    // `nyx_agent::ai_pipeline::candidate_id`); route those through the
5141    // `candidate_findings.trace_id` back-link so the trace viewer can
5142    // render the proposing AI call for a Pending candidate. Finding ids
5143    // hit the direct `agent_traces.finding_id` index as before.
5144    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// ---- /events ----------------------------------------------------------------
5166
5167#[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    // Subscribe *before* reading the replay so events that fire between
5179    // snapshot and join still hit this receiver. The snapshot is sent
5180    // first; duplicate frames are idempotent client-side because
5181    // applyEvent in repoStatus.ts treats per-repo state as a fold over
5182    // the latest event per key.
5183    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                        // Ignore client-initiated frames; this stream is
5225                        // server-push only.
5226                    }
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
5299// ---- /runs/:id/summary ------------------------------------------------------
5300
5301async 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
5336// ---- /findings/:id/repro-bundle ---------------------------------------------
5337
5338async 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    // Most-recently-built bundle wins. If none, build one inline so the
5358    // operator can hit the download URL directly without first calling
5359    // POST /repro-bundle from a script.
5360    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
5394/// Defense-in-depth: refuse to read a `repro_bundles.path` value that
5395/// canonicalises outside the configured bundles root. `build_bundle` is the
5396/// only writer today, but a future migration / import / handler that takes a
5397/// path from JSON could otherwise turn the download endpoint into an
5398/// authenticated arbitrary-file-read.
5399fn 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
5428// ---- /findings/:id/replay ---------------------------------------------------
5429
5430/// Hard wall-clock ceiling on a single replay invocation. A runaway
5431/// `repro.sh` cannot keep a daemon worker pinned indefinitely.
5432const REPLAY_WALL_CLOCK_TIMEOUT_SECS: u64 = 120;
5433/// Grace window after SIGKILL for the kernel to reap the child.
5434const 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    // Resolve (or build) the most recent bundle on disk.
5441    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    // Guard against on-disk substitution between build_bundle (which
5466    // stamps repro_bundles.sha256) and this exec. If the row exists
5467    // and the digest disagrees, refuse to extract.
5468    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        // Deadline keeps a runaway repro.sh (infinite loop, `sleep
5538        // infinity`, etc.) from pinning a daemon worker. On expiry we
5539        // SIGKILL the child; kill_on_drop also fires if the SSE client
5540        // disconnects.
5541        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        // Bound the wait so a child that ignores SIGKILL (or a kernel
5615        // that is slow to reap) cannot pin the task forever either.
5616        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        // Keep the extracted tempdir alive until after the child exits.
5663        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
5687/// Extract the USTAR/PAX tarball produced by
5688/// `nyx_agent_core::report::repro_bundle::build_ustar`. Rejects entries
5689/// whose path escapes the destination via `..` or absolute components
5690/// so a substituted bundle cannot write outside the tempdir.
5691fn extract_ustar(bytes: &[u8], dest: &std::path::Path) -> std::io::Result<()> {
5692    let mut archive = tar::Archive::new(std::io::Cursor::new(bytes));
5693    // Honor on-record perms but refuse path traversal. The `tar` crate
5694    // calls this "Overwrite" + "PreservePermissions" + "Unpack"; the
5695    // default unpack already rejects `..`, but we sanitise explicitly
5696    // so a malformed PAX `path` extension cannot smuggle one in.
5697    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
5711/// Reject tar entries containing `..` components or absolute paths so
5712/// extraction stays inside the destination tempdir.
5713fn 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// ---- helpers ----------------------------------------------------------------
5729
5730#[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}