1use std::{
2 collections::BTreeMap,
3 env, fmt,
4 io::{self, IsTerminal, Write},
5 path::{Path, PathBuf},
6 sync::Arc,
7 time::Duration,
8};
9
10use anyhow::Context as _;
11use clap::{Parser, Subcommand};
12use cookie_agent::run;
13use cookie_agent_engine::{Engine, EngineOptions};
14use cookie_agent_models::{
15 ModelManager,
16 catalog::{CatalogManager, CatalogTransport, HttpCatalogTransport},
17 provider_store::ProviderStore,
18};
19use cookie_agent_protocol::{
20 AuthMethodDescriptor, ClientConnectId, ClientRequestId, EffectiveAuthState, McpAuthBeginParams,
21 McpServerState, ProviderConfigurationState, ProviderConnectResult, ProviderDescriptor,
22 ProviderDisconnectParams, ProviderDisconnectResult, ProviderId, ProviderSupportState,
23 RuntimeSnapshotResult, SafeCode, SafeSetupValue, parse_setup_value, paths, setup_value_text,
24};
25use cookie_agent_server::{
26 Client, ClientProtocol, Server, generate_token, in_process_pair, ready_line,
27 validate_websocket_url,
28};
29use cookie_agent_tools::{
30 BuiltinTools, delegate::DelegateToolProvider, message::MessageToolProvider,
31};
32use serde::Serialize;
33use tokio_util::sync::CancellationToken;
34use uuid::Uuid;
35use zeroize::{Zeroize, Zeroizing};
36
37#[cfg(all(test, unix))]
38use cookie_agent_protocol::{SetupFieldDescriptor, SetupFieldType};
39#[cfg(all(test, unix))]
40use std::sync::atomic::{AtomicUsize as TestAtomicUsize, Ordering as TestOrdering};
41
42const DEFAULT_WEBSOCKET_URL: &str = "ws://127.0.0.1:7419/ws";
43const CATALOG_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60);
44
45enum RunCatalogTransport {
46 Http(HttpCatalogTransport),
47 #[cfg(debug_assertions)]
48 Bundled,
49}
50
51impl CatalogTransport for RunCatalogTransport {
52 fn fetch(
53 &self,
54 request: cookie_agent_models::catalog::CatalogRequest,
55 ) -> cookie_agent_models::catalog::CatalogTransportFuture<'_> {
56 match self {
57 Self::Http(transport) => transport.fetch(request),
58 #[cfg(debug_assertions)]
59 Self::Bundled => Box::pin(async {
60 Ok(
61 cookie_agent_models::catalog::CatalogTransportResponse::from_bytes(
62 200,
63 cookie_agent_models::catalog::MODELS_DEV_BOOTSTRAP.to_vec(),
64 ),
65 )
66 }),
67 }
68 }
69}
70
71fn run_catalog_transport() -> anyhow::Result<RunCatalogTransport> {
72 #[cfg(debug_assertions)]
73 if matches!(
74 env::var("COOKIE_AGENT_TEST_BUNDLED_CATALOG").as_deref(),
75 Ok("1")
76 ) {
77 return Ok(RunCatalogTransport::Bundled);
78 }
79 HttpCatalogTransport::new()
80 .context("construct fixed catalog transport")
81 .map(RunCatalogTransport::Http)
82}
83
84#[cfg(all(test, unix))]
85static SECRET_VALUES_WIPED: TestAtomicUsize = TestAtomicUsize::new(0);
86
87#[derive(Debug, Parser)]
88#[command(name = "cookie", version = env!("COOKIE_VERSION"))]
89struct Cli {
90 #[command(subcommand)]
91 command: Option<Command>,
92}
93
94#[derive(Debug, PartialEq, Eq, Subcommand)]
95enum Command {
96 Run {
98 #[command(flatten)]
99 args: Box<run::RunArgs>,
100 },
101 Daemon {
103 #[arg(long)]
106 port: Option<u16>,
107 },
108 Attach {
110 #[arg(long, default_value = DEFAULT_WEBSOCKET_URL)]
111 url: String,
112 #[arg(long, env = "COOKIE_DAEMON_TOKEN", hide_env_values = true)]
113 token: String,
114 },
115 Connect {
117 provider_id: Option<String>,
119 #[arg(long, default_value = DEFAULT_WEBSOCKET_URL)]
120 url: String,
121 #[arg(long, env = "COOKIE_DAEMON_TOKEN", hide_env_values = true)]
122 token: String,
123 },
124 Disconnect {
126 provider_id: Option<String>,
128 #[arg(long, default_value = DEFAULT_WEBSOCKET_URL)]
129 url: String,
130 #[arg(long, env = "COOKIE_DAEMON_TOKEN", hide_env_values = true)]
131 token: String,
132 },
133 Mcp {
135 #[command(subcommand)]
136 command: McpCommand,
137 #[arg(long, default_value = DEFAULT_WEBSOCKET_URL)]
138 url: String,
139 #[arg(long, env = "COOKIE_DAEMON_TOKEN", hide_env_values = true)]
140 token: String,
141 },
142}
143
144#[derive(Debug, PartialEq, Eq, Subcommand)]
145enum McpCommand {
146 List,
148 Auth { server: String },
150}
151
152struct Runtime {
153 engine: Engine,
154 server: Arc<Server>,
155 port: u16,
156 catalog_refresh_shutdown: CancellationToken,
157 catalog_refresh_task: Option<tokio::task::JoinHandle<()>>,
158}
159
160impl Runtime {
161 async fn stop_catalog_refresh(&mut self) {
162 self.catalog_refresh_shutdown.cancel();
163 if let Some(task) = self.catalog_refresh_task.take() {
164 let _ = task.await;
165 }
166 }
167}
168
169impl Drop for Runtime {
170 fn drop(&mut self) {
171 self.catalog_refresh_shutdown.cancel();
172 }
173}
174
175#[derive(Default, Serialize)]
176#[serde(transparent)]
177struct SecretValues(BTreeMap<String, String>);
178
179impl fmt::Debug for SecretValues {
180 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181 formatter.write_str("SecretValues(<redacted>)")
182 }
183}
184
185impl Drop for SecretValues {
186 fn drop(&mut self) {
187 for value in self.0.values_mut() {
188 value.zeroize();
189 }
190 #[cfg(all(test, unix))]
191 SECRET_VALUES_WIPED.fetch_add(1, TestOrdering::SeqCst);
192 }
193}
194
195#[derive(Serialize)]
196struct SensitiveProviderConnectParams {
197 provider_id: ProviderId,
198 expected_catalog_revision: cookie_agent_protocol::CatalogRevision,
199 setup_values: BTreeMap<cookie_agent_protocol::SetupFieldId, SafeSetupValue>,
200 auth_method: cookie_agent_protocol::AuthMethodId,
201 auth_values: SecretValues,
202 client_connect_id: ClientConnectId,
203}
204
205trait ConnectIo {
206 fn write_line(&mut self, line: &str) -> anyhow::Result<()>;
207 fn read_public(&mut self, prompt: &str) -> anyhow::Result<String>;
208 fn read_secret(&mut self, prompt: &str) -> anyhow::Result<Zeroizing<String>>;
209}
210
211struct StdioConnectIo;
212
213impl ConnectIo for StdioConnectIo {
214 fn write_line(&mut self, line: &str) -> anyhow::Result<()> {
215 println!("{line}");
216 Ok(())
217 }
218
219 fn read_public(&mut self, prompt: &str) -> anyhow::Result<String> {
220 read_public_line(prompt)
221 }
222
223 fn read_secret(&mut self, prompt: &str) -> anyhow::Result<Zeroizing<String>> {
224 read_secret_line(prompt)
225 }
226}
227
228#[tokio::main]
229async fn main() {
230 if let Err(error) = main_result().await {
231 eprintln!(
232 "cookie: {}",
233 cookie_agent_protocol::diagnostics::detail(&format!("{error:#}"))
234 );
235 std::process::exit(1);
236 }
237}
238
239async fn main_result() -> anyhow::Result<()> {
240 tracing_subscriber::fmt::init();
241 let Cli { command } = Cli::parse();
242 if let Some(Command::Run { args }) = &command
243 && let Err(message) = args.validate_cli()
244 {
245 clap::Error::raw(clap::error::ErrorKind::ArgumentConflict, message).exit();
246 }
247 let workspace = if matches!(&command, Some(Command::Run { .. })) {
248 None
249 } else {
250 local_workspace(&command, env::current_dir)?
251 };
252 match command {
253 Some(Command::Run { args }) => {
254 let workspace = match env::current_dir().context("determine current run workspace") {
255 Ok(path) => path,
256 Err(error) => {
257 eprintln!(
258 "cookie run: {}",
259 cookie_agent_protocol::diagnostics::detail(&format!("{error:#}"))
260 );
261 std::process::exit(run::EXIT_ENVIRONMENT);
262 }
263 };
264 let data_dir = match args.data_dir.clone().map_or_else(data_dir, Ok) {
265 Ok(path) => path,
266 Err(error) => {
267 eprintln!(
268 "cookie run: {}",
269 cookie_agent_protocol::diagnostics::detail(&format!("{error:#}"))
270 );
271 std::process::exit(run::EXIT_ENVIRONMENT);
272 }
273 };
274 let mut runtime = match compose_with(
275 &workspace,
276 run_catalog_transport,
277 CatalogManager::standard,
278 ProviderStore::standard,
279 || Ok(data_dir),
280 )
281 .await
282 {
283 Ok(runtime) => runtime,
284 Err(error) => {
285 eprintln!(
286 "cookie run: {}",
287 cookie_agent_protocol::diagnostics::detail(&format!("{error:#}"))
288 );
289 std::process::exit(run::EXIT_ENVIRONMENT);
290 }
291 };
292 let exit_code = run::execute(&runtime.engine, *args).await;
293 runtime.stop_catalog_refresh().await;
294 runtime.engine.shutdown().await;
295 if exit_code != 0 {
296 std::process::exit(exit_code);
297 }
298 Ok(())
299 }
300 Some(Command::Daemon { port }) => {
301 run_daemon(
302 compose(workspace.as_deref().expect("daemon workspace")).await?,
303 port,
304 )
305 .await
306 }
307 Some(Command::Connect {
308 provider_id,
309 url,
310 token,
311 }) => run_connect(&url, &token, provider_id).await,
312 Some(Command::Disconnect {
313 provider_id,
314 url,
315 token,
316 }) => run_disconnect(&url, &token, provider_id).await,
317 Some(Command::Mcp {
318 command,
319 url,
320 token,
321 }) => run_mcp(&url, &token, command).await,
322 Some(Command::Attach { url, token }) => run_attached_tui(&url, &token).await,
323 None => {
324 run_local_frontend(compose(workspace.as_deref().expect("local workspace")).await?).await
325 }
326 }
327}
328
329fn local_workspace(
330 command: &Option<Command>,
331 current_dir: impl FnOnce() -> io::Result<PathBuf>,
332) -> anyhow::Result<Option<PathBuf>> {
333 if matches!(
334 command,
335 Some(
336 Command::Attach { .. }
337 | Command::Connect { .. }
338 | Command::Disconnect { .. }
339 | Command::Mcp { .. },
340 )
341 ) {
342 Ok(None)
343 } else {
344 current_dir()
345 .context("determine current workspace")
346 .map(Some)
347 }
348}
349
350async fn compose(workspace: &Path) -> anyhow::Result<Runtime> {
351 compose_with(
352 workspace,
353 || HttpCatalogTransport::new().context("construct fixed catalog transport"),
354 CatalogManager::standard,
355 ProviderStore::standard,
356 data_dir,
357 )
358 .await
359}
360
361async fn compose_with<T: CatalogTransport + 'static>(
362 workspace: &Path,
363 open_transport: impl FnOnce() -> anyhow::Result<T>,
364 open_catalog: impl FnOnce(T) -> CatalogManager<T>,
365 open_provider_store: impl FnOnce() -> Result<
366 ProviderStore,
367 cookie_agent_models::provider_store::ProviderStoreError,
368 >,
369 open_data_dir: impl FnOnce() -> anyhow::Result<PathBuf>,
370) -> anyhow::Result<Runtime> {
371 let configuration =
372 cookie_agent_config::load(workspace).context("load workspace configuration and agents")?;
373 compose_with_configuration(
374 workspace,
375 configuration,
376 open_transport,
377 open_catalog,
378 open_provider_store,
379 open_data_dir,
380 )
381 .await
382}
383
384async fn compose_with_configuration<T: CatalogTransport + 'static>(
385 workspace: &Path,
386 configuration: cookie_agent_config::LoadedConfiguration,
387 open_transport: impl FnOnce() -> anyhow::Result<T>,
388 open_catalog: impl FnOnce(T) -> CatalogManager<T>,
389 open_provider_store: impl FnOnce() -> Result<
390 ProviderStore,
391 cookie_agent_models::provider_store::ProviderStoreError,
392 >,
393 open_data_dir: impl FnOnce() -> anyhow::Result<PathBuf>,
394) -> anyhow::Result<Runtime> {
395 if configuration.runtime.server.host != "127.0.0.1" {
396 anyhow::bail!("server.host must be exactly 127.0.0.1");
397 }
398 let port = configuration.runtime.server.port;
399
400 let catalog_manager = open_catalog(open_transport()?);
403 let catalog = Arc::new(
404 catalog_manager
405 .load_cached()
406 .context("load cached models.dev catalog")?,
407 );
408 let provider_store = open_provider_store().context("open provider store 3")?;
409 let model_manager = Arc::new(
410 ModelManager::new_with_headers(
411 configuration.runtime.providers.clone(),
412 configuration.runtime.headers.clone(),
413 catalog,
414 provider_store,
415 )
416 .context("compose effective providers and current model runtime")?,
417 );
418 let engine = Engine::open(EngineOptions {
419 data_dir: open_data_dir()?,
420 cwd: workspace.to_owned(),
421 config: configuration,
422 model_manager,
423 tools: vec![Arc::new(BuiltinTools::new(workspace))],
424 })
425 .context("open manifests, rehydrate project state, and reconcile engine")?;
426 if let Err(error) = engine
427 .try_register_tool_provider(Arc::new(DelegateToolProvider::new(engine.clone())))
428 .context("register delegate tools")
429 {
430 engine.shutdown().await;
431 return Err(error);
432 }
433 if let Err(error) = engine
434 .try_register_tool_provider(Arc::new(MessageToolProvider::new(engine.clone())))
435 .context("register message tools")
436 {
437 engine.shutdown().await;
438 return Err(error);
439 }
440 if let Err(error) = engine
441 .try_register_tool_provider(Arc::new(cookie_agent_tools::skill::SkillTool::new(
442 engine.clone(),
443 )))
444 .context("register skill tool")
445 {
446 engine.shutdown().await;
447 return Err(error);
448 }
449 if let Err(error) = engine
450 .try_register_tool_provider(Arc::new(cookie_agent_tools::goal::GoalTools::new(
451 engine.clone(),
452 )))
453 .context("register goal tools")
454 {
455 engine.shutdown().await;
456 return Err(error);
457 }
458 let server = Arc::new(Server::new(engine.clone()));
459 let catalog_refresh_shutdown = CancellationToken::new();
460 let catalog_refresh_task = tokio::spawn(run_catalog_refresh_loop(
461 catalog_manager,
462 engine.clone(),
463 catalog_refresh_shutdown.clone(),
464 CATALOG_REFRESH_INTERVAL,
465 ));
466 Ok(Runtime {
467 engine,
468 server,
469 port,
470 catalog_refresh_shutdown,
471 catalog_refresh_task: Some(catalog_refresh_task),
472 })
473}
474
475async fn run_catalog_refresh_loop<T: CatalogTransport + 'static>(
476 catalog_manager: CatalogManager<T>,
477 engine: Engine,
478 shutdown: CancellationToken,
479 cadence: Duration,
480) {
481 let mut first = true;
483 loop {
484 if !std::mem::take(&mut first) {
485 tokio::select! {
486 () = shutdown.cancelled() => return,
487 () = tokio::time::sleep(cadence) => {}
488 }
489 }
490 let refreshed = tokio::select! {
491 () = shutdown.cancelled() => return,
492 refreshed = catalog_manager.refresh() => refreshed,
493 };
494 let Ok(catalog) = refreshed else {
495 continue;
496 };
497 if !catalog_publication_changed(&engine, &catalog) {
498 continue;
499 }
500 if engine.refresh_catalog(Arc::new(catalog)).is_err() {
501 continue;
502 }
503 }
504}
505
506fn catalog_publication_changed(
510 engine: &Engine,
511 catalog: &cookie_agent_models::catalog::CatalogSnapshot,
512) -> bool {
513 let current = engine.current_runtime();
514 let current = current.models.catalog();
515 current.revision != catalog.revision || current.state.availability != catalog.state.availability
516}
517
518fn data_dir() -> anyhow::Result<PathBuf> {
519 paths::user_data_root().context("determine home directory for cookie agent data")
520}
521
522#[cfg(feature = "tui")]
523async fn run_local_frontend(mut runtime: Runtime) -> anyhow::Result<()> {
524 let (client_stream, server_stream) = in_process_pair(128);
525 let server_task = tokio::spawn(runtime.server.clone().serve_stream(server_stream));
526 let result = async {
527 let client = cookie_agent_tui::Client::connect_stream(client_stream);
528 client.handshake().await.context("handshake with daemon")?;
529 cookie_agent_tui::run_with_new_session(client).await
530 }
531 .await;
532 runtime.server.shutdown();
533 runtime.stop_catalog_refresh().await;
534 let server_result = server_task
535 .await
536 .context("join in-process server task")?
537 .context("run in-process server task");
538 runtime.engine.shutdown().await;
539 result.and(server_result)
540}
541
542#[cfg(not(feature = "tui"))]
543async fn run_local_frontend(mut runtime: Runtime) -> anyhow::Result<()> {
544 runtime.server.shutdown();
545 runtime.stop_catalog_refresh().await;
546 runtime.engine.shutdown().await;
547 anyhow::bail!(
548 "cookie was built without TUI support; use `cookie daemon`, `cookie connect`, or `cookie disconnect`"
549 )
550}
551
552#[cfg(feature = "tui")]
553async fn run_attached_tui(url: &str, token: &str) -> anyhow::Result<()> {
554 validate_websocket_url(url)?;
555 let client = cookie_agent_tui::Client::connect_websocket_with_token(url, token)
556 .await
557 .context("connect to daemon WebSocket")?;
558 client.handshake().await.context("handshake with daemon")?;
559 cookie_agent_tui::run_with_client(client).await
560}
561
562#[cfg(not(feature = "tui"))]
563async fn run_attached_tui(url: &str, _token: &str) -> anyhow::Result<()> {
564 validate_websocket_url(url)?;
565 anyhow::bail!("cookie was built without TUI support")
566}
567
568async fn runtime_snapshot(
569 client: &(impl ClientProtocol + ?Sized),
570) -> anyhow::Result<RuntimeSnapshotResult> {
571 client
572 .runtime_snapshot()
573 .await
574 .context("runtime.snapshot.get failed")
575}
576
577async fn run_connect(url: &str, token: &str, provider_id: Option<String>) -> anyhow::Result<()> {
578 require_interactive_tty(
579 io::stdin().is_terminal(),
580 io::stdout().is_terminal(),
581 io::stderr().is_terminal(),
582 "connect",
583 )?;
584 let client = Client::connect_websocket_with_token(url, token).await?;
585 let mut io = StdioConnectIo;
586 run_connect_with(&client, provider_id, &mut io).await
587}
588
589async fn run_connect_with<I: ConnectIo>(
590 client: &Client,
591 provider_id: Option<String>,
592 io: &mut I,
593) -> anyhow::Result<()> {
594 client.handshake().await.context("handshake with daemon")?;
595 let runtime = runtime_snapshot(client).await?;
596 let provider = choose_provider(&runtime.snapshot.providers, provider_id, io)?;
597 print_provider_details(provider, runtime.snapshot.catalog_revision.as_str(), io)?;
598 ensure_supported(provider)?;
599 io.write_line("Scope: durable, global (available to all workspaces).")?;
600 if !matches!(
601 io.read_public(connect_confirmation_prompt(provider))?
602 .to_ascii_lowercase()
603 .as_str(),
604 "y" | "yes"
605 ) {
606 anyhow::bail!("provider connection cancelled");
607 }
608
609 let setup_values = collect_setup_values(provider, |prompt| io.read_public(prompt))?;
610 let auth_method = choose_auth_method(provider, io)?;
611 let auth_values = collect_auth_values(auth_method, |prompt| io.read_secret(prompt))?;
612 let result: ProviderConnectResult = client
613 .call_sensitive(
614 "provider.connect",
615 SensitiveProviderConnectParams {
616 provider_id: provider.id.clone(),
617 expected_catalog_revision: runtime.snapshot.catalog_revision,
618 setup_values,
619 auth_method: auth_method.id.clone(),
620 auth_values,
621 client_connect_id: ClientConnectId::new(Uuid::now_v7().to_string())
622 .expect("UUID is a valid connect ID"),
623 },
624 )
625 .await
626 .context("provider.connect failed")?;
627 io.write_line(&format!(
628 "provider.connect succeeded for {} at runtime revision {}{}.",
629 result.durable_connection.provider_id,
630 result.runtime.runtime_revision,
631 if result.replayed { " (replayed)" } else { "" }
632 ))?;
633 Ok(())
634}
635
636async fn run_disconnect(url: &str, token: &str, provider_id: Option<String>) -> anyhow::Result<()> {
637 require_interactive_tty(
638 io::stdin().is_terminal(),
639 io::stdout().is_terminal(),
640 io::stderr().is_terminal(),
641 "disconnect",
642 )?;
643 let client = Client::connect_websocket_with_token(url, token).await?;
644 let mut io = StdioConnectIo;
645 client.handshake().await.context("handshake with daemon")?;
646 let runtime = runtime_snapshot(&client).await?;
647 let provider = choose_provider(&runtime.snapshot.providers, provider_id, &mut io)?;
648 print_provider_details(
649 provider,
650 runtime.snapshot.catalog_revision.as_str(),
651 &mut io,
652 )?;
653 println!("Scope: durable, global (available to all workspaces).");
654 if !prompt_confirmation("Disconnect this managed provider? [y/N] ")? {
655 anyhow::bail!("provider disconnection cancelled");
656 }
657 let result: ProviderDisconnectResult = client
658 .disconnect_provider(ProviderDisconnectParams {
659 provider_id: provider.id.clone(),
660 expected_runtime_revision: runtime.snapshot.runtime_revision,
661 expected_provider_state_revision: runtime.snapshot.provider_state_revision,
662 expected_connection_generation: provider
663 .durable_connection
664 .as_ref()
665 .map(|connection| connection.connection_generation),
666 client_request_id: ClientRequestId::new(Uuid::now_v7().to_string())
667 .expect("UUID is a valid request ID"),
668 })
669 .await
670 .context("provider.disconnect failed")?;
671 println!(
672 "provider.disconnect succeeded for {} at runtime revision {}{}.",
673 result.provider_id,
674 result.runtime.snapshot.runtime_revision,
675 if result.replayed { " (replayed)" } else { "" }
676 );
677 Ok(())
678}
679
680async fn run_mcp(url: &str, token: &str, command: McpCommand) -> anyhow::Result<()> {
681 let client = Client::connect_websocket_with_token(url, token).await?;
682 client.handshake().await.context("handshake with daemon")?;
683 match command {
684 McpCommand::List => {
685 let servers = client
686 .list_mcp_servers()
687 .await
688 .context("mcp.server.list failed")?
689 .servers;
690 if servers.is_empty() {
691 println!("No MCP servers are configured.");
692 }
693 for server in servers {
694 println!(
695 "{}\t{} tools\t{:?}",
696 server.name, server.tool_count, server.state
697 );
698 }
699 Ok(())
700 }
701 McpCommand::Auth { server } => {
702 let status = client
703 .list_mcp_servers()
704 .await
705 .context("mcp.server.list failed")?
706 .servers
707 .into_iter()
708 .find(|candidate| candidate.name == server)
709 .with_context(|| format!("unknown MCP server `{server}`"))?;
710 if status.state != McpServerState::NeedsAuth {
711 anyhow::bail!(
712 "MCP server `{server}` does not currently require OAuth authorization"
713 );
714 }
715 let result = client
716 .begin_mcp_auth(McpAuthBeginParams { server })
717 .await
718 .context("mcp.auth.begin failed")?;
719 println!(
720 "Open this URL to authenticate MCP server `{}`:",
721 result.server
722 );
723 println!("{}", result.authorization_url);
724 Ok(())
725 }
726 }
727}
728
729fn ensure_supported(provider: &ProviderDescriptor) -> anyhow::Result<()> {
730 if provider.support.state != ProviderSupportState::Supported {
731 let reason = provider
732 .support
733 .reason
734 .as_ref()
735 .map_or("unspecified", SafeCode::as_str);
736 anyhow::bail!("provider is not connectable: {reason}");
737 }
738 Ok(())
739}
740
741fn connect_confirmation_prompt(provider: &ProviderDescriptor) -> &'static str {
742 if matches!(
743 provider.configuration,
744 ProviderConfigurationState::Authored | ProviderConfigurationState::AuthoredAndStored
745 ) && provider.effective_auth_state == EffectiveAuthState::Unavailable
746 {
747 "Complete setup and authentication for this authored provider? [y/N] "
748 } else if provider.durable_connection.is_some() {
749 "Reconnect or update this provider? [y/N] "
750 } else {
751 "Connect this provider? [y/N] "
752 }
753}
754
755fn collect_setup_values(
756 provider: &ProviderDescriptor,
757 mut read: impl FnMut(&str) -> anyhow::Result<String>,
758) -> anyhow::Result<BTreeMap<cookie_agent_protocol::SetupFieldId, SafeSetupValue>> {
759 let stored = provider
760 .durable_connection
761 .as_ref()
762 .map(|connection| &connection.setup_values);
763 provider
764 .setup_fields
765 .iter()
766 .filter_map(|field| {
767 let existing = stored.and_then(|values| values.get(&field.id));
768 let fallback = existing.or(field.default.as_ref());
769 let prompt = if let Some(value) = fallback {
770 format!("{} [{}]: ", field.display_name, setup_value_text(value))
771 } else {
772 format!("{}: ", field.display_name)
773 };
774 let answer = match read(&prompt) {
775 Ok(answer) => answer,
776 Err(error) => return Some(Err(error)),
777 };
778 if answer.is_empty() {
779 return fallback
780 .cloned()
781 .map(|value| Ok((field.id.clone(), value)))
782 .or_else(|| {
783 field.required.then(|| {
784 Err(anyhow::anyhow!(
785 "required setup field `{}` was blank",
786 field.id
787 ))
788 })
789 });
790 }
791 Some(
792 parse_setup_value(field, &answer)
793 .map_err(anyhow::Error::from)
794 .map(|value| (field.id.clone(), value)),
795 )
796 })
797 .collect()
798}
799
800fn choose_auth_method<'a>(
801 provider: &'a ProviderDescriptor,
802 io: &mut impl ConnectIo,
803) -> anyhow::Result<&'a AuthMethodDescriptor> {
804 if provider.auth_methods.is_empty() {
805 anyhow::bail!("provider has no supported authentication method");
806 }
807 if provider.auth_methods.len() == 1 {
808 return Ok(&provider.auth_methods[0]);
809 }
810 io.write_line("Authentication methods:")?;
811 for (index, method) in provider.auth_methods.iter().enumerate() {
812 io.write_line(&format!(
813 " {}. {} ({})",
814 index + 1,
815 method.display_name,
816 method.id
817 ))?;
818 }
819 let answer = io.read_public("Authentication method number or ID: ")?;
820 if let Ok(index) = answer.parse::<usize>()
821 && let Some(method) = index
822 .checked_sub(1)
823 .and_then(|index| provider.auth_methods.get(index))
824 {
825 return Ok(method);
826 }
827 provider
828 .auth_methods
829 .iter()
830 .find(|method| method.id.as_str() == answer)
831 .with_context(|| format!("authentication method `{answer}` was not found"))
832}
833
834fn collect_auth_values(
835 method: &AuthMethodDescriptor,
836 mut read: impl FnMut(&str) -> anyhow::Result<Zeroizing<String>>,
837) -> anyhow::Result<SecretValues> {
838 let mut values = SecretValues::default();
839 for field in &method.credentials {
840 let mut value = read(&format!(
841 "{} (secret, blank does not retain): ",
842 field.display_name
843 ))?;
844 if value.is_empty() {
845 if field.required {
846 anyhow::bail!("required credential field `{}` was blank", field.id);
847 }
848 } else {
849 values
850 .0
851 .insert(field.id.as_str().to_owned(), std::mem::take(&mut *value));
852 }
853 }
854 Ok(values)
855}
856
857fn require_interactive_tty(
858 stdin: bool,
859 stdout: bool,
860 stderr: bool,
861 command: &str,
862) -> anyhow::Result<()> {
863 if !(stdin && stdout && stderr) {
864 anyhow::bail!(
865 "cookie {command} requires an interactive TTY; credentials are never accepted as command-line arguments"
866 );
867 }
868 Ok(())
869}
870
871fn choose_provider<'a>(
872 providers: &'a [ProviderDescriptor],
873 requested: Option<String>,
874 io: &mut impl ConnectIo,
875) -> anyhow::Result<&'a ProviderDescriptor> {
876 if let Some(requested) = requested {
877 return providers
878 .iter()
879 .find(|provider| provider.id.as_str() == requested)
880 .with_context(|| format!("runtime provider `{requested}` was not found"));
881 }
882 if providers.is_empty() {
883 anyhow::bail!("the runtime has no providers");
884 }
885 io.write_line("Runtime providers:")?;
886 for (index, provider) in providers.iter().enumerate() {
887 let support = provider.support.reason.as_ref().map_or_else(
888 || format!("{:?}", provider.support.state).to_ascii_lowercase(),
889 |reason| format!("{:?}: {}", provider.support.state, reason).to_ascii_lowercase(),
890 );
891 io.write_line(&format!(
892 " {}. {} ({}) — {}",
893 index + 1,
894 provider.display_name,
895 provider.id,
896 support
897 ))?;
898 }
899 let answer = io.read_public("Provider number or ID: ")?;
900 if let Ok(index) = answer.parse::<usize>()
901 && let Some(provider) = index.checked_sub(1).and_then(|index| providers.get(index))
902 {
903 return Ok(provider);
904 }
905 providers
906 .iter()
907 .find(|provider| provider.id.as_str() == answer)
908 .with_context(|| format!("runtime provider `{answer}` was not found"))
909}
910
911fn print_provider_details(
912 provider: &ProviderDescriptor,
913 revision: &str,
914 io: &mut impl ConnectIo,
915) -> anyhow::Result<()> {
916 io.write_line(&format!("Provider ID: {}", provider.id))?;
917 io.write_line(&format!("Name: {}", provider.display_name))?;
918 io.write_line(&format!("Catalog revision: {revision}"))?;
919 io.write_line(&format!("Presence: {:?}", provider.presence))?;
920 io.write_line(&format!("Support: {:?}", provider.support.state))?;
921 if let Some(reason) = &provider.support.reason {
922 io.write_line(&format!("Support reason: {reason}"))?;
923 }
924 io.write_line(&format!("Configuration: {:?}", provider.configuration))?;
925 Ok(())
926}
927
928fn read_public_line(prompt: &str) -> anyhow::Result<String> {
929 print!("{prompt}");
930 io::stdout().flush().context("flush prompt")?;
931 let mut answer = String::new();
932 io::stdin().read_line(&mut answer).context("read input")?;
933 Ok(answer.trim().to_owned())
934}
935
936fn prompt_confirmation(prompt: &str) -> anyhow::Result<bool> {
937 Ok(matches!(
938 read_public_line(prompt)?.to_ascii_lowercase().as_str(),
939 "y" | "yes"
940 ))
941}
942
943#[cfg(unix)]
944fn read_secret_line(prompt: &str) -> anyhow::Result<Zeroizing<String>> {
945 struct EchoGuard(libc::termios);
946 impl Drop for EchoGuard {
947 fn drop(&mut self) {
948 unsafe {
950 libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &self.0);
951 }
952 }
953 }
954
955 print!("{prompt}");
956 io::stdout().flush().context("flush credential prompt")?;
957 let mut original = unsafe { std::mem::zeroed::<libc::termios>() };
959 if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut original) } != 0 {
961 return Err(io::Error::last_os_error()).context("disable credential echo");
962 }
963 let guard = EchoGuard(original);
964 let mut hidden = original;
965 hidden.c_lflag &= !libc::ECHO;
966 if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, &hidden) } != 0 {
968 return Err(io::Error::last_os_error()).context("disable credential echo");
969 }
970 let mut value = String::new();
971 let read = io::stdin().read_line(&mut value).context("read credential");
972 drop(guard);
973 println!();
974 read?;
975 while matches!(value.as_bytes().last(), Some(b'\n' | b'\r')) {
976 value.pop();
977 }
978 Ok(Zeroizing::new(value))
979}
980
981#[cfg(windows)]
982fn read_secret_line(prompt: &str) -> anyhow::Result<Zeroizing<String>> {
983 use windows_sys::Win32::{
984 Foundation::{HANDLE, INVALID_HANDLE_VALUE},
985 System::Console::{
986 ENABLE_ECHO_INPUT, GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, SetConsoleMode,
987 },
988 };
989
990 struct ConsoleModeGuard {
991 handle: HANDLE,
992 mode: u32,
993 }
994 impl Drop for ConsoleModeGuard {
995 fn drop(&mut self) {
996 unsafe {
998 SetConsoleMode(self.handle, self.mode);
999 }
1000 }
1001 }
1002
1003 print!("{prompt}");
1004 io::stdout().flush().context("flush credential prompt")?;
1005 let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
1007 if handle.is_null() || handle == INVALID_HANDLE_VALUE {
1008 return Err(io::Error::last_os_error()).context("open credential console");
1009 }
1010 let mut original = 0;
1011 if unsafe { GetConsoleMode(handle, &mut original) } == 0 {
1013 return Err(io::Error::last_os_error()).context("read credential console mode");
1014 }
1015 if unsafe { SetConsoleMode(handle, original & !ENABLE_ECHO_INPUT) } == 0 {
1017 return Err(io::Error::last_os_error()).context("disable credential echo");
1018 }
1019 let guard = ConsoleModeGuard {
1020 handle,
1021 mode: original,
1022 };
1023 let mut value = String::new();
1024 let read = io::stdin().read_line(&mut value).context("read credential");
1025 drop(guard);
1026 println!();
1027 read?;
1028 while matches!(value.as_bytes().last(), Some(b'\n' | b'\r')) {
1029 value.pop();
1030 }
1031 Ok(Zeroizing::new(value))
1032}
1033
1034async fn run_daemon(mut runtime: Runtime, port_override: Option<u16>) -> anyhow::Result<()> {
1035 let token = generate_token().context("generate daemon authentication token")?;
1036 let port = port_override.unwrap_or(runtime.port);
1037 let listener = match runtime.server.clone().serve(port, token.clone()).await {
1038 Ok(listener) => listener,
1039 Err(error) => {
1040 runtime.server.shutdown();
1041 runtime.stop_catalog_refresh().await;
1042 runtime.engine.shutdown().await;
1043 return Err(anyhow::Error::new(error).context("start WebSocket daemon"));
1044 }
1045 };
1046 let url = format!("ws://{}/ws", listener.address());
1047 println!("{}", ready_line(&url, token.as_str()));
1048 drop(token);
1049 println!(
1050 "cookie daemon listening on {url} (protocol {})",
1051 cookie_agent_protocol::PROTOCOL_VERSION
1052 );
1053 let signal = tokio::signal::ctrl_c().await;
1054 runtime.server.shutdown();
1055 runtime.stop_catalog_refresh().await;
1056 listener.wait().await;
1057 runtime.engine.shutdown().await;
1058 signal.context("wait for daemon shutdown signal")
1059}
1060
1061#[cfg(test)]
1062#[path = "../../../test-support/config_harness.rs"]
1063mod config_harness;
1064
1065#[cfg(all(test, unix))]
1066mod tests;