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}