Skip to main content

lean_ctx/
gitlab_bootstrap.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The protected MCP server's outside wrapper.
3//!
4//! Codex spawns this process; it spawns the sandboxed MCP server and then stays
5//! alive as that server's supervisor. Two anonymous descriptors cross the fork
6//! and never pass through Codex:
7//!
8//! * fd 3 — selected GitLab credentials, read only *after* Codex has spawned
9//!   this wrapper, closed as soon as the handoff is acknowledged;
10//! * fd 4 — the supervised execution channel. macOS refuses a nested
11//!   `sandbox_apply`, so the MCP server cannot wrap its own command children in
12//!   the launcher's profile; this wrapper runs outside that sandbox and is the
13//!   only process that starts them.
14//!
15//! Both protected starts — with and without a selected GitLab source — run
16//! through here, so the boundary is identical.
17use crate::core::policy::runtime::{REQUIRED_POLICY_DIGEST_ENV, REQUIRED_POLICY_ROOT_ENV};
18use crate::core::providers::selected_gitlab::Selection;
19use std::path::{Path, PathBuf};
20
21/// Client environment we are willing to forward. Everything else is dropped by
22/// `env_clear`, including profile selectors, API keys and plugin/project config.
23pub const CLIENT_ENV_PASSTHROUGH: &[&str] = &[
24    "DO_NOT_TRACK",
25    "PATH",
26    "TERM",
27    "TMPDIR",
28    "LANG",
29    "LC_ALL",
30    "LC_CTYPE",
31    "LC_MESSAGES",
32];
33
34/// Environment forwarded to the MCP server only (never to the Codex client), so
35/// the server resolves the user's existing lean-ctx config, data and providers.
36pub const MCP_ENV_PASSTHROUGH: &[&str] = &[
37    "DO_NOT_TRACK",
38    "LEAN_CTX_CACHE_DIR",
39    "LEAN_CTX_CONFIG_DIR",
40    "LEAN_CTX_DATA_DIR",
41    "LEAN_CTX_STATE_DIR",
42    "LEANCTX_ORG_POLICY",
43    "XDG_CACHE_HOME",
44    "XDG_CONFIG_HOME",
45    "XDG_DATA_HOME",
46    "XDG_STATE_HOME",
47];
48
49pub fn utf8_path(path: &Path, label: &str) -> Result<String, String> {
50    path.to_str().map(str::to_string).ok_or_else(|| {
51        format!("{label} is not valid UTF-8; this launcher cannot express it in Codex config")
52    })
53}
54
55pub fn ensure_executable_file(path: &Path) -> Result<(), String> {
56    let meta = std::fs::metadata(path)
57        .map_err(|err| format!("could not stat {}: {err}", path.display()))?;
58    if !meta.is_file() {
59        return Err(format!("{} is not a regular file", path.display()));
60    }
61    #[cfg(unix)]
62    {
63        use std::os::unix::fs::PermissionsExt;
64        if meta.permissions().mode() & 0o111 == 0 {
65            return Err(format!("{} is not executable", path.display()));
66        }
67    }
68    Ok(())
69}
70
71#[cfg(windows)]
72mod windows;
73
74pub const WRAPPER: &str = "__protected-gitlab-mcp";
75const FD_FLAG: &str = "--protected-gitlab-fd";
76#[cfg(unix)]
77const MAX_FRAME: usize = 32 * 1024;
78
79#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct Launch {
82    pub source: Selection,
83    pub glab: PathBuf,
84    pub config_dir: PathBuf,
85}
86
87pub fn prepare(source: Selection, glab: Option<&str>, home: &Path) -> Result<Launch, String> {
88    source.validate()?;
89    let glab = if let Some(path) = glab {
90        PathBuf::from(path)
91    } else {
92        std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
93            .map(|directory| directory.join("glab"))
94            .find(|path| path.is_file())
95            .ok_or("install glab or select its absolute path with --glab")?
96    }
97    .canonicalize()
98    .map_err(|_| "cannot resolve glab executable")?;
99    ensure_executable_file(&glab)?;
100    let config_dir = std::env::var_os("GLAB_CONFIG_DIR")
101        .map(PathBuf::from)
102        .unwrap_or_else(|| home.join("Library/Application Support/glab-cli"));
103    if !config_dir.is_absolute() {
104        return Err("GLAB_CONFIG_DIR must be absolute".into());
105    }
106    utf8_path(&config_dir, "glab configuration directory")?;
107    Ok(Launch {
108        source,
109        glab,
110        config_dir,
111    })
112}
113
114/// SDK hosts select metadata only; the Engine retains the same bounded,
115/// in-memory credential reader and immutable provider binding as Codex.
116pub fn initialize_agent_gitlab(
117    source: Selection,
118    glab: &Path,
119    config_dir: Option<&Path>,
120    project: &Path,
121) -> Result<(), String> {
122    #[cfg(unix)]
123    {
124        let home = dirs::home_dir()
125            .and_then(|path| path.canonicalize().ok())
126            .ok_or("credential home unavailable")?;
127        let glab_path = utf8_path(glab, "glab executable")?;
128        let mut launch = prepare(source, Some(&glab_path), &home)?;
129        if let Some(path) = config_dir {
130            launch.config_dir = path.to_path_buf();
131        } else if cfg!(not(target_os = "macos")) {
132            launch.config_dir = home.join(".config/glab-cli");
133        }
134        launch.config_dir = launch
135            .config_dir
136            .canonicalize()
137            .map_err(|_| "credential configuration unavailable")?;
138        // No selected credential material or executable can be a project input.
139        if launch.glab.starts_with(project)
140            || launch.config_dir.starts_with(project)
141            || home.starts_with(project)
142        {
143            return Err("credential authority overlaps project inputs".into());
144        }
145        unix::disable_core_dumps()?;
146        let token = unix::credential(&launch, &home)?;
147        crate::core::providers::selected_gitlab::install(launch.source, token.to_string())
148    }
149    #[cfg(windows)]
150    {
151        windows::initialize(source, glab, config_dir, project)
152    }
153    #[cfg(not(any(unix, windows)))]
154    {
155        let _ = (source, glab, config_dir, project);
156        Err("selected GitLab SDK credential transport unavailable on this platform".into())
157    }
158}
159
160/// Runs before logging, telemetry, threads or tools can observe a descriptor.
161pub fn dispatch(args: &[String]) -> Result<Option<i32>, String> {
162    const EXEC_FD_FLAG: &str = "--protected-exec-fd";
163
164    let wrapper = args.get(1).is_some_and(|arg| arg == WRAPPER);
165    let receiver = args.get(1).is_some_and(|arg| arg == "mcp")
166        && args.iter().any(|arg| arg == FD_FLAG || arg == EXEC_FD_FLAG);
167    if !wrapper && !receiver {
168        return Ok(None);
169    }
170    if !cfg!(target_os = "macos") {
171        return Err("protected GitLab bootstrap is qualified on macOS only".into());
172    }
173    if std::env::var(REQUIRED_POLICY_ROOT_ENV).is_err()
174        || std::env::var(REQUIRED_POLICY_DIGEST_ENV).is_err()
175    {
176        return Err("protected GitLab bootstrap requires the selected policy authority".into());
177    }
178    #[cfg(unix)]
179    {
180        unix::disable_core_dumps()?;
181        if wrapper {
182            if args.len() != 4 {
183                return Err("invalid protected wrapper arguments".into());
184            }
185            // `null` is the ordinary protected start; a selected GitLab source
186            // is the same wrapper carrying a launch configuration.
187            let launch: Option<Launch> = serde_json::from_str(&args[2])
188                .map_err(|_| "invalid protected launch configuration")?;
189            if let Some(launch) = &launch {
190                launch.source.validate()?;
191            }
192            return unix::run(launch.as_ref(), &args[3]).map(Some);
193        }
194        // `mcp [--protected-gitlab-fd N] --protected-exec-fd N`, and nothing
195        // else. The credential channel is adopted first: it is read and closed
196        // before the execution channel can serve anything.
197        let mut index = 2;
198        let mut credential: Option<i32> = None;
199        let mut execution: Option<i32> = None;
200        while index < args.len() {
201            let fd: i32 = args
202                .get(index + 1)
203                .ok_or("invalid protected descriptor arguments")?
204                .parse()
205                .map_err(|_| "invalid protected descriptor")?;
206            let slot = match args[index].as_str() {
207                FD_FLAG => &mut credential,
208                EXEC_FD_FLAG => &mut execution,
209                _ => return Err("invalid protected descriptor arguments".into()),
210            };
211            if slot.is_some() {
212                return Err("duplicate protected descriptor".into());
213            }
214            *slot = Some(fd);
215            index += 2;
216        }
217        if let Some(fd) = credential {
218            unix::receive(fd)?;
219        }
220        let execution =
221            execution.ok_or("protected MCP start without a supervised execution channel")?;
222        crate::core::protected_execution::install_client(execution)?;
223        Ok(None)
224    }
225    #[cfg(not(unix))]
226    Err("protected GitLab descriptor transport unavailable".into())
227}
228
229#[cfg(unix)]
230mod unix {
231    use super::{CLIENT_ENV_PASSTHROUGH, MCP_ENV_PASSTHROUGH};
232    use super::{
233        FD_FLAG, Launch, MAX_FRAME, REQUIRED_POLICY_DIGEST_ENV, REQUIRED_POLICY_ROOT_ENV, Selection,
234    };
235    use crate::core::protected_store;
236    use std::io::{Read, Write};
237    use std::os::fd::{AsRawFd, FromRawFd};
238    use std::os::unix::net::UnixStream;
239    use std::os::unix::process::CommandExt;
240    use std::process::{Command, Stdio};
241    use std::time::{Duration, Instant};
242    use zeroize::Zeroizing;
243
244    const DEADLINE: Duration = Duration::from_secs(10);
245    /// Credential channel in the MCP child. Read and closed during startup.
246    const CHILD_FD: i32 = 3;
247    /// Supervised execution channel in the MCP child; lives for the session.
248    const EXEC_FD: i32 = crate::core::protected_execution::CHILD_EXEC_FD;
249
250    pub(super) fn disable_core_dumps() -> Result<(), String> {
251        let limit = libc::rlimit {
252            rlim_cur: 0,
253            rlim_max: 0,
254        };
255        // No credential-bearing process is permitted to write a core file.
256        // SAFETY: limit is initialized and lives throughout this synchronous syscall.
257        if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &raw const limit) } != 0 {
258            return Err("cannot disable credential-process core dumps".into());
259        }
260        Ok(())
261    }
262
263    fn read_bounded(stream: &mut UnixStream, max: usize) -> Result<Zeroizing<Vec<u8>>, String> {
264        stream
265            .set_nonblocking(true)
266            .map_err(|_| "cannot bound credential channel")?;
267        let end = Instant::now() + DEADLINE;
268        let mut output = Zeroizing::new(Vec::new());
269        let mut buffer = Zeroizing::new([0_u8; 4096]);
270        loop {
271            if Instant::now() >= end {
272                return Err("credential channel timed out".into());
273            }
274            match stream.read(buffer.as_mut()) {
275                Ok(0) => return Ok(output),
276                Ok(count) => {
277                    if output.len() + count > max {
278                        return Err("credential channel exceeded size limit".into());
279                    }
280                    output.extend_from_slice(&buffer[..count]);
281                }
282                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
283                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
284                    std::thread::sleep(Duration::from_millis(5));
285                }
286                Err(_) => return Err("credential channel failed".into()),
287            }
288        }
289    }
290
291    /// Unlike process_capture this never spools stdout or stderr to disk.
292    pub(super) fn credential(
293        launch: &Launch,
294        home: &std::path::Path,
295    ) -> Result<Zeroizing<String>, String> {
296        let (mut read, write) =
297            UnixStream::pair().map_err(|_| "cannot create credential channel")?;
298        let mut reader = Command::new(&launch.glab);
299        reader
300            .args([
301                "config",
302                "get",
303                "token",
304                "--global",
305                "--host",
306                &launch.source.host,
307            ])
308            .env_clear()
309            .env("HOME", home)
310            .env("GLAB_CONFIG_DIR", &launch.config_dir)
311            .env("GLAB_SEND_TELEMETRY", "false")
312            .env("GLAB_CHECK_UPDATE", "false")
313            .current_dir("/")
314            .stdin(Stdio::null())
315            .stdout(Stdio::from(std::os::fd::OwnedFd::from(write)))
316            .stderr(Stdio::null())
317            .process_group(0);
318        // Q13: the reader leads its own process group and has a guard on it,
319        // which ends the group when this read is over or this process is
320        // gone. The reader's program is executed only once the guard is
321        // ready; a reader that cannot get a guard never started. `None` for
322        // the guard: this process is not the `lean-ctx` binary. The guard
323        // holds neither end of the credential channel (both are close-on-exec)
324        // and writes nothing to disk.
325        let (mut child, guard) = match crate::shell::containment::start_guarded_child(&mut reader) {
326            Ok(started) => started,
327            Err(crate::shell::containment::Refusal::NotStarted(_)) => {
328                return Err("cannot start selected glab credential reader".into());
329            }
330            Err(_) => return Err("cannot contain selected glab credential reader".into()),
331        };
332        // The command holds the reader's end of the channel; with it open the
333        // end of the reader's output could not be seen.
334        drop(reader);
335        let end = Instant::now() + DEADLINE;
336        let result = read_bounded(&mut read, 16 * 1024);
337        let status = loop {
338            match child.try_wait() {
339                Ok(Some(status)) => break Some(status),
340                Ok(None) if Instant::now() < end && result.is_ok() => {
341                    std::thread::sleep(Duration::from_millis(5));
342                }
343                _ => break None,
344            }
345        };
346        // A successful try_wait has already reaped the leader. Never signal
347        // that recycled PID; only kill a group whose leader is still ours.
348        // SAFETY: the child was spawned with its own process group; the negative
349        // PID addresses that owned group and no pointers cross the syscall.
350        if status.is_none()
351            && let Ok(group) = crate::ipc::process::group_target(child.id())
352        {
353            // SAFETY: only the unreaped leader's own process group is
354            // addressed; `group` is positive, so `-group` is never 0 or -1.
355            unsafe {
356                libc::kill(-group, libc::SIGKILL);
357            }
358        }
359        let _ = child.wait();
360        if let Some(guard) = guard {
361            let _ = guard.finish();
362        }
363        if !status.is_some_and(|status| status.success()) {
364            return Err("selected glab credential could not be read".into());
365        }
366        let bytes = result?;
367        let text = std::str::from_utf8(&bytes).map_err(|_| "invalid selected GitLab credential")?;
368        let token = text.trim_end_matches(['\r', '\n']);
369        if token.is_empty() || !token.bytes().all(|b| (33..=126).contains(&b)) {
370            return Err("invalid selected GitLab credential".into());
371        }
372        Ok(Zeroizing::new(token.to_owned()))
373    }
374
375    /// The credential handoff frame, built before the MCP server starts and
376    /// dropped as soon as it is acknowledged. Only the selected-GitLab start
377    /// produces one; every other step below is shared.
378    fn handoff(launch: &Launch) -> Result<Zeroizing<Vec<u8>>, String> {
379        let home = std::env::var_os("HOME").ok_or("credential home unavailable")?;
380        let token = credential(launch, std::path::Path::new(&home))?;
381        #[derive(serde::Serialize)]
382        struct Frame<'a> {
383            version: u8,
384            source: &'a Selection,
385            token: &'a str,
386        }
387        let frame = Zeroizing::new(
388            serde_json::to_vec(&Frame {
389                version: 1,
390                source: &launch.source,
391                token: &token,
392            })
393            .map_err(|_| "cannot encode credential handoff")?,
394        );
395        drop(token);
396        if frame.len() > MAX_FRAME {
397            return Err("credential handoff exceeded size limit".into());
398        }
399        Ok(frame)
400    }
401
402    /// Start the sandboxed MCP server and supervise it. Returns the server's
403    /// exit code; the wrapper lives exactly as long as the session does.
404    pub(super) fn run(launch: Option<&Launch>, profile: &str) -> Result<i32, String> {
405        // The immutable boundary every command child of this session runs
406        // under. Built here, outside the MCP sandbox, from the rules the
407        // launcher pinned — never from a request or a writable file.
408        let boundary = crate::core::protected_execution::LaunchProfile::from_pinned(profile)?;
409        protected_store::validate(boundary.shell_profile())?;
410
411        let handoff_frame = match launch {
412            Some(launch) => {
413                if !launch.glab.is_absolute() || !launch.config_dir.is_absolute() {
414                    return Err("selected credential paths must be absolute".into());
415                }
416                Some(handoff(launch)?)
417            }
418            None => None,
419        };
420
421        let (supervisor, worker) =
422            UnixStream::pair().map_err(|_| "cannot create the supervised execution channel")?;
423        let (mut send, receive) = match handoff_frame {
424            Some(_) => {
425                let (send, receive) =
426                    UnixStream::pair().map_err(|_| "cannot create MCP credential channel")?;
427                send.set_write_timeout(Some(DEADLINE))
428                    .map_err(|_| "cannot bound MCP credential channel")?;
429                send.set_read_timeout(Some(DEADLINE))
430                    .map_err(|_| "cannot bound MCP acknowledgement")?;
431                (Some(send), Some(receive))
432            }
433            None => (None, None),
434        };
435
436        let credential_fd = receive.as_ref().map_or(-1, AsRawFd::as_raw_fd);
437        let worker_fd = worker.as_raw_fd();
438        let binary = std::env::current_exe().map_err(|_| "MCP executable unavailable")?;
439        let mut arguments = vec!["mcp".to_string()];
440        if receive.is_some() {
441            arguments.push(FD_FLAG.to_string());
442            arguments.push(CHILD_FD.to_string());
443        }
444        arguments.push(crate::core::protected_execution::EXEC_FD_FLAG.to_string());
445        arguments.push(crate::core::protected_execution::CHILD_EXEC_FD.to_string());
446        let mut command = Command::new(protected_store::SANDBOX_EXEC);
447        command
448            .args(["-p", profile])
449            .arg(binary)
450            .args(&arguments)
451            .env_clear();
452        // Forward exactly the frozen nonsecret MCP authority, never ambient
453        // tokens, proxies, injection options or glab configuration selectors.
454        for name in [
455            "HOME",
456            "LEAN_CTX_PROJECT_ROOT",
457            REQUIRED_POLICY_ROOT_ENV,
458            REQUIRED_POLICY_DIGEST_ENV,
459            protected_store::CHILD_PROFILE_ENV,
460        ]
461        .iter()
462        .chain(MCP_ENV_PASSTHROUGH.iter())
463        .chain(CLIENT_ENV_PASSTHROUGH.iter())
464        {
465            if let Some(value) = std::env::var_os(name) {
466                command.env(name, value);
467            }
468        }
469        // SAFETY: both ends remain owned and open through spawn; pre_exec
470        // invokes only async-signal-safe descriptor syscalls, with no
471        // allocation or locks. Each descriptor is first duplicated above the
472        // fixed range, so placing one cannot clobber the other's source.
473        unsafe {
474            command.pre_exec(move || {
475                let credential_hold = if credential_fd >= 0 {
476                    libc::fcntl(credential_fd, libc::F_DUPFD, 10)
477                } else {
478                    -1
479                };
480                if credential_fd >= 0 && credential_hold < 0 {
481                    return Err(std::io::Error::last_os_error());
482                }
483                let worker_hold = libc::fcntl(worker_fd, libc::F_DUPFD, 10);
484                if worker_hold < 0 {
485                    return Err(std::io::Error::last_os_error());
486                }
487                if credential_hold >= 0
488                    && (libc::dup2(credential_hold, CHILD_FD) < 0
489                        || libc::fcntl(CHILD_FD, libc::F_SETFD, 0) < 0)
490                {
491                    return Err(std::io::Error::last_os_error());
492                }
493                if libc::dup2(worker_hold, EXEC_FD) < 0
494                    || libc::fcntl(EXEC_FD, libc::F_SETFD, 0) < 0
495                {
496                    return Err(std::io::Error::last_os_error());
497                }
498                // The holds are only staging; the child must inherit exactly
499                // the two fixed descriptors and nothing else.
500                if credential_hold >= 0 {
501                    libc::close(credential_hold);
502                }
503                libc::close(worker_hold);
504                Ok(())
505            });
506        }
507        let mut child = command.spawn().map_err(|_| "cannot start protected MCP")?;
508        drop(receive);
509        // The wrapper keeps only the supervisor end; the worker end now lives
510        // in the MCP server, which seals it before any helper can observe it.
511        drop(worker);
512
513        if let (Some(mut send), Some(frame)) = (send.take(), handoff_frame) {
514            // Keep the peer alive until admission is acknowledged: on macOS a
515            // full peer close makes getpeername fail even while unread bytes
516            // remain.
517            let sent = send
518                .write_all(&frame)
519                .and_then(|()| send.shutdown(std::net::Shutdown::Write))
520                .and_then(|()| {
521                    let mut acknowledgement = [0_u8; 1];
522                    send.read_exact(&mut acknowledgement)?;
523                    if acknowledgement != [1] {
524                        return Err(std::io::Error::other("invalid MCP acknowledgement"));
525                    }
526                    Ok(())
527                });
528            drop(send);
529            drop(frame);
530            if sent.is_err() {
531                let _ = child.kill();
532                let _ = child.wait();
533                return Err("MCP credential handoff failed".into());
534            }
535        }
536
537        // Supervise this session's command children until the MCP server
538        // disconnects. A supervisor that cannot serve takes the session with
539        // it: there is no unsupervised second half of a protected session.
540        if let Err(error) = crate::core::protected_execution::serve(supervisor, boundary) {
541            let _ = child.kill();
542            let _ = child.wait();
543            return Err(error);
544        }
545        finish_mcp(&mut child, Duration::from_secs(5))
546    }
547
548    fn finish_mcp(child: &mut std::process::Child, grace: Duration) -> Result<i32, String> {
549        let deadline = Instant::now() + grace;
550        loop {
551            match child.try_wait() {
552                Ok(Some(status)) => return Ok(status.code().unwrap_or(1)),
553                Ok(None) if Instant::now() < deadline => {
554                    std::thread::sleep(Duration::from_millis(10));
555                }
556                _ => {
557                    let _ = child.kill();
558                    let _ = child.wait();
559                    return Err("protected execution channel ended before MCP shutdown".into());
560                }
561            }
562        }
563    }
564
565    #[test]
566    fn a_closed_execution_channel_cannot_leave_a_live_mcp_child() {
567        let mut child = Command::new("/bin/sleep").arg("120").spawn().unwrap();
568        let result = finish_mcp(&mut child, Duration::from_millis(20));
569        assert!(result.is_err());
570        assert!(child.try_wait().unwrap().is_some());
571    }
572
573    pub(super) fn receive(fd: i32) -> Result<(), String> {
574        if fd <= 2 {
575            return Err("invalid protected GitLab descriptor".into());
576        }
577        // Validate socket type without taking ownership of arbitrary descriptors.
578        let mut kind: libc::c_int = 0;
579        let mut len = std::mem::size_of_val(&kind) as libc::socklen_t;
580        // SAFETY: kind/len are initialized writable values of the sizes supplied.
581        if unsafe {
582            libc::getsockopt(
583                fd,
584                libc::SOL_SOCKET,
585                libc::SO_TYPE,
586                (&raw mut kind).cast(),
587                &raw mut len,
588            )
589        } != 0
590            || kind != libc::SOCK_STREAM
591        {
592            return Err("GitLab bootstrap requires an anonymous stream socket".into());
593        }
594        // SAFETY: the early CLI receiver owns the inherited descriptor exactly
595        // once, before other code can use it; getsockopt verified it is open.
596        let mut stream = unsafe { UnixStream::from_raw_fd(fd) };
597        if !stream
598            .local_addr()
599            .is_ok_and(|address| address.is_unnamed())
600            || !stream.peer_addr().is_ok_and(|address| address.is_unnamed())
601        {
602            return Err("GitLab bootstrap requires an anonymous connected socket pair".into());
603        }
604        // SAFETY: stream owns the open descriptor and F_SETFD takes integer flags.
605        if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
606            return Err("cannot seal credential descriptor".into());
607        }
608        let bytes = read_bounded(&mut stream, MAX_FRAME)?;
609        #[derive(serde::Deserialize)]
610        #[serde(deny_unknown_fields)]
611        struct Frame {
612            version: u8,
613            source: Selection,
614            #[serde(deserialize_with = "secret_string")]
615            token: Zeroizing<String>,
616        }
617        fn secret_string<'de, D: serde::Deserializer<'de>>(
618            deserializer: D,
619        ) -> Result<Zeroizing<String>, D::Error> {
620            serde::Deserialize::deserialize(deserializer).map(Zeroizing::new)
621        }
622        let frame: Frame =
623            serde_json::from_slice(&bytes).map_err(|_| "invalid GitLab bootstrap frame")?;
624        if frame.version != 1 {
625            return Err("unsupported GitLab bootstrap version".into());
626        }
627        crate::core::providers::selected_gitlab::install(frame.source, frame.token.to_string())?;
628        stream
629            .set_nonblocking(false)
630            .map_err(|_| "cannot acknowledge credential handoff")?;
631        stream
632            .set_write_timeout(Some(DEADLINE))
633            .map_err(|_| "cannot bound credential acknowledgement")?;
634        stream
635            .write_all(&[1])
636            .map_err(|_| "credential acknowledgement failed")?;
637        drop(stream);
638        Ok(())
639    }
640
641    #[cfg(test)]
642    mod tests {
643        use super::*;
644        #[test]
645        fn memory_channel_bounds_and_eof() {
646            let (mut read, mut write) = UnixStream::pair().unwrap();
647            write.write_all(b"synthetic").unwrap();
648            drop(write);
649            assert_eq!(read_bounded(&mut read, 9).unwrap().as_slice(), b"synthetic");
650            let (mut read, mut write) = UnixStream::pair().unwrap();
651            write.write_all(b"too long").unwrap();
652            drop(write);
653            assert!(read_bounded(&mut read, 2).is_err());
654        }
655        /// Q13: the credential reader runs with a process guard, and what it
656        /// starts in its group does not outlive the read. The stand-in `glab`
657        /// lives in a temp dir, touches no network and prints a fixed value
658        /// that is not shaped like a credential.
659        #[test]
660        fn the_credential_reader_leaves_nothing_behind() {
661            crate::shell::containment::tests::run_external_fixture(
662                module_path!(),
663                "credential_reader_fixture",
664            );
665        }
666
667        /// Body of the test above; runs in a fixture process group of its own
668        /// that is ended when this returns or an assertion fails. The
669        /// reader's background process is in the reader's group, not the
670        /// fixture's: if a regression leaves it, it expires by itself (20 s).
671        #[test]
672        #[ignore = "fixture body of the_credential_reader_leaves_nothing_behind"]
673        fn credential_reader_fixture() {
674            use std::os::unix::fs::PermissionsExt as _;
675            let fixture = crate::shell::containment::tests::external_fixture();
676            let directory = tempfile::tempdir().unwrap();
677            let glab = directory.path().join("glab");
678            let left = directory.path().join("left.pid");
679            std::fs::write(
680                &glab,
681                format!(
682                    "#!/bin/sh\n/bin/sleep 20 >/dev/null 2>&1 &\necho $! > '{}'\necho not-a-credential\n",
683                    left.display()
684                ),
685            )
686            .unwrap();
687            std::fs::set_permissions(&glab, std::fs::Permissions::from_mode(0o700)).unwrap();
688            let launch = Launch {
689                source: Selection {
690                    host: "gitlab.example.test".into(),
691                    project: 1,
692                    namespace: "group".into(),
693                },
694                glab,
695                config_dir: directory.path().to_path_buf(),
696            };
697            let value = credential(&launch, directory.path()).unwrap();
698            assert_eq!(value.as_str(), "not-a-credential");
699            let pid = crate::ipc::process::parse_pid_file(&std::fs::read_to_string(&left).unwrap())
700                .expect("the reader wrote a valid PID");
701            let deadline = Instant::now() + Duration::from_secs(5);
702            while crate::ipc::process::is_alive(pid) {
703                assert!(
704                    Instant::now() < deadline,
705                    "the reader's background process outlived the read"
706                );
707                std::thread::sleep(Duration::from_millis(20));
708            }
709            // The temp dir is removed here; the group kill that follows
710            // would skip its destructor.
711            drop(directory);
712            fixture.complete();
713        }
714
715        /// Q13: the credential reader is not executed before its guard is
716        /// ready. A caller killed in between leaves nothing running, and the
717        /// reader's program never ran.
718        #[test]
719        fn the_credential_reader_never_runs_before_its_guard_is_ready() {
720            crate::shell::containment::tests::run_external_fixture(
721                module_path!(),
722                "credential_caller_killed_fixture",
723            );
724        }
725
726        /// Body of the test above: the stand-in below is killed between "the
727        /// reader's process exists" and "attach its guard".
728        #[test]
729        #[ignore = "fixture body of the_credential_reader_never_runs_before_its_guard_is_ready"]
730        fn credential_caller_killed_fixture() {
731            let fixture = crate::shell::containment::tests::external_fixture();
732            crate::shell::containment::tests::caller_killed_before_its_guard(
733                module_path!(),
734                "credential_caller_standin",
735            );
736            fixture.complete();
737        }
738
739        /// Stands in for a launcher in the middle of the credential read. Its
740        /// `glab` lives beside the mark, touches no network, and would leave
741        /// the mark and keep running.
742        #[test]
743        #[ignore = "stand-in caller of credential_caller_killed_fixture"]
744        fn credential_caller_standin() {
745            use std::os::unix::fs::PermissionsExt as _;
746            let marker = crate::shell::containment::tests::caller_standin_marker();
747            let directory = marker.parent().unwrap();
748            let glab = directory.join("glab");
749            std::fs::write(
750                &glab,
751                format!(
752                    "#!/bin/sh\necho ran > '{}'\n/bin/sleep 60\n",
753                    marker.display()
754                ),
755            )
756            .unwrap();
757            std::fs::set_permissions(&glab, std::fs::Permissions::from_mode(0o700)).unwrap();
758            let launch = Launch {
759                source: Selection {
760                    host: "gitlab.example.test".into(),
761                    project: 1,
762                    namespace: "group".into(),
763                },
764                glab,
765                config_dir: directory.to_path_buf(),
766            };
767            let _ = credential(&launch, directory);
768        }
769        #[test]
770        fn standard_streams_and_closed_descriptors_are_rejected() {
771            for fd in [-1, 0, 1, 2, i32::MAX] {
772                assert!(receive(fd).is_err());
773            }
774        }
775    }
776}