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            // SAFETY: only the unreaped leader's own process group is addressed.
352            unsafe {
353                libc::kill(-(child.id() as i32), libc::SIGKILL);
354            }
355        }
356        let _ = child.wait();
357        if let Some(guard) = guard {
358            let _ = guard.finish();
359        }
360        if !status.is_some_and(|status| status.success()) {
361            return Err("selected glab credential could not be read".into());
362        }
363        let bytes = result?;
364        let text = std::str::from_utf8(&bytes).map_err(|_| "invalid selected GitLab credential")?;
365        let token = text.trim_end_matches(['\r', '\n']);
366        if token.is_empty() || !token.bytes().all(|b| (33..=126).contains(&b)) {
367            return Err("invalid selected GitLab credential".into());
368        }
369        Ok(Zeroizing::new(token.to_owned()))
370    }
371
372    /// The credential handoff frame, built before the MCP server starts and
373    /// dropped as soon as it is acknowledged. Only the selected-GitLab start
374    /// produces one; every other step below is shared.
375    fn handoff(launch: &Launch) -> Result<Zeroizing<Vec<u8>>, String> {
376        let home = std::env::var_os("HOME").ok_or("credential home unavailable")?;
377        let token = credential(launch, std::path::Path::new(&home))?;
378        #[derive(serde::Serialize)]
379        struct Frame<'a> {
380            version: u8,
381            source: &'a Selection,
382            token: &'a str,
383        }
384        let frame = Zeroizing::new(
385            serde_json::to_vec(&Frame {
386                version: 1,
387                source: &launch.source,
388                token: &token,
389            })
390            .map_err(|_| "cannot encode credential handoff")?,
391        );
392        drop(token);
393        if frame.len() > MAX_FRAME {
394            return Err("credential handoff exceeded size limit".into());
395        }
396        Ok(frame)
397    }
398
399    /// Start the sandboxed MCP server and supervise it. Returns the server's
400    /// exit code; the wrapper lives exactly as long as the session does.
401    pub(super) fn run(launch: Option<&Launch>, profile: &str) -> Result<i32, String> {
402        // The immutable boundary every command child of this session runs
403        // under. Built here, outside the MCP sandbox, from the rules the
404        // launcher pinned — never from a request or a writable file.
405        let boundary = crate::core::protected_execution::LaunchProfile::from_pinned(profile)?;
406        protected_store::validate(boundary.shell_profile())?;
407
408        let handoff_frame = match launch {
409            Some(launch) => {
410                if !launch.glab.is_absolute() || !launch.config_dir.is_absolute() {
411                    return Err("selected credential paths must be absolute".into());
412                }
413                Some(handoff(launch)?)
414            }
415            None => None,
416        };
417
418        let (supervisor, worker) =
419            UnixStream::pair().map_err(|_| "cannot create the supervised execution channel")?;
420        let (mut send, receive) = match handoff_frame {
421            Some(_) => {
422                let (send, receive) =
423                    UnixStream::pair().map_err(|_| "cannot create MCP credential channel")?;
424                send.set_write_timeout(Some(DEADLINE))
425                    .map_err(|_| "cannot bound MCP credential channel")?;
426                send.set_read_timeout(Some(DEADLINE))
427                    .map_err(|_| "cannot bound MCP acknowledgement")?;
428                (Some(send), Some(receive))
429            }
430            None => (None, None),
431        };
432
433        let credential_fd = receive.as_ref().map_or(-1, AsRawFd::as_raw_fd);
434        let worker_fd = worker.as_raw_fd();
435        let binary = std::env::current_exe().map_err(|_| "MCP executable unavailable")?;
436        let mut arguments = vec!["mcp".to_string()];
437        if receive.is_some() {
438            arguments.push(FD_FLAG.to_string());
439            arguments.push(CHILD_FD.to_string());
440        }
441        arguments.push(crate::core::protected_execution::EXEC_FD_FLAG.to_string());
442        arguments.push(crate::core::protected_execution::CHILD_EXEC_FD.to_string());
443        let mut command = Command::new(protected_store::SANDBOX_EXEC);
444        command
445            .args(["-p", profile])
446            .arg(binary)
447            .args(&arguments)
448            .env_clear();
449        // Forward exactly the frozen nonsecret MCP authority, never ambient
450        // tokens, proxies, injection options or glab configuration selectors.
451        for name in [
452            "HOME",
453            "LEAN_CTX_PROJECT_ROOT",
454            REQUIRED_POLICY_ROOT_ENV,
455            REQUIRED_POLICY_DIGEST_ENV,
456            protected_store::CHILD_PROFILE_ENV,
457        ]
458        .iter()
459        .chain(MCP_ENV_PASSTHROUGH.iter())
460        .chain(CLIENT_ENV_PASSTHROUGH.iter())
461        {
462            if let Some(value) = std::env::var_os(name) {
463                command.env(name, value);
464            }
465        }
466        // SAFETY: both ends remain owned and open through spawn; pre_exec
467        // invokes only async-signal-safe descriptor syscalls, with no
468        // allocation or locks. Each descriptor is first duplicated above the
469        // fixed range, so placing one cannot clobber the other's source.
470        unsafe {
471            command.pre_exec(move || {
472                let credential_hold = if credential_fd >= 0 {
473                    libc::fcntl(credential_fd, libc::F_DUPFD, 10)
474                } else {
475                    -1
476                };
477                if credential_fd >= 0 && credential_hold < 0 {
478                    return Err(std::io::Error::last_os_error());
479                }
480                let worker_hold = libc::fcntl(worker_fd, libc::F_DUPFD, 10);
481                if worker_hold < 0 {
482                    return Err(std::io::Error::last_os_error());
483                }
484                if credential_hold >= 0
485                    && (libc::dup2(credential_hold, CHILD_FD) < 0
486                        || libc::fcntl(CHILD_FD, libc::F_SETFD, 0) < 0)
487                {
488                    return Err(std::io::Error::last_os_error());
489                }
490                if libc::dup2(worker_hold, EXEC_FD) < 0
491                    || libc::fcntl(EXEC_FD, libc::F_SETFD, 0) < 0
492                {
493                    return Err(std::io::Error::last_os_error());
494                }
495                // The holds are only staging; the child must inherit exactly
496                // the two fixed descriptors and nothing else.
497                if credential_hold >= 0 {
498                    libc::close(credential_hold);
499                }
500                libc::close(worker_hold);
501                Ok(())
502            });
503        }
504        let mut child = command.spawn().map_err(|_| "cannot start protected MCP")?;
505        drop(receive);
506        // The wrapper keeps only the supervisor end; the worker end now lives
507        // in the MCP server, which seals it before any helper can observe it.
508        drop(worker);
509
510        if let (Some(mut send), Some(frame)) = (send.take(), handoff_frame) {
511            // Keep the peer alive until admission is acknowledged: on macOS a
512            // full peer close makes getpeername fail even while unread bytes
513            // remain.
514            let sent = send
515                .write_all(&frame)
516                .and_then(|()| send.shutdown(std::net::Shutdown::Write))
517                .and_then(|()| {
518                    let mut acknowledgement = [0_u8; 1];
519                    send.read_exact(&mut acknowledgement)?;
520                    if acknowledgement != [1] {
521                        return Err(std::io::Error::other("invalid MCP acknowledgement"));
522                    }
523                    Ok(())
524                });
525            drop(send);
526            drop(frame);
527            if sent.is_err() {
528                let _ = child.kill();
529                let _ = child.wait();
530                return Err("MCP credential handoff failed".into());
531            }
532        }
533
534        // Supervise this session's command children until the MCP server
535        // disconnects. A supervisor that cannot serve takes the session with
536        // it: there is no unsupervised second half of a protected session.
537        if let Err(error) = crate::core::protected_execution::serve(supervisor, boundary) {
538            let _ = child.kill();
539            let _ = child.wait();
540            return Err(error);
541        }
542        finish_mcp(&mut child, Duration::from_secs(5))
543    }
544
545    fn finish_mcp(child: &mut std::process::Child, grace: Duration) -> Result<i32, String> {
546        let deadline = Instant::now() + grace;
547        loop {
548            match child.try_wait() {
549                Ok(Some(status)) => return Ok(status.code().unwrap_or(1)),
550                Ok(None) if Instant::now() < deadline => {
551                    std::thread::sleep(Duration::from_millis(10));
552                }
553                _ => {
554                    let _ = child.kill();
555                    let _ = child.wait();
556                    return Err("protected execution channel ended before MCP shutdown".into());
557                }
558            }
559        }
560    }
561
562    #[test]
563    fn a_closed_execution_channel_cannot_leave_a_live_mcp_child() {
564        let mut child = Command::new("/bin/sleep").arg("120").spawn().unwrap();
565        let result = finish_mcp(&mut child, Duration::from_millis(20));
566        assert!(result.is_err());
567        assert!(child.try_wait().unwrap().is_some());
568    }
569
570    pub(super) fn receive(fd: i32) -> Result<(), String> {
571        if fd <= 2 {
572            return Err("invalid protected GitLab descriptor".into());
573        }
574        // Validate socket type without taking ownership of arbitrary descriptors.
575        let mut kind: libc::c_int = 0;
576        let mut len = std::mem::size_of_val(&kind) as libc::socklen_t;
577        // SAFETY: kind/len are initialized writable values of the sizes supplied.
578        if unsafe {
579            libc::getsockopt(
580                fd,
581                libc::SOL_SOCKET,
582                libc::SO_TYPE,
583                (&raw mut kind).cast(),
584                &raw mut len,
585            )
586        } != 0
587            || kind != libc::SOCK_STREAM
588        {
589            return Err("GitLab bootstrap requires an anonymous stream socket".into());
590        }
591        // SAFETY: the early CLI receiver owns the inherited descriptor exactly
592        // once, before other code can use it; getsockopt verified it is open.
593        let mut stream = unsafe { UnixStream::from_raw_fd(fd) };
594        if !stream
595            .local_addr()
596            .is_ok_and(|address| address.is_unnamed())
597            || !stream.peer_addr().is_ok_and(|address| address.is_unnamed())
598        {
599            return Err("GitLab bootstrap requires an anonymous connected socket pair".into());
600        }
601        // SAFETY: stream owns the open descriptor and F_SETFD takes integer flags.
602        if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
603            return Err("cannot seal credential descriptor".into());
604        }
605        let bytes = read_bounded(&mut stream, MAX_FRAME)?;
606        #[derive(serde::Deserialize)]
607        #[serde(deny_unknown_fields)]
608        struct Frame {
609            version: u8,
610            source: Selection,
611            #[serde(deserialize_with = "secret_string")]
612            token: Zeroizing<String>,
613        }
614        fn secret_string<'de, D: serde::Deserializer<'de>>(
615            deserializer: D,
616        ) -> Result<Zeroizing<String>, D::Error> {
617            serde::Deserialize::deserialize(deserializer).map(Zeroizing::new)
618        }
619        let frame: Frame =
620            serde_json::from_slice(&bytes).map_err(|_| "invalid GitLab bootstrap frame")?;
621        if frame.version != 1 {
622            return Err("unsupported GitLab bootstrap version".into());
623        }
624        crate::core::providers::selected_gitlab::install(frame.source, frame.token.to_string())?;
625        stream
626            .set_nonblocking(false)
627            .map_err(|_| "cannot acknowledge credential handoff")?;
628        stream
629            .set_write_timeout(Some(DEADLINE))
630            .map_err(|_| "cannot bound credential acknowledgement")?;
631        stream
632            .write_all(&[1])
633            .map_err(|_| "credential acknowledgement failed")?;
634        drop(stream);
635        Ok(())
636    }
637
638    #[cfg(test)]
639    mod tests {
640        use super::*;
641        #[test]
642        fn memory_channel_bounds_and_eof() {
643            let (mut read, mut write) = UnixStream::pair().unwrap();
644            write.write_all(b"synthetic").unwrap();
645            drop(write);
646            assert_eq!(read_bounded(&mut read, 9).unwrap().as_slice(), b"synthetic");
647            let (mut read, mut write) = UnixStream::pair().unwrap();
648            write.write_all(b"too long").unwrap();
649            drop(write);
650            assert!(read_bounded(&mut read, 2).is_err());
651        }
652        /// Q13: the credential reader runs with a process guard, and what it
653        /// starts in its group does not outlive the read. The stand-in `glab`
654        /// lives in a temp dir, touches no network and prints a fixed value
655        /// that is not shaped like a credential.
656        #[test]
657        fn the_credential_reader_leaves_nothing_behind() {
658            crate::shell::containment::tests::run_external_fixture(
659                module_path!(),
660                "credential_reader_fixture",
661            );
662        }
663
664        /// Body of the test above; runs in a fixture process group of its own
665        /// that is ended when this returns or an assertion fails. The
666        /// reader's background process is in the reader's group, not the
667        /// fixture's: if a regression leaves it, it expires by itself (20 s).
668        #[test]
669        #[ignore = "fixture body of the_credential_reader_leaves_nothing_behind"]
670        fn credential_reader_fixture() {
671            use std::os::unix::fs::PermissionsExt as _;
672            let fixture = crate::shell::containment::tests::external_fixture();
673            let directory = tempfile::tempdir().unwrap();
674            let glab = directory.path().join("glab");
675            let left = directory.path().join("left.pid");
676            std::fs::write(
677                &glab,
678                format!(
679                    "#!/bin/sh\n/bin/sleep 20 >/dev/null 2>&1 &\necho $! > '{}'\necho not-a-credential\n",
680                    left.display()
681                ),
682            )
683            .unwrap();
684            std::fs::set_permissions(&glab, std::fs::Permissions::from_mode(0o700)).unwrap();
685            let launch = Launch {
686                source: Selection {
687                    host: "gitlab.example.test".into(),
688                    project: 1,
689                    namespace: "group".into(),
690                },
691                glab,
692                config_dir: directory.path().to_path_buf(),
693            };
694            let value = credential(&launch, directory.path()).unwrap();
695            assert_eq!(value.as_str(), "not-a-credential");
696            let pid: libc::pid_t = std::fs::read_to_string(&left)
697                .unwrap()
698                .trim()
699                .parse()
700                .unwrap();
701            let deadline = Instant::now() + Duration::from_secs(5);
702            // SAFETY: signal 0 only probes for existence.
703            while unsafe { libc::kill(pid, 0) } == 0 {
704                assert!(
705                    Instant::now() < deadline,
706                    "the reader's background process outlived the read"
707                );
708                std::thread::sleep(Duration::from_millis(20));
709            }
710            // The temp dir is removed here; the group kill that follows
711            // would skip its destructor.
712            drop(directory);
713            fixture.complete();
714        }
715
716        /// Q13: the credential reader is not executed before its guard is
717        /// ready. A caller killed in between leaves nothing running, and the
718        /// reader's program never ran.
719        #[test]
720        fn the_credential_reader_never_runs_before_its_guard_is_ready() {
721            crate::shell::containment::tests::run_external_fixture(
722                module_path!(),
723                "credential_caller_killed_fixture",
724            );
725        }
726
727        /// Body of the test above: the stand-in below is killed between "the
728        /// reader's process exists" and "attach its guard".
729        #[test]
730        #[ignore = "fixture body of the_credential_reader_never_runs_before_its_guard_is_ready"]
731        fn credential_caller_killed_fixture() {
732            let fixture = crate::shell::containment::tests::external_fixture();
733            crate::shell::containment::tests::caller_killed_before_its_guard(
734                module_path!(),
735                "credential_caller_standin",
736            );
737            fixture.complete();
738        }
739
740        /// Stands in for a launcher in the middle of the credential read. Its
741        /// `glab` lives beside the mark, touches no network, and would leave
742        /// the mark and keep running.
743        #[test]
744        #[ignore = "stand-in caller of credential_caller_killed_fixture"]
745        fn credential_caller_standin() {
746            use std::os::unix::fs::PermissionsExt as _;
747            let marker = crate::shell::containment::tests::caller_standin_marker();
748            let directory = marker.parent().unwrap();
749            let glab = directory.join("glab");
750            std::fs::write(
751                &glab,
752                format!(
753                    "#!/bin/sh\necho ran > '{}'\n/bin/sleep 60\n",
754                    marker.display()
755                ),
756            )
757            .unwrap();
758            std::fs::set_permissions(&glab, std::fs::Permissions::from_mode(0o700)).unwrap();
759            let launch = Launch {
760                source: Selection {
761                    host: "gitlab.example.test".into(),
762                    project: 1,
763                    namespace: "group".into(),
764                },
765                glab,
766                config_dir: directory.to_path_buf(),
767            };
768            let _ = credential(&launch, directory);
769        }
770        #[test]
771        fn standard_streams_and_closed_descriptors_are_rejected() {
772            for fd in [-1, 0, 1, 2, i32::MAX] {
773                assert!(receive(fd).is_err());
774            }
775        }
776    }
777}