返回 CodeWhale
daemon_socket.rs
根目录 / crates / app-server / src / daemon_socket.rs
1 //! Unix-domain-socket daemon transport (Desktop Phase 0, socket half).
2 //!
3 //! The desktop shell attaches to a long-lived `codewhale app-server --socket`
4 //! daemon over a local socket instead of a TCP port: local multi-client,
5 //! peer-credential auth, nothing to firewall (CORE-PROTOCOL spec §5). The
6 //! wire is *identical* to the `--stdio` transport — newline-delimited
7 //! JSON-RPC 2.0 driven by the same `crate::run_stdio_loop` — with exactly
8 //! one addition in front of it: a `daemon/attach` handshake that establishes
9 //! who this client is and whether it owns the daemon.
10 //!
11 //! # Endpoint resolution
12 //!
13 //! In precedence order (see [`resolve_socket_path`]):
14 //!
15 //! 1. an explicit path (`--socket-path`);
16 //! 2. `$CODEWHALE_HOME/run/daemon.sock` when `CODEWHALE_HOME` is set — an
17 //! explicit home is an isolation boundary, so its daemon must not collide
18 //! with the default one;
19 //! 3. `$XDG_RUNTIME_DIR/codewhale/daemon.sock`;
20 //! 4. macOS: `~/Library/Application Support/codewhale/daemon.sock`;
21 //! 5. `~/.codewhale/run/daemon.sock`.
22 //!
23 //! Windows is reserved as the named pipe [`WINDOWS_NAMED_PIPE`]; binding
24 //! there returns [`DaemonSocketError::UnsupportedPlatform`] rather than
25 //! silently falling back to TCP.
26 //!
27 //! # Ownership
28 //!
29 //! Hermes' claim model, server-side: a client attaches with `mode: "claim"`
30 //! (it spawned the daemon and will manage its lifetime) or `mode: "attach"`
31 //! (it found a healthy daemon and is a guest). Only the current owner may
32 //! `shutdown` the daemon; guests get `not_daemon_owner`. When the owner
33 //! disconnects the slot frees, so a relaunched shell can re-claim the daemon
34 //! it left running — sessions survive UI restarts because the daemon does.
35
36 use std::path::{Path, PathBuf};
37
38 use serde::{Deserialize, Serialize};
39
40 /// Basename of the daemon socket inside the Codewhale runtime directory.
41 pub const DAEMON_SOCKET_FILE_NAME: &str = "daemon.sock";
42
43 /// Reserved Windows endpoint. Not implemented yet; binding on Windows fails
44 /// with [`DaemonSocketError::UnsupportedPlatform`] naming this pipe.
45 pub const WINDOWS_NAMED_PIPE: &str = r"\\.\pipe\codewhale-daemon";
46
47 /// JSON-RPC method a client must send first on a daemon-socket connection.
48 pub const ATTACH_METHOD: &str = "daemon/attach";
49
50 /// Longest socket path the kernel accepts (`sun_path` minus the NUL).
51 pub const MAX_SOCKET_PATH_BYTES: usize = if cfg!(any(target_os = "macos", target_os = "ios")) {
52 103
53 } else {
54 107
55 };
56
57 /// Typed failures of the daemon socket transport.
58 #[derive(Debug, thiserror::Error)]
59 pub enum DaemonSocketError {
60 /// The platform has no daemon socket implementation. Never a silent
61 /// fallback: the caller must pick another transport explicitly.
62 #[error(
63 "the daemon socket transport is not supported on {platform}; the reserved endpoint \
64 there is the named pipe {planned_endpoint}, which is not implemented yet"
65 )]
66 UnsupportedPlatform {
67 platform: &'static str,
68 planned_endpoint: &'static str,
69 },
70 /// No home directory (or runtime directory) to derive a default path from.
71 #[error(
72 "cannot resolve the Codewhale runtime directory for the daemon socket: no home directory"
73 )]
74 RuntimeDirUnavailable,
75 /// `CODEWHALE_HOME` is set but not a usable absolute path.
76 #[error("invalid CODEWHALE_HOME override: {0}")]
77 InvalidHomeOverride(String),
78 /// Unix socket paths are limited to roughly one hundred bytes.
79 #[error("daemon socket path {} is {len} bytes; this platform allows at most {max}", path.display())]
80 PathTooLong {
81 path: PathBuf,
82 len: usize,
83 max: usize,
84 },
85 /// Something other than a socket already sits at the path. Refused so a
86 /// misconfigured path can never delete a user's file.
87 #[error("{} exists and is not a unix socket; refusing to remove it", path.display())]
88 NotASocket { path: PathBuf },
89 /// A daemon answered on the socket: this one must not replace it.
90 #[error(
91 "a live listener already answers on {}; refusing to replace it (another codewhale daemon, or something else bound to this path)",
92 path.display()
93 )]
94 AlreadyRunning { path: PathBuf },
95 /// The liveness probe neither connected nor was refused within the
96 /// budget. Refused rather than clobbered; remove the file by hand if the
97 /// old daemon is truly gone.
98 #[error("liveness probe of {} timed out; refusing to replace a socket that may be live", path.display())]
99 ProbeTimedOut { path: PathBuf },
100 /// Filesystem or socket I/O failed.
101 #[error("{context} ({})", path.display())]
102 Io {
103 context: &'static str,
104 path: PathBuf,
105 #[source]
106 source: std::io::Error,
107 },
108 /// The app-server state (config, state store, runtime) failed to build.
109 #[error("failed to build daemon state")]
110 State(#[source] anyhow::Error),
111 }
112
113 /// How to start the daemon socket transport.
114 #[derive(Debug, Clone, Default)]
115 pub struct DaemonSocketOptions {
116 /// Explicit socket path; `None` resolves the platform default.
117 pub socket_path: Option<PathBuf>,
118 /// Explicit config file, like `app-server --config`.
119 pub config_path: Option<PathBuf>,
120 }
121
122 /// Inputs to [`resolve_socket_path`], separated from the environment so the
123 /// precedence rules are a pure, testable function.
124 #[derive(Debug, Clone, Default)]
125 pub struct SocketPathInputs {
126 /// `--socket-path`.
127 pub explicit: Option<PathBuf>,
128 /// A valid explicit `CODEWHALE_HOME`.
129 pub codewhale_home_override: Option<PathBuf>,
130 /// `$XDG_RUNTIME_DIR`, when set and non-empty.
131 pub xdg_runtime_dir: Option<PathBuf>,
132 /// The user's home directory.
133 pub user_home: Option<PathBuf>,
134 /// Whether the macOS Application Support layout applies.
135 pub macos: bool,
136 }
137
138 impl SocketPathInputs {
139 /// Capture the live environment.
140 pub fn from_environment(explicit: Option<PathBuf>) -> Result<Self, DaemonSocketError> {
141 let codewhale_home_override = codewhale_paths::codewhale_home_override()
142 .map_err(|err| DaemonSocketError::InvalidHomeOverride(err.to_string()))?;
143 let xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
144 .filter(|value| !value.is_empty())
145 .map(PathBuf::from);
146 Ok(Self {
147 explicit,
148 codewhale_home_override,
149 xdg_runtime_dir,
150 user_home: codewhale_paths::user_home(),
151 macos: cfg!(target_os = "macos"),
152 })
153 }
154 }
155
156 /// Apply the precedence rules documented at the module level and enforce the
157 /// kernel's path-length limit.
158 pub fn resolve_socket_path(inputs: &SocketPathInputs) -> Result<PathBuf, DaemonSocketError> {
159 let path = if let Some(explicit) = inputs.explicit.clone() {
160 explicit
161 } else if let Some(home) = inputs.codewhale_home_override.clone() {
162 home.join("run").join(DAEMON_SOCKET_FILE_NAME)
163 } else if let Some(runtime_dir) = inputs.xdg_runtime_dir.clone() {
164 runtime_dir.join("codewhale").join(DAEMON_SOCKET_FILE_NAME)
165 } else {
166 let user_home = inputs
167 .user_home
168 .clone()
169 .ok_or(DaemonSocketError::RuntimeDirUnavailable)?;
170 if inputs.macos {
171 user_home
172 .join("Library")
173 .join("Application Support")
174 .join("codewhale")
175 .join(DAEMON_SOCKET_FILE_NAME)
176 } else {
177 user_home
178 .join(codewhale_paths::CODEWHALE_APP_DIR)
179 .join("run")
180 .join(DAEMON_SOCKET_FILE_NAME)
181 }
182 };
183 let len = path.as_os_str().len();
184 if len > MAX_SOCKET_PATH_BYTES {
185 return Err(DaemonSocketError::PathTooLong {
186 path,
187 len,
188 max: MAX_SOCKET_PATH_BYTES,
189 });
190 }
191 Ok(path)
192 }
193
194 /// The socket path this host would use with no explicit override.
195 #[cfg(unix)]
196 pub fn default_socket_path() -> Result<PathBuf, DaemonSocketError> {
197 resolve_socket_path(&SocketPathInputs::from_environment(None)?)
198 }
199
200 /// The socket path this host would use with no explicit override.
201 #[cfg(not(unix))]
202 pub fn default_socket_path() -> Result<PathBuf, DaemonSocketError> {
203 Err(unsupported_platform())
204 }
205
206 /// Who is on the other end of a daemon-socket connection.
207 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208 pub struct ClientIdentity {
209 /// Product name of the client, e.g. `codewhale-desktop`.
210 pub name: String,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub version: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub pid: Option<u32>,
215 }
216
217 /// Ownership intent carried by `daemon/attach`.
218 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
219 #[serde(rename_all = "snake_case")]
220 pub enum AttachMode {
221 /// A guest: use the daemon, never stop it.
222 #[default]
223 Attach,
224 /// The daemon's owner: may `shutdown`. Fails if a live owner exists.
225 Claim,
226 }
227
228 /// Role granted by a successful attach.
229 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230 #[serde(rename_all = "snake_case")]
231 pub enum AttachRole {
232 Owner,
233 Attached,
234 }
235
236 /// `daemon/attach` params.
237 #[derive(Debug, Clone, Deserialize)]
238 pub struct AttachParams {
239 pub client: ClientIdentity,
240 #[serde(default)]
241 pub mode: AttachMode,
242 /// Bundle-skew guard: when set, the daemon refuses the attach unless its
243 /// own version string matches exactly.
244 #[serde(default)]
245 pub expect_daemon_version: Option<String>,
246 }
247
248 /// The typed refusal every non-unix entry point returns. Unused in the unix
249 /// library build by construction; the tests pin its wording on every host.
250 #[cfg_attr(unix, allow(dead_code))]
251 fn unsupported_platform() -> DaemonSocketError {
252 DaemonSocketError::UnsupportedPlatform {
253 platform: std::env::consts::OS,
254 planned_endpoint: WINDOWS_NAMED_PIPE,
255 }
256 }
257
258 #[cfg(unix)]
259 mod platform {
260 use std::collections::HashMap;
261 use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
262 use std::path::{Path, PathBuf};
263 use std::sync::{Arc, Mutex};
264 use std::time::{Duration, Instant};
265
266 use anyhow::Result;
267 use serde_json::{Value, json};
268 use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, BufReader, Lines};
269 use tokio::net::{UnixListener, UnixStream};
270 use tokio::sync::watch;
271 use tokio::task::JoinSet;
272
273 use super::{
274 ATTACH_METHOD, AttachMode, AttachParams, AttachRole, ClientIdentity, DaemonSocketError,
275 DaemonSocketOptions, SocketPathInputs, resolve_socket_path,
276 };
277 use crate::{
278 AppState, AppTransport, JsonRpcError, ParsedStdioLine, ShutdownAuthority, StdioLoopExit,
279 StdioLoopPolicy, build_state_with_transport, dispatch_stdio_request_with_writer,
280 jsonrpc_error, jsonrpc_result, legacy_deepseek_compat, params_or_object, parse_params,
281 parse_stdio_line, run_stdio_loop, write_stdio_line,
282 };
283
284 /// How long the stale-socket probe waits for a connect to resolve.
285 const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
286
287 /// Facts about this daemon, reported in every attach reply.
288 #[derive(Debug)]
289 struct DaemonInfo {
290 pid: u32,
291 version: &'static str,
292 /// Owner uid of the socket file, i.e. the daemon's effective uid.
293 uid: u32,
294 socket_path: PathBuf,
295 started_at: Instant,
296 }
297
298 #[derive(Debug, Default)]
299 struct ConnectionRegistry {
300 next_id: u64,
301 connections: HashMap<u64, ClientIdentity>,
302 owner: Option<u64>,
303 }
304
305 impl ConnectionRegistry {
306 fn register(&mut self, client: ClientIdentity) -> u64 {
307 self.next_id += 1;
308 let id = self.next_id;
309 self.connections.insert(id, client);
310 id
311 }
312
313 /// Take the owner slot, or report who holds it.
314 fn claim(&mut self, id: u64) -> Result<(), ClientIdentity> {
315 if let Some(owner_id) = self.owner
316 && owner_id != id
317 && let Some(owner) = self.connections.get(&owner_id)
318 {
319 return Err(owner.clone());
320 }
321 self.owner = Some(id);
322 Ok(())
323 }
324
325 fn owner(&self) -> Option<ClientIdentity> {
326 self.owner.and_then(|id| self.connections.get(&id)).cloned()
327 }
328
329 fn remove(&mut self, id: u64) {
330 self.connections.remove(&id);
331 if self.owner == Some(id) {
332 self.owner = None;
333 }
334 }
335 }
336
337 /// Releases the registry slot (and the owner claim) on drop, whichever
338 /// way the connection ends.
339 struct ConnectionGuard {
340 registry: Arc<Mutex<ConnectionRegistry>>,
341 id: u64,
342 role: AttachRole,
343 }
344
345 impl Drop for ConnectionGuard {
346 fn drop(&mut self) {
347 if let Ok(mut registry) = self.registry.lock() {
348 registry.remove(self.id);
349 }
350 }
351 }
352
353 /// Removes the socket file when the server stops, however it stops.
354 struct SocketFileGuard(PathBuf);
355
356 impl Drop for SocketFileGuard {
357 fn drop(&mut self) {
358 let _ = std::fs::remove_file(&self.0);
359 }
360 }
361
362 /// Asks a running [`DaemonSocket::serve`] to stop.
363 #[derive(Debug, Clone)]
364 pub struct DaemonShutdownHandle(Arc<watch::Sender<bool>>);
365
366 impl DaemonShutdownHandle {
367 /// Idempotent; safe to call from any task or signal handler.
368 pub fn trigger(&self) {
369 self.0.send_replace(true);
370 }
371 }
372
373 #[derive(Clone)]
374 struct ConnectionContext {
375 state: AppState,
376 registry: Arc<Mutex<ConnectionRegistry>>,
377 info: Arc<DaemonInfo>,
378 shutdown: DaemonShutdownHandle,
379 }
380
381 /// A bound, not yet serving, daemon socket.
382 pub struct DaemonSocket {
383 listener: UnixListener,
384 path: PathBuf,
385 state: AppState,
386 shutdown: Arc<watch::Sender<bool>>,
387 }
388
389 impl DaemonSocket {
390 /// Where clients connect.
391 #[must_use]
392 pub fn local_path(&self) -> &Path {
393 &self.path
394 }
395
396 /// A handle that stops [`Self::serve`] from outside (signals, tests).
397 #[must_use]
398 pub fn shutdown_handle(&self) -> DaemonShutdownHandle {
399 DaemonShutdownHandle(Arc::clone(&self.shutdown))
400 }
401
402 /// Accept clients until the owner sends `shutdown` or the handle is
403 /// triggered. Removes the socket file on the way out.
404 pub async fn serve(self) -> Result<(), DaemonSocketError> {
405 let Self {
406 listener,
407 path,
408 state,
409 shutdown,
410 } = self;
411 let _socket_file = SocketFileGuard(path.clone());
412 let uid = tokio::fs::metadata(&path)
413 .await
414 .map_err(|source| DaemonSocketError::Io {
415 context: "failed to stat the daemon socket",
416 path: path.clone(),
417 source,
418 })?
419 .uid();
420 let context = ConnectionContext {
421 state,
422 registry: Arc::new(Mutex::new(ConnectionRegistry::default())),
423 info: Arc::new(DaemonInfo {
424 pid: std::process::id(),
425 version: env!("CARGO_PKG_VERSION"),
426 uid,
427 socket_path: path.clone(),
428 started_at: Instant::now(),
429 }),
430 shutdown: DaemonShutdownHandle(Arc::clone(&shutdown)),
431 };
432 let mut shutdown_rx = shutdown.subscribe();
433 let mut connections = JoinSet::new();
434
435 loop {
436 if *shutdown_rx.borrow() {
437 break;
438 }
439 tokio::select! {
440 accepted = listener.accept() => match accepted {
441 Ok((stream, _)) => {
442 connections.spawn(handle_connection(context.clone(), stream));
443 }
444 Err(err) => {
445 tracing::warn!(error = %err, "daemon socket accept failed");
446 tokio::time::sleep(Duration::from_millis(50)).await;
447 }
448 },
449 changed = shutdown_rx.changed() => {
450 if changed.is_err() || *shutdown_rx.borrow() {
451 break;
452 }
453 }
454 }
455 }
456
457 // The owner's `shutdown` reply was flushed before its loop
458 // returned, so aborting what is left loses nothing a client
459 // still needs.
460 connections.shutdown().await;
461 Ok(())
462 }
463 }
464
465 /// Resolve the path, clear a stale socket, bind with `0600`, and build
466 /// the shared app state. Does not accept anything until
467 /// [`DaemonSocket::serve`].
468 pub async fn bind_daemon_socket(
469 options: DaemonSocketOptions,
470 ) -> Result<DaemonSocket, DaemonSocketError> {
471 let path = resolve_socket_path(&SocketPathInputs::from_environment(options.socket_path)?)?;
472 ensure_private_parent_dir(&path).await?;
473 clear_stale_socket(&path).await?;
474
475 let listener = UnixListener::bind(&path).map_err(|source| DaemonSocketError::Io {
476 context: "failed to bind the daemon socket",
477 path: path.clone(),
478 source,
479 })?;
480 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
481 .await
482 .map_err(|source| DaemonSocketError::Io {
483 context: "failed to restrict daemon socket permissions to 0600",
484 path: path.clone(),
485 source,
486 })?;
487
488 let state = build_state_with_transport(options.config_path, None, AppTransport::Socket)
489 .map_err(DaemonSocketError::State)?;
490 let (shutdown, _) = watch::channel(false);
491 Ok(DaemonSocket {
492 listener,
493 path,
494 state,
495 shutdown: Arc::new(shutdown),
496 })
497 }
498
499 /// Create the socket's directory as `0700` when it does not exist. An
500 /// existing directory is left as the operator made it. Async because the
501 /// caller runs on the Tokio runtime (blocking-call convention, #6149).
502 async fn ensure_private_parent_dir(path: &Path) -> Result<(), DaemonSocketError> {
503 let Some(parent) = path
504 .parent()
505 .filter(|parent| !parent.as_os_str().is_empty())
506 else {
507 return Ok(());
508 };
509 if tokio::fs::metadata(parent)
510 .await
511 .is_ok_and(|metadata| metadata.is_dir())
512 {
513 return Ok(());
514 }
515 tokio::fs::DirBuilder::new()
516 .recursive(true)
517 .mode(0o700)
518 .create(parent)
519 .await
520 .map_err(|source| DaemonSocketError::Io {
521 context: "failed to create the daemon runtime directory",
522 path: parent.to_path_buf(),
523 source,
524 })
525 }
526
527 /// Remove a socket file nobody answers on; refuse to touch anything else.
528 async fn clear_stale_socket(path: &Path) -> Result<(), DaemonSocketError> {
529 let metadata = match tokio::fs::symlink_metadata(path).await {
530 Ok(metadata) => metadata,
531 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
532 Err(source) => {
533 return Err(DaemonSocketError::Io {
534 context: "failed to inspect the daemon socket path",
535 path: path.to_path_buf(),
536 source,
537 });
538 }
539 };
540 if !metadata.file_type().is_socket() {
541 return Err(DaemonSocketError::NotASocket {
542 path: path.to_path_buf(),
543 });
544 }
545 match tokio::time::timeout(PROBE_TIMEOUT, UnixStream::connect(path)).await {
546 Ok(Ok(_live)) => Err(DaemonSocketError::AlreadyRunning {
547 path: path.to_path_buf(),
548 }),
549 Ok(Err(_refused)) => {
550 tokio::fs::remove_file(path)
551 .await
552 .map_err(|source| DaemonSocketError::Io {
553 context: "failed to remove a stale daemon socket",
554 path: path.to_path_buf(),
555 source,
556 })
557 }
558 Err(_elapsed) => Err(DaemonSocketError::ProbeTimedOut {
559 path: path.to_path_buf(),
560 }),
561 }
562 }
563
564 async fn handle_connection(context: ConnectionContext, stream: UnixStream) {
565 match stream.peer_cred() {
566 Ok(cred) if cred.uid() == context.info.uid => {}
567 Ok(cred) => {
568 tracing::warn!(
569 peer_uid = cred.uid(),
570 daemon_uid = context.info.uid,
571 "rejected daemon socket peer: uid mismatch"
572 );
573 return;
574 }
575 Err(err) => {
576 tracing::warn!(error = %err, "rejected daemon socket peer: no peer credentials");
577 return;
578 }
579 }
580
581 let (rx, mut writer) = stream.into_split();
582 let mut lines = BufReader::new(rx).lines();
583 let guard = match handshake(&context, &mut lines, &mut writer).await {
584 Ok(Some(guard)) => guard,
585 Ok(None) => return,
586 Err(err) => {
587 tracing::debug!(error = %err, "daemon socket handshake aborted");
588 return;
589 }
590 };
591
592 let policy = StdioLoopPolicy {
593 transport: AppTransport::Socket,
594 shutdown: match guard.role {
595 AttachRole::Owner => ShutdownAuthority::Granted,
596 AttachRole::Attached => ShutdownAuthority::Denied,
597 },
598 };
599 // The guard moves into the loop so the claim is released when the
600 // socket closes, not when a long-running turn finally returns.
601 let exit = run_stdio_loop(&context.state, lines, writer, policy, Some(guard)).await;
602 match exit {
603 Ok(StdioLoopExit::Shutdown) => context.shutdown.trigger(),
604 Ok(StdioLoopExit::InputClosed) => {}
605 Err(err) => tracing::debug!(error = %err, "daemon socket connection ended with error"),
606 }
607 }
608
609 /// Serve `healthz` and wait for `daemon/attach`; everything else is
610 /// refused with `attach_required` until the client attaches.
611 async fn handshake<R, W>(
612 context: &ConnectionContext,
613 lines: &mut Lines<R>,
614 writer: &mut W,
615 ) -> Result<Option<ConnectionGuard>>
616 where
617 R: AsyncBufRead + Unpin,
618 W: AsyncWrite + Unpin,
619 {
620 loop {
621 let Some(line) = lines.next_line().await? else {
622 return Ok(None);
623 };
624 let request = match parse_stdio_line(&line) {
625 ParsedStdioLine::Blank => continue,
626 ParsedStdioLine::Rejected(response) => {
627 write_stdio_line(writer, &response).await?;
628 continue;
629 }
630 ParsedStdioLine::Request(request) => request,
631 };
632 let id = request.id.clone();
633 match request.method.as_str() {
634 "healthz" | "app/healthz" => {
635 let response = match dispatch_stdio_request_with_writer(
636 &context.state,
637 writer,
638 &request.method,
639 request.params,
640 AppTransport::Socket,
641 )
642 .await
643 {
644 Ok(dispatch) => jsonrpc_result(id, dispatch.result),
645 Err(err) => jsonrpc_error(id, err),
646 };
647 write_stdio_line(writer, &response).await?;
648 }
649 ATTACH_METHOD => match attach(context, request.params) {
650 Ok((result, guard)) => {
651 write_stdio_line(writer, &jsonrpc_result(id, result)).await?;
652 return Ok(Some(guard));
653 }
654 Err(err) => write_stdio_line(writer, &jsonrpc_error(id, err)).await?,
655 },
656 other => {
657 write_stdio_line(
658 writer,
659 &jsonrpc_error(id, JsonRpcError::attach_required(other)),
660 )
661 .await?;
662 }
663 }
664 }
665 }
666
667 fn attach(
668 context: &ConnectionContext,
669 params: Value,
670 ) -> Result<(Value, ConnectionGuard), JsonRpcError> {
671 let params: AttachParams = parse_params(params_or_object(params))?;
672 if params.client.name.trim().is_empty() {
673 return Err(JsonRpcError::invalid_params(
674 "client.name must not be empty",
675 ));
676 }
677 if let Some(expected) = params.expect_daemon_version.as_deref()
678 && expected != context.info.version
679 {
680 return Err(JsonRpcError::daemon_version_skew(
681 expected,
682 context.info.version,
683 ));
684 }
685
686 let mut registry = context
687 .registry
688 .lock()
689 .map_err(|_| JsonRpcError::internal("daemon connection registry poisoned"))?;
690 let id = registry.register(params.client.clone());
691 let role = match params.mode {
692 AttachMode::Attach => AttachRole::Attached,
693 AttachMode::Claim => match registry.claim(id) {
694 Ok(()) => AttachRole::Owner,
695 Err(owner) => {
696 registry.remove(id);
697 let owner = serde_json::to_value(owner)
698 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
699 return Err(JsonRpcError::daemon_already_claimed(&owner));
700 }
701 },
702 };
703 let owner = registry.owner();
704 let connections = registry.connections.len();
705 drop(registry);
706
707 let info = &context.info;
708 let result = json!({
709 "attached": true,
710 "connection_id": id,
711 "role": role,
712 "transport": AppTransport::Socket.label(),
713 "daemon": {
714 "service": legacy_deepseek_compat::SERVICE_NAME,
715 "pid": info.pid,
716 "version": info.version,
717 "socket_path": info.socket_path.display().to_string(),
718 "uptime_ms": u64::try_from(info.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
719 },
720 "owner": owner,
721 "connections": connections,
722 });
723 Ok((
724 result,
725 ConnectionGuard {
726 registry: Arc::clone(&context.registry),
727 id,
728 role,
729 },
730 ))
731 }
732
733 #[cfg(test)]
734 mod tests {
735 use super::*;
736
737 fn client(name: &str) -> ClientIdentity {
738 ClientIdentity {
739 name: name.to_string(),
740 version: None,
741 pid: None,
742 }
743 }
744
745 #[test]
746 fn registry_claim_is_exclusive_until_the_owner_leaves() {
747 let mut registry = ConnectionRegistry::default();
748 let first = registry.register(client("desktop-a"));
749 let second = registry.register(client("desktop-b"));
750
751 assert!(registry.claim(first).is_ok());
752 assert_eq!(registry.claim(second), Err(client("desktop-a")));
753 assert!(
754 registry.claim(first).is_ok(),
755 "re-claim by the owner is idempotent"
756 );
757
758 registry.remove(first);
759 assert_eq!(registry.owner(), None);
760 assert!(registry.claim(second).is_ok());
761 assert_eq!(registry.owner(), Some(client("desktop-b")));
762 }
763
764 #[test]
765 fn removing_a_guest_keeps_the_owner() {
766 let mut registry = ConnectionRegistry::default();
767 let owner = registry.register(client("owner"));
768 let guest = registry.register(client("guest"));
769 registry.claim(owner).expect("claim");
770 registry.remove(guest);
771 assert_eq!(registry.owner(), Some(client("owner")));
772 assert_eq!(registry.connections.len(), 1);
773 }
774 }
775 }
776
777 #[cfg(not(unix))]
778 mod platform {
779 use std::path::Path;
780
781 use super::{DaemonSocketError, DaemonSocketOptions, unsupported_platform};
782
783 /// Placeholder until the Windows named pipe lands; cannot be constructed.
784 pub struct DaemonSocket {
785 never: std::convert::Infallible,
786 }
787
788 /// Placeholder handle for the unsupported platform.
789 #[derive(Debug, Clone)]
790 pub struct DaemonShutdownHandle(());
791
792 impl DaemonShutdownHandle {
793 pub fn trigger(&self) {}
794 }
795
796 impl DaemonSocket {
797 #[must_use]
798 pub fn local_path(&self) -> &Path {
799 match self.never {}
800 }
801
802 #[must_use]
803 pub fn shutdown_handle(&self) -> DaemonShutdownHandle {
804 match self.never {}
805 }
806
807 pub async fn serve(self) -> Result<(), DaemonSocketError> {
808 match self.never {}
809 }
810 }
811
812 /// Always [`DaemonSocketError::UnsupportedPlatform`] here.
813 pub async fn bind_daemon_socket(
814 _options: DaemonSocketOptions,
815 ) -> Result<DaemonSocket, DaemonSocketError> {
816 Err(unsupported_platform())
817 }
818 }
819
820 pub use platform::{DaemonShutdownHandle, DaemonSocket, bind_daemon_socket};
821
822 /// `codewhale app-server --socket`: bind, announce, serve until the owner's
823 /// `shutdown` or a termination signal.
824 pub async fn run_daemon_socket(options: DaemonSocketOptions) -> anyhow::Result<()> {
825 let daemon = bind_daemon_socket(options).await?;
826 let path: &Path = daemon.local_path();
827 tracing::info!(path = %path.display(), "codewhale daemon listening on unix socket");
828 eprintln!("codewhale daemon: listening on {}", path.display());
829
830 let handle = daemon.shutdown_handle();
831 tokio::spawn(async move {
832 crate::shutdown_signal().await;
833 handle.trigger();
834 });
835 daemon.serve().await?;
836 Ok(())
837 }
838
839 #[cfg(test)]
840 mod tests {
841 use super::*;
842
843 fn inputs() -> SocketPathInputs {
844 SocketPathInputs {
845 explicit: None,
846 codewhale_home_override: None,
847 xdg_runtime_dir: None,
848 user_home: Some(PathBuf::from("/home/whale")),
849 macos: false,
850 }
851 }
852
853 #[test]
854 fn explicit_path_wins() {
855 let resolved = resolve_socket_path(&SocketPathInputs {
856 explicit: Some(PathBuf::from("/tmp/x.sock")),
857 codewhale_home_override: Some(PathBuf::from("/iso")),
858 xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")),
859 ..inputs()
860 })
861 .expect("resolve");
862 assert_eq!(resolved, PathBuf::from("/tmp/x.sock"));
863 }
864
865 #[test]
866 fn explicit_codewhale_home_isolates_the_daemon() {
867 let resolved = resolve_socket_path(&SocketPathInputs {
868 codewhale_home_override: Some(PathBuf::from("/iso/home")),
869 xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")),
870 ..inputs()
871 })
872 .expect("resolve");
873 assert_eq!(resolved, PathBuf::from("/iso/home/run/daemon.sock"));
874 }
875
876 #[test]
877 fn xdg_runtime_dir_beats_home_layouts() {
878 let resolved = resolve_socket_path(&SocketPathInputs {
879 xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")),
880 macos: true,
881 ..inputs()
882 })
883 .expect("resolve");
884 assert_eq!(
885 resolved,
886 PathBuf::from("/run/user/1000/codewhale/daemon.sock")
887 );
888 }
889
890 #[test]
891 fn macos_defaults_to_application_support() {
892 let resolved = resolve_socket_path(&SocketPathInputs {
893 macos: true,
894 user_home: Some(PathBuf::from("/Users/whale")),
895 ..inputs()
896 })
897 .expect("resolve");
898 assert_eq!(
899 resolved,
900 PathBuf::from("/Users/whale/Library/Application Support/codewhale/daemon.sock")
901 );
902 }
903
904 #[test]
905 fn linux_defaults_to_dot_codewhale_run() {
906 let resolved = resolve_socket_path(&inputs()).expect("resolve");
907 assert_eq!(
908 resolved,
909 PathBuf::from("/home/whale/.codewhale/run/daemon.sock")
910 );
911 }
912
913 #[test]
914 fn no_home_is_a_typed_error() {
915 let err = resolve_socket_path(&SocketPathInputs {
916 user_home: None,
917 ..inputs()
918 })
919 .expect_err("must fail");
920 assert!(
921 matches!(err, DaemonSocketError::RuntimeDirUnavailable),
922 "{err}"
923 );
924 }
925
926 #[test]
927 fn over_long_paths_are_refused_before_bind() {
928 let long = PathBuf::from(format!(
929 "/{}/daemon.sock",
930 "d".repeat(MAX_SOCKET_PATH_BYTES)
931 ));
932 let err = resolve_socket_path(&SocketPathInputs {
933 explicit: Some(long.clone()),
934 ..inputs()
935 })
936 .expect_err("must fail");
937 match err {
938 DaemonSocketError::PathTooLong { path, len, max } => {
939 assert_eq!(path, long);
940 assert!(len > max);
941 assert_eq!(max, MAX_SOCKET_PATH_BYTES);
942 }
943 other => panic!("unexpected error: {other}"),
944 }
945 }
946
947 #[test]
948 fn unsupported_platform_error_names_the_named_pipe() {
949 let err = unsupported_platform();
950 let text = err.to_string();
951 assert!(text.contains(WINDOWS_NAMED_PIPE), "{text}");
952 assert!(text.contains("not implemented"), "{text}");
953 }
954
955 #[test]
956 fn attach_mode_defaults_to_guest() {
957 let params: AttachParams =
958 serde_json::from_value(serde_json::json!({ "client": { "name": "x" } }))
959 .expect("parse");
960 assert_eq!(params.mode, AttachMode::Attach);
961 assert_eq!(params.expect_daemon_version, None);
962 }
963 }
964
964 lines RUST