agentmux_launcher\ipc/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase B.2: launcher-owned named-pipe IPC server.
5//
6// Per `specs/SPEC_WINDOW_PROCESS_STATE_MACHINE_2026_04_27.md` §3.2 and §5,
7// the launcher hosts the canonical state machine and exposes it over a
8// pipe per data-dir-scoped namespace. Each subscriber (host, eventually
9// frontend renderers, srv) connects, sends `Command` messages, and
10// receives `Event` messages back.
11//
12// B.2 scope (this module): just the wire — types + accept loop +
13// per-connection read/write tasks. No reducer, no events emitted yet
14// (B.3 wires the reducer; B.4 pipes events back). This commit makes
15// the host able to register itself with the launcher and the launcher
16// to log incoming Commands. Foundation for everything else in Phase B.
17
18pub mod server;
19
20// Wire types live in agentmux-common::ipc so the host (client) and
21// launcher (server) compile against one definition. Phase F.7
22// cleanup audit: the prior `pub use {Command, Event}` re-exports
23// from this module had no consumers — every reference uses the
24// canonical `agentmux_common::ipc` path directly. Removed to keep
25// the launcher's public surface honest.
26pub use server::run_ipc_server;
27
28/// Construct the IPC endpoint path for a given data-dir hash.
29///
30/// Windows: a named-pipe path `\\.\pipe\agentmux-{hash16}\command`.
31/// Unix:    a Unix-domain-socket path under `$XDG_RUNTIME_DIR/agentmux/`
32///          (fallback `/tmp/agentmux-{uid}/`), file name
33///          `{hash16}.sock`. The directory is created with 0700 perms
34///          and ownership = the user, so cross-user squatting in `/tmp`
35///          can't happen.
36///
37/// Per-data-dir scoping preserves multi-instance support per
38/// `CLAUDE.md`: different portable folders / installed versions
39/// → different data dirs → different hashes → distinct endpoints.
40/// Two launchers pointing at the SAME data dir collide at bind time,
41/// which is also the single-instance signal Phase B.6 / A1.6 use.
42#[cfg(target_os = "windows")]
43pub fn pipe_name(data_dir_hash16: &str) -> String {
44    format!("\\\\.\\pipe\\agentmux-{}\\command", data_dir_hash16)
45}
46
47#[cfg(unix)]
48pub fn pipe_name(data_dir_hash16: &str) -> String {
49    // PURE — no filesystem mutation. `ipc_socket_dir_path` returns
50    // the path string without creating or validating the directory.
51    // Callers that need the directory to actually exist + be safe
52    // (only the launcher's startup path needs that) must call
53    // `ensure_ipc_socket_dir()` separately before binding/connecting.
54    //
55    // Reagent P2 on PR #1288: pipe_name on Windows is a pure string
56    // formatter; making the Unix variant filesystem-mutating + able
57    // to std::process::exit was a footgun for any future read-only
58    // inspector (e.g. the planned Linux `--diag` port) that calls
59    // pipe_name purely to locate the socket.
60    format!(
61        "{}/{}.sock",
62        ipc_socket_dir_path().display(),
63        data_dir_hash16
64    )
65}
66
67/// Phase E.1b — srv-side pipe path. Same data-dir hash as the
68/// launcher pipe (multi-instance scoping is identical), different
69/// leaf name. Both pipes coexist; subscribers connect to whichever
70/// reducer they need.
71#[cfg(target_os = "windows")]
72pub fn srv_pipe_name(data_dir_hash16: &str) -> String {
73    format!("\\\\.\\pipe\\agentmux-{}\\srv-command", data_dir_hash16)
74}
75
76#[cfg(unix)]
77pub fn srv_pipe_name(data_dir_hash16: &str) -> String {
78    // PURE — see comment on `pipe_name` above.
79    format!(
80        "{}/{}-srv.sock",
81        ipc_socket_dir_path().display(),
82        data_dir_hash16
83    )
84}
85
86/// Pure computation of the IPC socket directory path. No I/O, no
87/// process-exit. Used by `pipe_name` / `srv_pipe_name` so they
88/// behave like their Windows counterparts (pure string formatters).
89///
90/// Resolution order (A1.1 of SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_
91/// 2026_06_05):
92///   1. `$XDG_RUNTIME_DIR/agentmux/` — preferred.
93///   2. `/tmp/agentmux-{uid}/` — fallback.
94#[cfg(unix)]
95pub fn ipc_socket_dir_path() -> std::path::PathBuf {
96    if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
97        let mut p = std::path::PathBuf::from(runtime);
98        p.push("agentmux");
99        p
100    } else {
101        let uid = unsafe { libc::getuid() };
102        std::path::PathBuf::from(format!("/tmp/agentmux-{}", uid))
103    }
104}
105
106/// Directory under which all launcher Unix sockets live. Created with
107/// 0700 perms so cross-user squatting can't happen.
108///
109/// Resolution order (A1.1 of SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_
110/// 2026_06_05):
111///   1. `$XDG_RUNTIME_DIR/agentmux/` — preferred; tmpfs, per-user,
112///      automatically cleaned up by systemd-logind on session end.
113///   2. `/tmp/agentmux-{uid}/` — fallback for environments without
114///      a systemd user manager. UID in the path so users can't
115///      collide.
116///
117/// Security (codex P1 + reagent P1 on #1288). The `/tmp` fallback path
118/// is reachable by every local user. The resolver must close every
119/// TOCTOU window:
120///
121///   * If the dir doesn't exist, create it NON-RECURSIVELY (so a race
122///     between our stat and our create fails with `AlreadyExists`,
123///     not silently succeeds the way `create_dir_all` would).
124///   * Immediately after a successful create, RE-STAT and verify
125///     ownership + mode. A `create_dir(0700)` syscall under a 0022
126///     umask still produces a 0700 dir; this re-stat is belt-and-
127///     suspenders against the unlikely case where the dir we just
128///     created has been replaced by an attacker between mkdir and
129///     re-stat.
130///   * If the dir already exists, `symlink_metadata` (NOT `metadata`
131///     — we must not follow symlinks) and refuse to proceed unless
132///     it's a real directory owned by our uid with mode masking 0700.
133///   * Refusal = `std::process::exit(2)` with a clear error. We do
134///     NOT try to recover by picking a different path; that would
135///     enlarge the trust boundary.
136/// Ensure the IPC socket directory exists with safe ownership + mode.
137///
138/// This is the SIDE-EFFECTING half of the path/ensure split — callers
139/// that need the directory to actually exist (only the launcher's
140/// startup path) call this BEFORE binding/connecting. Read-only
141/// inspectors (e.g. future `--diag` tools) use `ipc_socket_dir_path`
142/// directly and never reach this code.
143///
144/// Returns the validated directory path on success. Calls
145/// `std::process::exit(2)` on any verification failure — we do NOT
146/// recover by picking a different path; that would enlarge the trust
147/// boundary.
148///
149/// (Reagent P2 on PR #1288: previously this logic was inside the
150/// `ipc_socket_dir` function which `pipe_name` called, making
151/// `pipe_name` a filesystem-mutating + process-exiting function on
152/// Unix while it's a pure string formatter on Windows. Split into a
153/// pure `ipc_socket_dir_path` + this ensure-step.)
154#[cfg(unix)]
155pub fn ensure_ipc_socket_dir() -> std::path::PathBuf {
156    use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _};
157
158    let dir = ipc_socket_dir_path();
159
160    // Security (codex P1 on PR #1288): the /tmp fallback is reachable
161    // by any local user. If an attacker pre-creates the path with a
162    // permissive mode, the naive `create_dir_all` + best-effort chmod
163    // pattern silently lets us bind the launcher socket inside an
164    // attacker-controlled directory, where they can connect as the
165    // first IPC client and impersonate the host. Defense:
166    //   1. Create the directory atomically with mode 0700.
167    //   2. If the directory already exists, stat it and refuse to
168    //      proceed unless: (a) it's a real directory (not a symlink),
169    //      (b) it's owned by the current uid, (c) its mode masks 0700.
170    //   3. On refusal, abort the launcher with a clear error rather
171    //      than continuing to bind in an unsafe location.
172    let our_uid = unsafe { libc::getuid() };
173
174    // Validate an existing directory (whether we found it via the
175    // initial stat or via a same-user-race AlreadyExists from create).
176    // Returns Ok on safe-to-use, exits(2) with a clear error otherwise.
177    // Side-effect: tightens mode to 0700 if it's looser.
178    let validate_existing = |meta: std::fs::Metadata| {
179        let file_type = meta.file_type();
180        if file_type.is_symlink() {
181            eprintln!(
182                "AgentMux refusing to start: IPC socket dir {} is a symlink (potential squatting attack).",
183                dir.display()
184            );
185            std::process::exit(2);
186        }
187        if !file_type.is_dir() {
188            eprintln!(
189                "AgentMux refusing to start: IPC socket path {} exists but is not a directory.",
190                dir.display()
191            );
192            std::process::exit(2);
193        }
194        if meta.uid() != our_uid {
195            eprintln!(
196                "AgentMux refusing to start: IPC socket dir {} is owned by uid {}, not our uid {} (potential squatting attack).",
197                dir.display(),
198                meta.uid(),
199                our_uid
200            );
201            std::process::exit(2);
202        }
203        let mode = meta.mode() & 0o777;
204        if mode & 0o077 != 0 {
205            use std::os::unix::fs::PermissionsExt as _;
206            if let Err(e) = std::fs::set_permissions(
207                &dir,
208                std::fs::Permissions::from_mode(0o700),
209            ) {
210                eprintln!(
211                    "AgentMux refusing to start: IPC socket dir {} has mode {:o} (group/other accessible) and chmod 0700 failed: {}.",
212                    dir.display(),
213                    mode,
214                    e
215                );
216                std::process::exit(2);
217            }
218        }
219    };
220
221    match std::fs::symlink_metadata(&dir) {
222        Ok(meta) => validate_existing(meta),
223        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
224            // Non-recursive create with mode 0700. `recursive(true)`
225            // (= create_dir_all semantics) would return Ok even if an
226            // attacker pre-created the directory in the race window
227            // between our `symlink_metadata` NotFound and this call —
228            // we'd then bind our socket inside attacker-controlled
229            // space, exactly the squatting attack this resolver is
230            // here to prevent. Non-recursive create fails with
231            // `AlreadyExists` in that race; we treat it as fatal.
232            //
233            // For the XDG path (`$XDG_RUNTIME_DIR/agentmux`), the
234            // parent (`/run/user/{uid}`) is created by systemd-logind
235            // and always exists. For the `/tmp` fallback, `/tmp` is
236            // a standard system directory and always exists. So a
237            // non-recursive create is safe; it only fails when the
238            // parent is missing (which is itself a sign something
239            // weird is going on and we should abort).
240            let mut builder = std::fs::DirBuilder::new();
241            builder.recursive(false).mode(0o700);
242            match builder.create(&dir) {
243                Ok(()) => {
244                    // Belt-and-suspenders post-create verification.
245                    // mkdir(2) is atomic; the dir we just created is
246                    // OURS at this instant. Re-stat to defend against
247                    // any future refactor that loosens the flags.
248                    match std::fs::symlink_metadata(&dir) {
249                        Ok(m) => validate_existing(m),
250                        Err(stat_err) => {
251                            eprintln!(
252                                "AgentMux refusing to start: post-create stat of {} failed: {}.",
253                                dir.display(),
254                                stat_err
255                            );
256                            std::process::exit(2);
257                        }
258                    }
259                }
260                Err(create_err)
261                    if create_err.kind() == std::io::ErrorKind::AlreadyExists =>
262                {
263                    // Legitimate same-user race (codex P2 on PR #1288):
264                    // a concurrent launcher created the dir between our
265                    // initial NotFound and this create. NOT necessarily
266                    // a squatting attack — re-stat + run the same
267                    // owner/mode/symlink validation we'd run on a
268                    // pre-existing dir. If validation passes, the
269                    // concurrent launcher created a safe dir for us
270                    // both and we can proceed.
271                    match std::fs::symlink_metadata(&dir) {
272                        Ok(m) => validate_existing(m),
273                        Err(stat_err) => {
274                            eprintln!(
275                                "AgentMux refusing to start: re-stat after AlreadyExists race on {} failed: {}.",
276                                dir.display(),
277                                stat_err
278                            );
279                            std::process::exit(2);
280                        }
281                    }
282                }
283                Err(create_err) => {
284                    eprintln!(
285                        "AgentMux refusing to start: failed to create IPC socket dir {} (mode 0700, non-recursive): {}.",
286                        dir.display(),
287                        create_err
288                    );
289                    std::process::exit(2);
290                }
291            }
292        }
293        Err(e) => {
294            eprintln!(
295                "AgentMux refusing to start: failed to stat IPC socket dir {}: {}.",
296                dir.display(),
297                e
298            );
299            std::process::exit(2);
300        }
301    }
302
303    dir
304}