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 #[arg(long, default_value = "127.0.0.1:8765")]
38 listen: SocketAddr,
39
40 #[arg(long, value_name = "URL")]
42 public_url: Option<String>,
43
44 #[arg(long)]
46 pair: bool,
47
48 #[arg(long, default_value_t = DEFAULT_PAIRING_TTL_SECS)]
50 pairing_ttl_secs: u64,
51
52 #[arg(long, value_name = "PATH")]
54 state_file: Option<PathBuf>,
55
56 #[arg(long)]
59 allow_insecure_http: bool,
60
61 #[arg(long, value_name = "URL")]
63 access_jwt_issuer: Option<String>,
64
65 #[arg(long, value_name = "AUDIENCE")]
67 access_jwt_audience: Option<String>,
68
69 #[arg(long, value_name = "URL")]
71 access_jwt_jwks_url: Option<String>,
72
73 #[arg(long, value_name = "HEADER")]
75 access_jwt_header: Option<String>,
76
77 #[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
217async 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 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 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 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}