agentmux_cef/
dev_authfile.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dev-only auth-key file writer for external test harnesses.
5//!
6//! Writes `<data_dir>/authkey.dev` containing the random per-process
7//! `auth_key`, IPC token, backend endpoints, and instance metadata so a
8//! harness in any language can call `POST /agentmux/service` against a
9//! running `task dev` instance.
10//!
11//! Gated at the call site by a runtime `AGENTMUX_DEV=1` env-var check
12//! (see `main.rs`). The first revision of this module used
13//! `cfg(debug_assertions)`, but `task dev` builds with `--release`
14//! (Taskfile.yml `build:host:windows`), which made the gate a no-op
15//! exactly where we needed the file. The runtime env-var gate matches
16//! the same signal `sidecar.rs` uses to pick the dev data dir.
17//!
18//! On Windows the file is created with an owner-only DACL via
19//! `SetNamedSecurityInfoW`, with `PROTECTED_DACL_SECURITY_INFORMATION`
20//! to break parent-dir inheritance — defense against a hostile parent
21//! ACL change after file creation. On Unix the file is chmod'd 0600
22//! after creation to override the default umask.
23//!
24//! Spec: `docs/specs/SPEC_TEST_API_ACCESS.md` §5–§6.
25
26use serde::Serialize;
27use std::path::Path;
28
29const FILE_NAME: &str = "authkey.dev";
30
31#[derive(Serialize)]
32pub struct DevAuthFile<'a> {
33    pub version: u32,
34    pub auth_key: &'a str,
35    pub web_endpoint: &'a str,
36    pub ws_endpoint: &'a str,
37    pub ipc_endpoint: &'a str,
38    pub ipc_token: &'a str,
39    pub service_path: &'static str,
40    pub file_path: &'static str,
41    pub instance: &'a str,
42    pub data_dir: String,
43    pub host_pid: u32,
44    pub created_at: String,
45}
46
47/// Write `authkey.dev` to `data_dir`. Returns the absolute file path on
48/// success. Errors are returned as strings — the caller in `main.rs`
49/// logs them at warn-level and continues; a missing dev file is not a
50/// fatal startup failure.
51pub fn write_dev_auth_file(
52    data_dir: &Path,
53    auth_key: &str,
54    web_endpoint: &str,
55    ws_endpoint: &str,
56    ipc_endpoint: &str,
57    ipc_token: &str,
58    instance: &str,
59    host_pid: u32,
60) -> Result<std::path::PathBuf, String> {
61    let path = data_dir.join(FILE_NAME);
62    let payload = DevAuthFile {
63        version: 1,
64        auth_key,
65        web_endpoint,
66        ws_endpoint,
67        ipc_endpoint,
68        ipc_token,
69        service_path: "/agentmux/service",
70        file_path: "/agentmux/file",
71        instance,
72        data_dir: data_dir.to_string_lossy().into_owned(),
73        host_pid,
74        created_at: chrono::Utc::now().to_rfc3339(),
75    };
76    let json = serde_json::to_string_pretty(&payload)
77        .map_err(|e| format!("serialize authkey.dev: {}", e))?;
78
79    // Overwrite-if-exists. The previous file is by definition stale (it
80    // belonged to a prior cef host process); harnesses that read the
81    // file are expected to validate `host_pid` liveness.
82    std::fs::write(&path, json.as_bytes())
83        .map_err(|e| format!("write {}: {}", path.display(), e))?;
84
85    // Lock down read access to the file owner. Default umask leaves
86    // world-readable bits on most distros; on Windows, files inherit
87    // the parent dir's ACL which can include other principals. Both
88    // paths apply explicit owner-only access — the auth_key and
89    // ipc_token in this file would let any peer on the machine drive
90    // the service API.
91    #[cfg(unix)]
92    apply_owner_only_mode(&path)?;
93    #[cfg(target_os = "windows")]
94    apply_owner_only_dacl(&path)?;
95
96    Ok(path)
97}
98
99#[cfg(unix)]
100fn apply_owner_only_mode(path: &Path) -> Result<(), String> {
101    use std::os::unix::fs::PermissionsExt;
102    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
103        .map_err(|e| format!("chmod 0600 {}: {}", path.display(), e))
104}
105
106#[cfg(target_os = "windows")]
107fn apply_owner_only_dacl(path: &Path) -> Result<(), String> {
108    use std::ffi::c_void;
109    use std::os::windows::ffi::OsStrExt;
110    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE};
111    use windows_sys::Win32::Security::Authorization::{
112        SetNamedSecurityInfoW, SE_FILE_OBJECT,
113    };
114    use windows_sys::Win32::Security::{
115        AddAccessAllowedAce, GetLengthSid, GetTokenInformation, InitializeAcl,
116        TokenUser, ACL, ACL_REVISION, DACL_SECURITY_INFORMATION,
117        PROTECTED_DACL_SECURITY_INFORMATION, TOKEN_QUERY, TOKEN_USER,
118    };
119    use windows_sys::Win32::System::Threading::{
120        GetCurrentProcess, OpenProcessToken,
121    };
122
123    // FILE_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FF.
124    // Defined locally to avoid pulling in Win32_Storage_FileSystem just
125    // for one constant.
126    const FILE_ALL_ACCESS: u32 = 0x001F_01FF;
127
128    unsafe {
129        // 1. Get current-user SID via process token.
130        let mut token: HANDLE = std::ptr::null_mut();
131        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
132            return Err(format!("OpenProcessToken failed: {}", GetLastError()));
133        }
134        let mut needed: u32 = 0;
135        // First call returns ERROR_INSUFFICIENT_BUFFER and sets `needed`.
136        let _ = GetTokenInformation(
137            token,
138            TokenUser,
139            std::ptr::null_mut(),
140            0,
141            &mut needed,
142        );
143        if needed == 0 {
144            CloseHandle(token);
145            return Err(format!(
146                "GetTokenInformation sizing returned 0: {}",
147                GetLastError()
148            ));
149        }
150        let mut token_buf: Vec<u8> = vec![0; needed as usize];
151        if GetTokenInformation(
152            token,
153            TokenUser,
154            token_buf.as_mut_ptr() as *mut c_void,
155            needed,
156            &mut needed,
157        ) == 0
158        {
159            CloseHandle(token);
160            return Err(format!("GetTokenInformation failed: {}", GetLastError()));
161        }
162        CloseHandle(token);
163
164        // SID is owned by `token_buf`; keep that buffer alive for the
165        // duration of ACL construction.
166        let token_user_ptr = token_buf.as_ptr() as *const TOKEN_USER;
167        let sid = (*token_user_ptr).User.Sid;
168        let sid_len = GetLengthSid(sid);
169        if sid_len == 0 {
170            return Err("GetLengthSid returned 0 — invalid SID".to_string());
171        }
172
173        // 2. Build a DACL: ACL header + one ACCESS_ALLOWED_ACE inline
174        //    with the SID body. ACCESS_ALLOWED_ACE.SidStart is the first
175        //    DWORD of the SID; total ACE size = struct size + sid_len -
176        //    sizeof(DWORD).
177        let ace_header_size =
178            std::mem::size_of::<windows_sys::Win32::Security::ACCESS_ALLOWED_ACE>() as u32;
179        let ace_size = ace_header_size
180            .saturating_add(sid_len)
181            .saturating_sub(std::mem::size_of::<u32>() as u32);
182        let acl_size = std::mem::size_of::<ACL>() as u32 + ace_size;
183
184        let mut acl_buf: Vec<u8> = vec![0; acl_size as usize];
185        let acl_ptr = acl_buf.as_mut_ptr() as *mut ACL;
186        if InitializeAcl(acl_ptr, acl_size, ACL_REVISION as u32) == 0 {
187            return Err(format!("InitializeAcl failed: {}", GetLastError()));
188        }
189        if AddAccessAllowedAce(acl_ptr, ACL_REVISION as u32, FILE_ALL_ACCESS, sid)
190            == 0
191        {
192            return Err(format!("AddAccessAllowedAce failed: {}", GetLastError()));
193        }
194
195        // 3. Apply DACL to the file. PROTECTED_DACL_SECURITY_INFORMATION
196        //    breaks inheritance from the parent dir so a later ACL change
197        //    on the data dir doesn't widen access to authkey.dev.
198        let mut wide: Vec<u16> = path
199            .as_os_str()
200            .encode_wide()
201            .chain(std::iter::once(0))
202            .collect();
203        let result = SetNamedSecurityInfoW(
204            wide.as_mut_ptr(),
205            SE_FILE_OBJECT,
206            DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
207            std::ptr::null_mut(),
208            std::ptr::null_mut(),
209            acl_ptr,
210            std::ptr::null_mut(),
211        );
212        if result != 0 {
213            return Err(format!("SetNamedSecurityInfoW returned {}", result));
214        }
215    }
216
217    Ok(())
218}
219
220// ── Tests ───────────────────────────────────────────────────────────────
221//
222// Gated behind `--features test-authfile` per spec §7. They write to
223// $TEMP and (on Windows) read the DACL back via GetNamedSecurityInfoW
224// to assert exactly one ACE present, granting access to the current
225// user's SID. The feature gate keeps OS-touching tests opt-in so plain
226// `cargo test` stays hermetic.
227
228#[cfg(all(test, feature = "test-authfile"))]
229mod tests {
230    use super::*;
231
232    fn temp_dir(label: &str) -> std::path::PathBuf {
233        let mut p = std::env::temp_dir();
234        p.push(format!("agentmux-authfile-test-{}-{}", label, std::process::id()));
235        std::fs::create_dir_all(&p).expect("mkdir tempdir");
236        p
237    }
238
239    #[test]
240    fn writes_well_formed_json_with_all_fields() {
241        let dir = temp_dir("format");
242        let path = write_dev_auth_file(
243            &dir,
244            "f8c9b0e4-1234-4567-89ab-cdef01234567",
245            "127.0.0.1:59719",
246            "127.0.0.1:59720",
247            "127.0.0.1:59718",
248            "92d136fa-2e14-46d0-9ace-eddee320a35e",
249            "v0.33.265",
250            12345,
251        )
252        .expect("write authfile");
253
254        let body = std::fs::read_to_string(&path).expect("read back");
255        let parsed: serde_json::Value =
256            serde_json::from_str(&body).expect("parse JSON");
257
258        assert_eq!(parsed["version"], 1);
259        assert_eq!(parsed["auth_key"], "f8c9b0e4-1234-4567-89ab-cdef01234567");
260        assert_eq!(parsed["web_endpoint"], "127.0.0.1:59719");
261        assert_eq!(parsed["ws_endpoint"], "127.0.0.1:59720");
262        assert_eq!(parsed["ipc_endpoint"], "127.0.0.1:59718");
263        assert_eq!(parsed["ipc_token"], "92d136fa-2e14-46d0-9ace-eddee320a35e");
264        assert_eq!(parsed["service_path"], "/agentmux/service");
265        assert_eq!(parsed["file_path"], "/agentmux/file");
266        assert_eq!(parsed["instance"], "v0.33.265");
267        assert_eq!(parsed["host_pid"], 12345);
268        assert!(parsed["created_at"].as_str().unwrap_or("").contains("T"));
269        assert!(parsed["data_dir"].as_str().unwrap_or("").contains("agentmux-authfile-test"));
270
271        // cleanup
272        let _ = std::fs::remove_dir_all(&dir);
273    }
274
275    #[test]
276    fn overwrites_existing_file_on_repeat_call() {
277        let dir = temp_dir("overwrite");
278        let path1 = write_dev_auth_file(
279            &dir, "old-key", "127.0.0.1:1", "127.0.0.1:2", "127.0.0.1:3",
280            "old-token", "v0.0.1", 1,
281        ).unwrap();
282        let path2 = write_dev_auth_file(
283            &dir, "new-key", "127.0.0.1:11", "127.0.0.1:22", "127.0.0.1:33",
284            "new-token", "v0.0.2", 2,
285        ).unwrap();
286        assert_eq!(path1, path2, "same path expected");
287        let body = std::fs::read_to_string(&path2).unwrap();
288        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
289        assert_eq!(parsed["auth_key"], "new-key");
290        assert_eq!(parsed["instance"], "v0.0.2");
291        let _ = std::fs::remove_dir_all(&dir);
292    }
293
294    #[cfg(target_os = "windows")]
295    #[test]
296    fn dacl_grants_only_current_user() {
297        use std::ffi::c_void;
298        use std::os::windows::ffi::OsStrExt;
299        use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, HANDLE};
300        use windows_sys::Win32::Security::Authorization::{
301            GetNamedSecurityInfoW, SE_FILE_OBJECT,
302        };
303        use windows_sys::Win32::Security::{
304            EqualSid, GetAce, GetTokenInformation, TokenUser,
305            ACCESS_ALLOWED_ACE, ACL, ACL_SIZE_INFORMATION, AclSizeInformation,
306            DACL_SECURITY_INFORMATION, GetAclInformation, TOKEN_QUERY, TOKEN_USER,
307        };
308        use windows_sys::Win32::System::Threading::{
309            GetCurrentProcess, OpenProcessToken,
310        };
311
312        let dir = temp_dir("dacl");
313        let path = write_dev_auth_file(
314            &dir, "k", "127.0.0.1:1", "127.0.0.1:2", "127.0.0.1:3",
315            "t", "v0.0.0", std::process::id(),
316        ).unwrap();
317
318        unsafe {
319            // Read DACL back.
320            let mut wide: Vec<u16> = path
321                .as_os_str()
322                .encode_wide()
323                .chain(std::iter::once(0))
324                .collect();
325            let mut dacl: *mut ACL = std::ptr::null_mut();
326            let mut sd: *mut c_void = std::ptr::null_mut();
327            let result = GetNamedSecurityInfoW(
328                wide.as_mut_ptr(),
329                SE_FILE_OBJECT,
330                DACL_SECURITY_INFORMATION,
331                std::ptr::null_mut(),
332                std::ptr::null_mut(),
333                &mut dacl,
334                std::ptr::null_mut(),
335                &mut sd,
336            );
337            assert_eq!(result, 0, "GetNamedSecurityInfoW failed: {}", result);
338            assert!(!dacl.is_null(), "DACL should be present");
339
340            // Assert exactly one ACE.
341            let mut sz: ACL_SIZE_INFORMATION = std::mem::zeroed();
342            assert_ne!(
343                GetAclInformation(
344                    dacl,
345                    &mut sz as *mut _ as *mut c_void,
346                    std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
347                    AclSizeInformation,
348                ),
349                0,
350                "GetAclInformation failed",
351            );
352            assert_eq!(sz.AceCount, 1, "expected exactly one ACE in DACL, got {}", sz.AceCount);
353
354            // Read the ACE and extract its SID.
355            let mut ace_ptr: *mut c_void = std::ptr::null_mut();
356            assert_ne!(
357                GetAce(dacl, 0, &mut ace_ptr),
358                0,
359                "GetAce failed",
360            );
361            let ace = ace_ptr as *const ACCESS_ALLOWED_ACE;
362            let ace_sid = &(*ace).SidStart as *const u32 as *mut c_void;
363
364            // Get current-user SID for comparison.
365            let mut token: HANDLE = std::ptr::null_mut();
366            assert_ne!(
367                OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token),
368                0,
369            );
370            let mut needed = 0u32;
371            let _ = GetTokenInformation(
372                token, TokenUser, std::ptr::null_mut(), 0, &mut needed,
373            );
374            let mut buf = vec![0u8; needed as usize];
375            assert_ne!(
376                GetTokenInformation(
377                    token, TokenUser,
378                    buf.as_mut_ptr() as *mut c_void, needed, &mut needed,
379                ),
380                0,
381            );
382            CloseHandle(token);
383            let token_user = buf.as_ptr() as *const TOKEN_USER;
384            let user_sid = (*token_user).User.Sid;
385
386            assert_ne!(
387                EqualSid(ace_sid, user_sid),
388                0,
389                "ACE SID does not match current user SID",
390            );
391
392            LocalFree(sd);
393        }
394
395        let _ = std::fs::remove_dir_all(&dir);
396    }
397}