Skip to main content

systemprompt_models/subprocess/
mod.rs

1//! Identity contract for the detached agent and MCP children the supervisor
2//! owns: the environment markers stamped at spawn time and the pure parsers
3//! that read them back off a live process image.
4//!
5//! The supervisor stamps [`SUBPROCESS_MARKER_ENV`] and a `name_key=service`
6//! pair into every child; shutdown, reconciliation, and port reclamation
7//! confirm a registry PID still names *this* installation's child before
8//! signalling it. PIDs are recycled, and group-signalling a stale PID
9//! (`kill(-pid)`) could reach an unrelated session leader — so a row is only
10//! ever signalled once both the marker and the exact pairing are found.
11//!
12//! The API server is not a spawned child: `infra services serve` re-executes
13//! itself once with [`API_SERVER_ENV`] so a later `stop api` can prove a port
14//! holder is the API server before signalling it. It deliberately does not
15//! carry [`SUBPROCESS_MARKER_ENV`], which selects the subprocess secrets
16//! source.
17//!
18//! Spawning and the platform-specific process probes live in
19//! `systemprompt_loader::subprocess`; this module holds only data and pure
20//! functions so the shared layer stays free of process I/O.
21//!
22//! Copyright (c) systemprompt.io — Business Source License 1.1.
23//! See <https://systemprompt.io> for licensing details.
24
25use systemprompt_identifiers::ServiceName;
26
27pub const SUBPROCESS_MARKER_ENV: &str = "SYSTEMPROMPT_SUBPROCESS";
28pub const AGENT_NAME_ENV: &str = "AGENT_NAME";
29pub const MCP_SERVICE_ID_ENV: &str = "MCP_SERVICE_ID";
30pub const API_SERVER_ENV: &str = "SYSTEMPROMPT_API_SERVER";
31pub const API_SERVER_SERVICE: &str = "api";
32
33pub const DEPLOYMENT_HOST_ENV: &str = "SYSTEMPROMPT_DEPLOYMENT_HOST";
34
35// Why: Fly injects `FLY_APP_NAME` into deployed machines.
36const FLY_HOST_ENV: &str = "FLY_APP_NAME";
37
38pub fn deployment_host(lookup: impl Fn(&str) -> Option<String>) -> Option<String> {
39    [DEPLOYMENT_HOST_ENV, FLY_HOST_ENV].iter().find_map(|name| {
40        lookup(name)
41            .map(|value| value.trim().to_owned())
42            .filter(|value| !value.is_empty())
43    })
44}
45
46pub fn is_deployment_host(lookup: impl Fn(&str) -> Option<String>) -> bool {
47    deployment_host(lookup).is_some()
48}
49
50pub fn inherited_parent_env(lookup: impl Fn(&str) -> Option<String>) -> Vec<(String, String)> {
51    let mut env: Vec<(String, String)> = [
52        DEPLOYMENT_HOST_ENV,
53        FLY_HOST_ENV,
54        "HOSTNAME",
55        "FLY_MACHINE_ID",
56        "PATH",
57        "HOME",
58    ]
59    .iter()
60    .filter_map(|name| lookup(name).map(|value| ((*name).to_owned(), value)))
61    .collect();
62
63    if let Some(entry) = crate::net::trusted_hosts_env_entry(&lookup) {
64        env.push(entry);
65    }
66
67    env
68}
69
70#[must_use]
71pub const fn identity_verification_supported() -> bool {
72    cfg!(any(target_os = "linux", target_os = "macos"))
73}
74
75#[must_use]
76pub fn signalable_pid(pid: u32) -> Option<i32> {
77    if pid == 0 {
78        return None;
79    }
80    i32::try_from(pid).ok()
81}
82
83#[must_use]
84pub fn environ_identifies_child(
85    environ: &[u8],
86    name_key: &str,
87    service_name: &ServiceName,
88) -> bool {
89    let marker = format!("{SUBPROCESS_MARKER_ENV}=1");
90    let expected_name = format!("{name_key}={service_name}");
91
92    let mut has_marker = false;
93    let mut has_name = false;
94    for entry in environ.split(|&b| b == 0) {
95        if entry == marker.as_bytes() {
96            has_marker = true;
97        } else if entry == expected_name.as_bytes() {
98            has_name = true;
99        }
100    }
101
102    has_marker && has_name
103}
104
105#[must_use]
106pub fn environ_identifies_api_server(environ: &[u8], service_name: &ServiceName) -> bool {
107    let expected = format!("{API_SERVER_ENV}={service_name}");
108    environ
109        .split(|&b| b == 0)
110        .any(|entry| entry == expected.as_bytes())
111}
112
113#[must_use]
114pub fn environ_has_entries(environ: &[u8]) -> bool {
115    environ
116        .split(|&b| b == 0)
117        .any(|entry| entry.contains(&b'='))
118}
119
120// Why: macOS `KERN_PROCARGS2` stores argc, exec path, NUL padding, argv, then
121// environ. Skip argv by argc: argument strings can themselves look like
122// environment entries.
123#[must_use]
124pub fn environ_from_procargs2(blob: &[u8]) -> Option<&[u8]> {
125    const ARGC_LEN: usize = size_of::<i32>();
126
127    let argc_bytes: [u8; ARGC_LEN] = blob.get(..ARGC_LEN)?.try_into().ok()?;
128    let argc = usize::try_from(i32::from_ne_bytes(argc_bytes)).ok()?;
129
130    let mut rest = blob.get(ARGC_LEN..)?;
131    let exec_path_end = rest.iter().position(|&b| b == 0)?;
132    rest = rest.get(exec_path_end + 1..)?;
133
134    let argv_start = rest.iter().position(|&b| b != 0)?;
135    rest = rest.get(argv_start..)?;
136
137    for _ in 0..argc {
138        let entry_end = rest.iter().position(|&b| b == 0)?;
139        rest = rest.get(entry_end + 1..)?;
140    }
141
142    Some(rest)
143}