Skip to main content

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 `docs/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/// Pure computation of the IPC socket directory path. No I/O, no
77/// process-exit. Used by `pipe_name` so it behaves like its Windows
78/// counterpart (a pure string formatter).
79///
80/// Resolution order (A1.1 of SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_
81/// 2026_06_05):
82///   1. `$XDG_RUNTIME_DIR/agentmux/` — preferred.
83///   2. `/tmp/agentmux-{uid}/` — fallback.
84#[cfg(unix)]
85pub fn ipc_socket_dir_path() -> std::path::PathBuf {
86    if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
87        let mut p = std::path::PathBuf::from(runtime);
88        p.push("agentmux");
89        p
90    } else {
91        let uid = unsafe { libc::getuid() };
92        std::path::PathBuf::from(format!("/tmp/agentmux-{}", uid))
93    }
94}
95
96/// Directory under which all launcher Unix sockets live. Created with
97/// 0700 perms so cross-user squatting can't happen.
98///
99/// Resolution order (A1.1 of SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_
100/// 2026_06_05):
101///   1. `$XDG_RUNTIME_DIR/agentmux/` — preferred; tmpfs, per-user,
102///      automatically cleaned up by systemd-logind on session end.
103///   2. `/tmp/agentmux-{uid}/` — fallback for environments without
104///      a systemd user manager. UID in the path so users can't
105///      collide.
106///
107/// Security (codex P1 + reagent P1 on #1288). The `/tmp` fallback path
108/// is reachable by every local user. The resolver must close every
109/// TOCTOU window:
110///
111///   * If the dir doesn't exist, create it NON-RECURSIVELY (so a race
112///     between our stat and our create fails with `AlreadyExists`,
113///     not silently succeeds the way `create_dir_all` would).
114///   * Immediately after a successful create, RE-STAT and verify
115///     ownership + mode. A `create_dir(0700)` syscall under a 0022
116///     umask still produces a 0700 dir; this re-stat is belt-and-
117///     suspenders against the unlikely case where the dir we just
118///     created has been replaced by an attacker between mkdir and
119///     re-stat.
120///   * If the dir already exists, `symlink_metadata` (NOT `metadata`
121///     — we must not follow symlinks) and refuse to proceed unless
122///     it's a real directory owned by our uid with mode masking 0700.
123///   * Refusal = `std::process::exit(2)` with a clear error. We do
124///     NOT try to recover by picking a different path; that would
125///     enlarge the trust boundary.
126/// Ensure the IPC socket directory exists with safe ownership + mode.
127///
128/// This is the SIDE-EFFECTING half of the path/ensure split — callers
129/// that need the directory to actually exist (only the launcher's
130/// startup path) call this BEFORE binding/connecting. Read-only
131/// inspectors (e.g. future `--diag` tools) use `ipc_socket_dir_path`
132/// directly and never reach this code.
133///
134/// Returns the validated directory path on success. Calls
135/// `std::process::exit(2)` on any verification failure — we do NOT
136/// recover by picking a different path; that would enlarge the trust
137/// boundary.
138///
139/// (Reagent P2 on PR #1288: previously this logic was inside the
140/// `ipc_socket_dir` function which `pipe_name` called, making
141/// `pipe_name` a filesystem-mutating + process-exiting function on
142/// Unix while it's a pure string formatter on Windows. Split into a
143/// pure `ipc_socket_dir_path` + this ensure-step.)
144#[cfg(unix)]
145pub fn ensure_ipc_socket_dir() -> std::path::PathBuf {
146    use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _};
147
148    let dir = ipc_socket_dir_path();
149
150    // Security (codex P1 on PR #1288): the /tmp fallback is reachable
151    // by any local user. If an attacker pre-creates the path with a
152    // permissive mode, the naive `create_dir_all` + best-effort chmod
153    // pattern silently lets us bind the launcher socket inside an
154    // attacker-controlled directory, where they can connect as the
155    // first IPC client and impersonate the host. Defense:
156    //   1. Create the directory atomically with mode 0700.
157    //   2. If the directory already exists, stat it and refuse to
158    //      proceed unless: (a) it's a real directory (not a symlink),
159    //      (b) it's owned by the current uid, (c) its mode masks 0700.
160    //   3. On refusal, abort the launcher with a clear error rather
161    //      than continuing to bind in an unsafe location.
162    let our_uid = unsafe { libc::getuid() };
163
164    // Validate an existing directory (whether we found it via the
165    // initial stat or via a same-user-race AlreadyExists from create).
166    // Returns Ok on safe-to-use, exits(2) with a clear error otherwise.
167    // Side-effect: tightens mode to 0700 if it's looser.
168    let validate_existing = |meta: std::fs::Metadata| {
169        let file_type = meta.file_type();
170        if file_type.is_symlink() {
171            eprintln!(
172                "AgentMux refusing to start: IPC socket dir {} is a symlink (potential squatting attack).",
173                dir.display()
174            );
175            std::process::exit(2);
176        }
177        if !file_type.is_dir() {
178            eprintln!(
179                "AgentMux refusing to start: IPC socket path {} exists but is not a directory.",
180                dir.display()
181            );
182            std::process::exit(2);
183        }
184        if meta.uid() != our_uid {
185            eprintln!(
186                "AgentMux refusing to start: IPC socket dir {} is owned by uid {}, not our uid {} (potential squatting attack).",
187                dir.display(),
188                meta.uid(),
189                our_uid
190            );
191            std::process::exit(2);
192        }
193        let mode = meta.mode() & 0o777;
194        if mode & 0o077 != 0 {
195            use std::os::unix::fs::PermissionsExt as _;
196            if let Err(e) = std::fs::set_permissions(
197                &dir,
198                std::fs::Permissions::from_mode(0o700),
199            ) {
200                eprintln!(
201                    "AgentMux refusing to start: IPC socket dir {} has mode {:o} (group/other accessible) and chmod 0700 failed: {}.",
202                    dir.display(),
203                    mode,
204                    e
205                );
206                std::process::exit(2);
207            }
208        }
209    };
210
211    match std::fs::symlink_metadata(&dir) {
212        Ok(meta) => validate_existing(meta),
213        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
214            // Non-recursive create with mode 0700. `recursive(true)`
215            // (= create_dir_all semantics) would return Ok even if an
216            // attacker pre-created the directory in the race window
217            // between our `symlink_metadata` NotFound and this call —
218            // we'd then bind our socket inside attacker-controlled
219            // space, exactly the squatting attack this resolver is
220            // here to prevent. Non-recursive create fails with
221            // `AlreadyExists` in that race; we treat it as fatal.
222            //
223            // For the XDG path (`$XDG_RUNTIME_DIR/agentmux`), the
224            // parent (`/run/user/{uid}`) is created by systemd-logind
225            // and always exists. For the `/tmp` fallback, `/tmp` is
226            // a standard system directory and always exists. So a
227            // non-recursive create is safe; it only fails when the
228            // parent is missing (which is itself a sign something
229            // weird is going on and we should abort).
230            let mut builder = std::fs::DirBuilder::new();
231            builder.recursive(false).mode(0o700);
232            match builder.create(&dir) {
233                Ok(()) => {
234                    // Belt-and-suspenders post-create verification.
235                    // mkdir(2) is atomic; the dir we just created is
236                    // OURS at this instant. Re-stat to defend against
237                    // any future refactor that loosens the flags.
238                    match std::fs::symlink_metadata(&dir) {
239                        Ok(m) => validate_existing(m),
240                        Err(stat_err) => {
241                            eprintln!(
242                                "AgentMux refusing to start: post-create stat of {} failed: {}.",
243                                dir.display(),
244                                stat_err
245                            );
246                            std::process::exit(2);
247                        }
248                    }
249                }
250                Err(create_err)
251                    if create_err.kind() == std::io::ErrorKind::AlreadyExists =>
252                {
253                    // Legitimate same-user race (codex P2 on PR #1288):
254                    // a concurrent launcher created the dir between our
255                    // initial NotFound and this create. NOT necessarily
256                    // a squatting attack — re-stat + run the same
257                    // owner/mode/symlink validation we'd run on a
258                    // pre-existing dir. If validation passes, the
259                    // concurrent launcher created a safe dir for us
260                    // both and we can proceed.
261                    match std::fs::symlink_metadata(&dir) {
262                        Ok(m) => validate_existing(m),
263                        Err(stat_err) => {
264                            eprintln!(
265                                "AgentMux refusing to start: re-stat after AlreadyExists race on {} failed: {}.",
266                                dir.display(),
267                                stat_err
268                            );
269                            std::process::exit(2);
270                        }
271                    }
272                }
273                Err(create_err) => {
274                    eprintln!(
275                        "AgentMux refusing to start: failed to create IPC socket dir {} (mode 0700, non-recursive): {}.",
276                        dir.display(),
277                        create_err
278                    );
279                    std::process::exit(2);
280                }
281            }
282        }
283        Err(e) => {
284            eprintln!(
285                "AgentMux refusing to start: failed to stat IPC socket dir {}: {}.",
286                dir.display(),
287                e
288            );
289            std::process::exit(2);
290        }
291    }
292
293    dir
294}