Skip to main content

orchestral_cli/remote/
server.rs

1use std::collections::BTreeMap;
2use std::net::SocketAddr;
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use anyhow::{bail, Context};
8use axum::Router;
9use clap::Args;
10use orchestral_artifact_r2::R2ArtifactStore;
11use orchestral_core::agent_protocol::reference::AgentRunStatus;
12use orchestral_core::io::{ArtifactPublisher, ArtifactResolver, BlobStore};
13use qrcode::render::unicode;
14use qrcode::QrCode;
15
16use crate::agent::{build_agent_host, AgentRunOptions};
17use crate::agent_connectors::{build_agent_directory, AgentJournalAccess};
18use crate::mcp_config::user_config_root;
19
20use super::api::{
21    is_retryable_agent_error, spawn_remembered_approval_driver, spawn_run_supervisor,
22};
23use super::{
24    router, router_with_artifact_origin, GatewayAuthenticator, JwtGatewayAuthenticator,
25    JwtGatewayConfig, PairingTicket, RemoteApiState, RemoteRegistry,
26};
27
28const DEFAULT_PAIRING_TTL_SECS: u64 = 5 * 60;
29const ARTIFACT_ENV_FILE: &str = "ORCHESTRAL_ARTIFACT_R2_ENV";
30const ARTIFACT_INTERNAL_URL: &str = "ORCHESTRAL_ARTIFACT_R2_INTERNAL_URL";
31const ARTIFACT_KEYCHAIN_SERVICE: &str = "ORCHESTRAL_ARTIFACT_R2_KEYCHAIN_SERVICE";
32const ARTIFACT_KEYCHAIN_ACCOUNT: &str = "ORCHESTRAL_ARTIFACT_R2_KEYCHAIN_ACCOUNT";
33
34#[derive(Debug, Clone, Args)]
35pub struct ServeCommand {
36    /// Address for the local Host gateway.
37    #[arg(long, default_value = "127.0.0.1:8765")]
38    listen: SocketAddr,
39
40    /// Browser-visible HTTPS URL when a relay or reverse proxy fronts this Host.
41    #[arg(long, value_name = "URL")]
42    public_url: Option<String>,
43
44    /// Issue a new one-time mobile device pairing link.
45    #[arg(long)]
46    pair: bool,
47
48    /// Pairing link lifetime.
49    #[arg(long, default_value_t = DEFAULT_PAIRING_TTL_SECS)]
50    pairing_ttl_secs: u64,
51
52    /// Override the durable paired-device/session registry.
53    #[arg(long, value_name = "PATH")]
54    state_file: Option<PathBuf>,
55
56    /// Permit a non-loopback browser URL over cleartext HTTP. Intended only
57    /// for a trusted LAN; installable PWA features normally require HTTPS.
58    #[arg(long)]
59    allow_insecure_http: bool,
60
61    /// Expected issuer for JWT assertions injected by an identity-aware reverse proxy.
62    #[arg(long, value_name = "URL")]
63    access_jwt_issuer: Option<String>,
64
65    /// Expected audience for reverse-proxy JWT assertions.
66    #[arg(long, value_name = "AUDIENCE")]
67    access_jwt_audience: Option<String>,
68
69    /// JWKS endpoint used to verify reverse-proxy JWT assertions.
70    #[arg(long, value_name = "URL")]
71    access_jwt_jwks_url: Option<String>,
72
73    /// Request header carrying the reverse-proxy JWT assertion.
74    #[arg(long, value_name = "HEADER")]
75    access_jwt_header: Option<String>,
76
77    /// Required JWT claim. Repeat for multiple NAME=VALUE checks; dotted names address nested claims.
78    #[arg(long = "access-jwt-required-claim", value_name = "NAME=VALUE")]
79    access_jwt_required_claims: Vec<String>,
80}
81
82pub(crate) async fn serve(command: ServeCommand, options: AgentRunOptions) -> anyhow::Result<()> {
83    validate_public_surface(&command)?;
84    let gateway_authenticator = build_gateway_authenticator(&command)?;
85    if gateway_authenticator.is_some() && command.pair {
86        bail!("--pair cannot be combined with gateway JWT authentication");
87    }
88    let pairing = command
89        .pair
90        .then(|| {
91            let ttl_ms = i64::try_from(command.pairing_ttl_secs)
92                .unwrap_or(i64::MAX)
93                .saturating_mul(1_000);
94            PairingTicket::issue(ttl_ms)
95        })
96        .transpose()?;
97    let state_path = command
98        .state_file
99        .clone()
100        .unwrap_or(user_config_root()?.join("remote-control.json"));
101    let registry = RemoteRegistry::open(&state_path, pairing.clone())?;
102    if registry.active_device_count().await == 0
103        && pairing.is_none()
104        && gateway_authenticator.is_none()
105    {
106        bail!("no paired mobile device exists; start with `orchestral serve --pair` to pair one");
107    }
108
109    let host = build_agent_host(&options).await?;
110    let artifact_store = configured_artifact_store()
111        .context("configure R2 Artifact store")?
112        .map(Arc::new);
113    let artifact_image_origin = artifact_store.as_ref().map(|store| store.access_origin());
114    let artifact_resolver = artifact_store
115        .as_ref()
116        .map(|store| Arc::clone(store) as Arc<dyn ArtifactResolver>);
117    let artifact_blob_store = artifact_store
118        .as_ref()
119        .map(|store| Arc::clone(store) as Arc<dyn BlobStore>);
120    let artifact_publisher = artifact_store
121        .as_ref()
122        .map(|store| Arc::clone(store) as Arc<dyn ArtifactPublisher>);
123    let agent_directory = build_agent_directory(
124        artifact_resolver.clone(),
125        artifact_blob_store.clone(),
126        artifact_publisher,
127        AgentJournalAccess::SingleWriter,
128    )
129    .await?;
130    let remote_state = RemoteApiState {
131        agent: host.api.clone(),
132        agent_directory,
133        native_session_defaults: super::state::NativeSessionDefaults {
134            cwd: Some(host.workspace_root.to_string_lossy().into_owned()),
135            execution_profile: host.execution_profile.clone(),
136        },
137        approvals: host.approvals.clone(),
138        registry,
139        gateway_authenticator: gateway_authenticator.clone(),
140        run_supervisors: Arc::default(),
141        session_coordinators: Arc::default(),
142        artifact_resolver,
143        artifact_blob_store,
144    };
145    recover_registered_runs(&remote_state).await;
146    let app =
147        Router::new()
148            .nest("/api/v1", router(remote_state))
149            .merge(router_with_artifact_origin(
150                artifact_image_origin.as_deref(),
151            ));
152    let listener = tokio::net::TcpListener::bind(command.listen)
153        .await
154        .with_context(|| format!("bind Orchestral Host gateway at {}", command.listen))?;
155    let local_address = listener.local_addr().context("read Host gateway address")?;
156    let public_url = command
157        .public_url
158        .clone()
159        .unwrap_or_else(|| format!("http://{local_address}"));
160
161    tracing::info!(
162        backend = host.backend_name,
163        model = host.model,
164        listen = %local_address,
165        "Orchestral Host listening"
166    );
167    tracing::info!(path = %state_path.display(), "Device registry opened");
168    if gateway_authenticator.is_some() {
169        tracing::info!(
170            mode = "signed_reverse_proxy_jwt",
171            "Remote authentication configured"
172        );
173    }
174    if let Some(ticket) = pairing {
175        let pairing_url = format!(
176            "{}/#pair={}",
177            public_url.trim_end_matches('/'),
178            ticket.secret()
179        );
180        print_pairing(&pairing_url, ticket.expires_at_unix_ms)?;
181    } else {
182        tracing::info!(%public_url, "Orchestral Host ready");
183    }
184
185    let result = axum::serve(listener, app)
186        .with_graceful_shutdown(shutdown_signal())
187        .await
188        .context("serve Orchestral Host gateway");
189    host.shutdown().await;
190    result
191}
192
193fn configured_artifact_store() -> anyhow::Result<Option<R2ArtifactStore>> {
194    if let Some(path) = std::env::var_os(ARTIFACT_ENV_FILE).filter(|value| !value.is_empty()) {
195        return R2ArtifactStore::from_env_file(PathBuf::from(path).as_path())
196            .map(Some)
197            .map_err(Into::into);
198    }
199
200    let internal_url = std::env::var(ARTIFACT_INTERNAL_URL).ok();
201    let keychain_service = std::env::var(ARTIFACT_KEYCHAIN_SERVICE).ok();
202    let keychain_account = std::env::var(ARTIFACT_KEYCHAIN_ACCOUNT).ok();
203    match (internal_url, keychain_service, keychain_account) {
204        (None, None, None) => Ok(None),
205        (Some(internal_url), Some(service), Some(account)) => {
206            R2ArtifactStore::from_macos_keychain(&internal_url, &service, &account)
207                .map(Some)
208                .map_err(Into::into)
209        }
210        _ => bail!(
211            "{ARTIFACT_INTERNAL_URL}, {ARTIFACT_KEYCHAIN_SERVICE}, and \
212             {ARTIFACT_KEYCHAIN_ACCOUNT} must be configured together"
213        ),
214    }
215}
216
217/// Restores every durable, non-terminal Run owned by the remote registry
218/// before the HTTP surface becomes reachable. Loading a Run into a fresh
219/// controller first records continuity loss; Provider recovery then verifies
220/// and replays its committed prefix, which also re-stages any pending approval
221/// in the replacement Host broker.
222async fn recover_registered_runs(state: &RemoteApiState) {
223    let mut incompatible_runs = 0_u64;
224    let mut manual_recovery_runs = 0_u64;
225    let mut supervised_runs = 0_u64;
226    let run_ids = match state.agent.catalog_runs().await {
227        Ok(entries) => entries
228            .into_iter()
229            .map(|entry| entry.run_id)
230            .collect::<std::collections::BTreeSet<_>>(),
231        Err(error) => {
232            tracing::warn!(%error, "could not enumerate durable Runs during Host recovery");
233            std::collections::BTreeSet::new()
234        }
235    };
236
237    for run_id in run_ids {
238        match state.agent.can_control_run(&run_id).await {
239            Ok(true) => {}
240            Ok(false) => {
241                incompatible_runs = incompatible_runs.saturating_add(1);
242                tracing::debug!(
243                    run_id = %run_id.as_str(),
244                    "ignored durable Run registered against an older Provider contract"
245                );
246                continue;
247            }
248            Err(error) => {
249                tracing::warn!(run_id = %run_id.as_str(), %error, "could not validate registered remote Run during Host recovery");
250                continue;
251            }
252        }
253        let view = match state.agent.inspect(&run_id).await {
254            Ok(view) => view,
255            Err(error) => {
256                tracing::warn!(run_id = %run_id.as_str(), %error, "could not inspect registered remote Run during Host recovery");
257                continue;
258            }
259        };
260        if view.state.is_terminal() {
261            continue;
262        }
263
264        if view.state.status() == AgentRunStatus::Unknown {
265            if let Err(error) = state.agent.recover(&run_id).await {
266                if is_retryable_agent_error(&error) {
267                    tracing::warn!(run_id = %run_id.as_str(), %error, "could not recover registered remote Run");
268                } else {
269                    manual_recovery_runs = manual_recovery_runs.saturating_add(1);
270                    state.run_supervisors.mark_manual(
271                        super::api::RunSupervisorRegistry::key(None, &run_id),
272                        error.to_string(),
273                    );
274                    tracing::debug!(run_id = %run_id.as_str(), %error, "registered remote Run requires manual recovery");
275                }
276                continue;
277            }
278        }
279
280        spawn_remembered_approval_driver(state.clone(), run_id);
281        supervised_runs = supervised_runs.saturating_add(1);
282    }
283
284    // Connector Runs live in independent Agent controllers and journals. They
285    // must be supervised at Host startup too; otherwise Codex continuity is
286    // restored only when a browser refresh happens to request that exact Run.
287    for descriptor in state.agent_directory.connectors().await {
288        let connector_id = descriptor.connector_id;
289        let agent = match state.agent_directory.agent_api(&connector_id).await {
290            Ok(agent) => agent,
291            Err(error) => {
292                tracing::warn!(connector_id = %connector_id.as_str(), %error, "could not open connector Agent controller during Host recovery");
293                continue;
294            }
295        };
296        let mut catalog = match agent.catalog_runs().await {
297            Ok(catalog) => catalog,
298            Err(error) => {
299                tracing::warn!(connector_id = %connector_id.as_str(), %error, "could not enumerate connector Runs during Host recovery");
300                continue;
301            }
302        };
303        catalog.sort_by_key(|entry| {
304            std::cmp::Reverse((entry.updated_at_unix_ms, entry.created_at_unix_ms))
305        });
306        let mut inspected_sessions = std::collections::BTreeSet::new();
307        for entry in catalog {
308            // The newest Run is the sole authority for a connector session.
309            // If it is terminal, an older Unknown Run was superseded and must
310            // never be resurrected as the session controller.
311            if !inspected_sessions.insert(entry.session_id.clone()) {
312                continue;
313            }
314            match agent.can_control_run(&entry.run_id).await {
315                Ok(true) => {}
316                Ok(false) => {
317                    incompatible_runs = incompatible_runs.saturating_add(1);
318                    tracing::debug!(
319                        connector_id = %connector_id.as_str(),
320                        run_id = %entry.run_id.as_str(),
321                        "ignored connector Run registered against an older Provider contract"
322                    );
323                    continue;
324                }
325                Err(error) => {
326                    tracing::warn!(
327                        connector_id = %connector_id.as_str(),
328                        run_id = %entry.run_id.as_str(),
329                        %error,
330                        "could not validate connector Run during Host recovery"
331                    );
332                    continue;
333                }
334            }
335            let view = match agent.inspect(&entry.run_id).await {
336                Ok(view) => view,
337                Err(error) => {
338                    tracing::warn!(
339                        connector_id = %connector_id.as_str(),
340                        run_id = %entry.run_id.as_str(),
341                        %error,
342                        "could not inspect connector Run during Host recovery"
343                    );
344                    continue;
345                }
346            };
347            if view.state.is_terminal() {
348                continue;
349            }
350            if view.state.status() == AgentRunStatus::Unknown {
351                if let Err(error) = agent.recover(&entry.run_id).await {
352                    if is_retryable_agent_error(&error) {
353                        tracing::warn!(
354                            connector_id = %connector_id.as_str(),
355                            run_id = %entry.run_id.as_str(),
356                            %error,
357                            "could not recover connector Run during Host recovery"
358                        );
359                    } else {
360                        manual_recovery_runs = manual_recovery_runs.saturating_add(1);
361                        state.run_supervisors.mark_manual(
362                            super::api::RunSupervisorRegistry::key(
363                                Some(&connector_id),
364                                &entry.run_id,
365                            ),
366                            error.to_string(),
367                        );
368                        tracing::debug!(
369                            connector_id = %connector_id.as_str(),
370                            run_id = %entry.run_id.as_str(),
371                            %error,
372                            "connector Run requires manual recovery"
373                        );
374                    }
375                    continue;
376                }
377            }
378            tracing::info!(
379                connector_id = %connector_id.as_str(),
380                run_id = %entry.run_id.as_str(),
381                "supervising connector Run during Host recovery"
382            );
383            spawn_run_supervisor(
384                state.clone(),
385                agent.clone(),
386                Some(connector_id.clone()),
387                entry.run_id,
388            );
389            supervised_runs = supervised_runs.saturating_add(1);
390        }
391    }
392    tracing::info!(
393        supervised_runs,
394        incompatible_runs,
395        manual_recovery_runs,
396        "completed durable Agent Run recovery audit"
397    );
398}
399
400fn validate_public_surface(command: &ServeCommand) -> anyhow::Result<()> {
401    if command.pairing_ttl_secs == 0 {
402        bail!("pairing TTL must be positive");
403    }
404    if let Some(public_url) = &command.public_url {
405        let secure = public_url.starts_with("https://");
406        let local = public_url.starts_with("http://127.0.0.1")
407            || public_url.starts_with("http://localhost")
408            || public_url.starts_with("http://[::1]");
409        if !secure && !local && !command.allow_insecure_http {
410            bail!(
411                "non-loopback public URL must use HTTPS; pass --allow-insecure-http only for a trusted LAN"
412            );
413        }
414        if !(public_url.starts_with("https://") || public_url.starts_with("http://")) {
415            bail!("public URL must begin with https:// or http://");
416        }
417    } else if !command.listen.ip().is_loopback() && !command.allow_insecure_http {
418        bail!(
419            "non-loopback listen requires --public-url https://... or explicit --allow-insecure-http"
420        );
421    }
422    Ok(())
423}
424
425fn build_gateway_authenticator(
426    command: &ServeCommand,
427) -> anyhow::Result<Option<Arc<dyn GatewayAuthenticator>>> {
428    let configured = command.access_jwt_issuer.is_some()
429        || command.access_jwt_audience.is_some()
430        || command.access_jwt_jwks_url.is_some()
431        || command.access_jwt_header.is_some()
432        || !command.access_jwt_required_claims.is_empty();
433    if !configured {
434        return Ok(None);
435    }
436    let issuer = command
437        .access_jwt_issuer
438        .clone()
439        .context("--access-jwt-issuer is required when gateway JWT authentication is enabled")?;
440    let audience = command
441        .access_jwt_audience
442        .clone()
443        .context("--access-jwt-audience is required when gateway JWT authentication is enabled")?;
444    let jwks_url = command
445        .access_jwt_jwks_url
446        .clone()
447        .context("--access-jwt-jwks-url is required when gateway JWT authentication is enabled")?;
448    let header = command
449        .access_jwt_header
450        .clone()
451        .context("--access-jwt-header is required when gateway JWT authentication is enabled")?;
452    let required_claims = parse_required_claims(&command.access_jwt_required_claims)?;
453    let config = JwtGatewayConfig::new(issuer, audience, jwks_url, header, required_claims)?;
454    Ok(Some(Arc::new(JwtGatewayAuthenticator::new(config)?)))
455}
456
457fn parse_required_claims(entries: &[String]) -> anyhow::Result<BTreeMap<String, String>> {
458    entries
459        .iter()
460        .try_fold(BTreeMap::new(), |mut claims, entry| {
461            let (name, value) = entry.split_once('=').with_context(|| {
462                format!("invalid --access-jwt-required-claim '{entry}'; expected NAME=VALUE")
463            })?;
464            if claims.insert(name.to_owned(), value.to_owned()).is_some() {
465                bail!("duplicate gateway JWT required claim '{name}'");
466            }
467            Ok(claims)
468        })
469}
470
471fn print_pairing(url: &str, expires_at_unix_ms: i64) -> anyhow::Result<()> {
472    let code = QrCode::new(url.as_bytes()).context("encode pairing QR")?;
473    let image = code
474        .render::<unicode::Dense1x2>()
475        .dark_color(unicode::Dense1x2::Light)
476        .light_color(unicode::Dense1x2::Dark)
477        .quiet_zone(true)
478        .build();
479    eprintln!("\nScan to pair this device (expires at {expires_at_unix_ms}):");
480    eprintln!("{image}");
481    eprintln!("{url}\n");
482    Ok(())
483}
484
485async fn shutdown_signal() {
486    let _ = tokio::signal::ctrl_c().await;
487    // Let in-flight HTTP responses finish before the Agent/MCP host shuts down.
488    tokio::time::sleep(Duration::from_millis(50)).await;
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn command(listen: &str, public_url: Option<&str>) -> ServeCommand {
496        ServeCommand {
497            listen: listen.parse().unwrap(),
498            public_url: public_url.map(str::to_owned),
499            pair: true,
500            pairing_ttl_secs: 300,
501            state_file: None,
502            allow_insecure_http: false,
503            access_jwt_issuer: None,
504            access_jwt_audience: None,
505            access_jwt_jwks_url: None,
506            access_jwt_header: None,
507            access_jwt_required_claims: Vec::new(),
508        }
509    }
510
511    #[test]
512    fn remote_cleartext_requires_an_explicit_lan_override() {
513        let remote = command("0.0.0.0:8765", Some("http://192.168.1.4:8765"));
514        assert!(validate_public_surface(&remote).is_err());
515        let secure = command("0.0.0.0:8765", Some("https://agent.example.test"));
516        assert!(validate_public_surface(&secure).is_ok());
517    }
518
519    #[test]
520    fn loopback_development_surface_is_allowed() {
521        assert!(validate_public_surface(&command("127.0.0.1:8765", None)).is_ok());
522    }
523
524    #[test]
525    fn gateway_jwt_configuration_is_all_or_nothing() {
526        let mut partial = command("127.0.0.1:8765", None);
527        partial.access_jwt_issuer = Some("https://access.example.com".to_owned());
528        assert!(build_gateway_authenticator(&partial).is_err());
529
530        partial.access_jwt_audience = Some("orchestral".to_owned());
531        partial.access_jwt_jwks_url = Some("https://access.example.com/keys".to_owned());
532        partial.access_jwt_header = Some("x-access-jwt".to_owned());
533        partial.access_jwt_required_claims = vec!["email=person@example.com".to_owned()];
534        assert!(build_gateway_authenticator(&partial).is_ok());
535    }
536}