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}