Summary
When the Rust SDK spawns the agent (Client::start → spawn_stdio / spawn_tcp → build_command → Command::spawn), the embedder has no way to configure the child process at fork time, and the SDK's teardown paths act only on the immediate child PID. That makes it impossible for a long-lived host embedding the SDK to:
- Guarantee the agent's whole descendant tree is reaped on shutdown or crash.
Client::stop calls child.kill().await, and Client::force_stop / Drop for ClientInner call child.start_kill() — all of these signal only the immediate CLI child. Any processes the agent itself spawns (tool subprocesses, shells, MCP servers, language servers) are not part of that signal. Because the child is spawned in the embedder's own process group (nothing in build_command calls process_group / sets a new session), there is no distinct process group to signal, so those descendants are reparented to init and survive as orphans holding file handles and network connections.
- Place the agent in its own process group / session so the embedder can do a clean
kill(-pgid) + reap of the entire tree.
- Drop privileges before
execve (setuid/setgid/setgroups) so the agent doesn't inherit the host's full uid/gid.
- Install a parent-death signal (Linux
prctl(PR_SET_PDEATHSIG)) so a hard host crash (SIGKILL, OOM, abort) — where no Rust Drop runs — still tears the agent tree down instead of leaving it orphaned.
All four require running code in the child between fork and execve, or setting the standard std::os::unix::process::CommandExt knobs on the command the SDK builds. Today ClientOptions exposes none of these, and since the SDK owns the Command internally, the embedder can't reach it. (An embedder building its own std::process::Command could set these via CommandExt and then tokio::process::Command::from(..), but that lever isn't available when the SDK does the spawning.)
Why this matters
Embedders that run the agent as a long-lived, network-connected daemon on behalf of many sessions need agent subprocesses to be confined and fully reaped — no orphaned tool/MCP processes should outlive the agent, and none should outlive a host crash. Process-group / session isolation at spawn time plus a parent-death signal are the standard OS primitives for that guarantee, and they can only be set by the process that forks the child.
Current behavior (public source, for reference)
ClientOptions carries program, prefix_args, working_directory, env, env_remove, extra_args, transport, token/auth, telemetry, base_directory, etc. — no child-process/spawn configuration.
build_command builds a tokio::process::Command and never calls process_group, sets a session, drops privileges, or installs a pre_exec hook.
Client::stop → child.kill().await (immediate child only).
Client::force_stop and Drop for ClientInner → child.start_kill() (immediate child only).
Client::pid() exposes the immediate child PID; there is no process_group() accessor.
Proposed API
Two complementary pieces; either alone is a big step, both together fully unblock the use cases above.
1. Structured, safe pass-through fields on ClientOptions
Thin wrappers over std::os::unix::process::CommandExt (Unix) and std::os::windows::process::CommandExt (Windows), applied in build_command:
pub struct ClientOptions {
// … existing fields …
/// Place the spawned agent in a process group. `Some(0)` makes the
/// child a new process-group leader (its PGID == its PID); `Some(pgid)`
/// joins an existing group. `None` (default) inherits the caller's
/// group — today's behavior. Maps to `CommandExt::process_group`.
pub process_group: Option<i32>,
/// Unix privilege drop applied before `execve`. Map to
/// `CommandExt::uid` / `gid` / `groups`.
#[cfg(unix)] pub uid: Option<u32>,
#[cfg(unix)] pub gid: Option<u32>,
#[cfg(unix)] pub groups: Option<Vec<u32>>,
/// Windows process creation flags (e.g. `CREATE_NEW_PROCESS_GROUP`),
/// OR-ed into the existing `CREATE_NO_WINDOW`. Maps to
/// `CommandExt::creation_flags`.
#[cfg(windows)] pub creation_flags: Option<u32>,
}
process_group, uid, gid, groups are all safe methods on CommandExt, so this subset needs no unsafe API surface and directly covers process-group isolation + privilege drop.
2. An escape-hatch child-setup hook
For everything the structured fields don't cover — prctl(PR_SET_PDEATHSIG), setrlimit, sandbox entry, mount/user namespace setup — expose a callback the SDK runs on the built command just before spawn:
/// Called with the fully-built command immediately before spawn, so the
/// embedder can apply platform-specific configuration (e.g. `pre_exec`,
/// resource limits, sandbox entry) that the structured fields don't cover.
pub command_customizer: Option<Arc<dyn Fn(&mut std::process::Command) + Send + Sync>>,
A command_customizer keeps the SDK from having to enumerate and re-export every platform knob; the embedder applies CommandExt::pre_exec (which is unsafe and carries the usual async-signal-safety contract) or any other config itself. If a callback surface is undesirable, an explicit pre_exec: Option<Arc<dyn Fn() -> io::Result<()> + Send + Sync>> field would also work.
3. Reap the group, not just the PID (when the SDK created one)
If the agent is spawned as a process-group leader (via field #1), the SDK's own teardown paths (stop, force_stop, Drop) should signal the process group (kill(-pgid, …) on Unix; a Job Object or GenerateConsoleCtrlEvent on Windows) rather than only the immediate child, so descendants are reaped on the paths the embedder doesn't drive directly. At minimum, add a Client::process_group() -> Option<i32> accessor so an embedder that opted into a new group can perform the group reap itself.
Alternatives considered
- Embedder wraps
program in a launcher that setsids / drops privileges / execs the real agent. Fragile: it fights the bundled-CLI resolution (the embedder doesn't know the extracted binary path), breaks Client::pid() semantics, and setsid(1) isn't portable (absent on macOS). A first-class SDK hook is cleaner and portable.
- Post-spawn
setpgid from the parent. Not possible: Client::start returns after execve, and setpgid(2) fails with EACCES once the child has exec'd.
Acceptance
ClientOptions can request a new process group / session for the agent, privilege drop, and arbitrary child-side setup before execve.
- With a new group requested, the SDK reaps the whole group on
stop / force_stop / Drop, or exposes the group id so the embedder can.
- Defaults are unchanged (no new group, no privilege drop, no customizer) so existing consumers are unaffected.
Summary
When the Rust SDK spawns the agent (
Client::start→spawn_stdio/spawn_tcp→build_command→Command::spawn), the embedder has no way to configure the child process at fork time, and the SDK's teardown paths act only on the immediate child PID. That makes it impossible for a long-lived host embedding the SDK to:Client::stopcallschild.kill().await, andClient::force_stop/Drop for ClientInnercallchild.start_kill()— all of these signal only the immediate CLI child. Any processes the agent itself spawns (tool subprocesses, shells, MCP servers, language servers) are not part of that signal. Because the child is spawned in the embedder's own process group (nothing inbuild_commandcallsprocess_group/ sets a new session), there is no distinct process group to signal, so those descendants are reparented toinitand survive as orphans holding file handles and network connections.kill(-pgid)+ reap of the entire tree.execve(setuid/setgid/setgroups) so the agent doesn't inherit the host's full uid/gid.prctl(PR_SET_PDEATHSIG)) so a hard host crash (SIGKILL, OOM, abort) — where no RustDropruns — still tears the agent tree down instead of leaving it orphaned.All four require running code in the child between
forkandexecve, or setting the standardstd::os::unix::process::CommandExtknobs on the command the SDK builds. TodayClientOptionsexposes none of these, and since the SDK owns theCommandinternally, the embedder can't reach it. (An embedder building its ownstd::process::Commandcould set these viaCommandExtand thentokio::process::Command::from(..), but that lever isn't available when the SDK does the spawning.)Why this matters
Embedders that run the agent as a long-lived, network-connected daemon on behalf of many sessions need agent subprocesses to be confined and fully reaped — no orphaned tool/MCP processes should outlive the agent, and none should outlive a host crash. Process-group / session isolation at spawn time plus a parent-death signal are the standard OS primitives for that guarantee, and they can only be set by the process that forks the child.
Current behavior (public source, for reference)
ClientOptionscarriesprogram,prefix_args,working_directory,env,env_remove,extra_args,transport, token/auth, telemetry,base_directory, etc. — no child-process/spawn configuration.build_commandbuilds atokio::process::Commandand never callsprocess_group, sets a session, drops privileges, or installs apre_exechook.Client::stop→child.kill().await(immediate child only).Client::force_stopandDrop for ClientInner→child.start_kill()(immediate child only).Client::pid()exposes the immediate child PID; there is noprocess_group()accessor.Proposed API
Two complementary pieces; either alone is a big step, both together fully unblock the use cases above.
1. Structured, safe pass-through fields on
ClientOptionsThin wrappers over
std::os::unix::process::CommandExt(Unix) andstd::os::windows::process::CommandExt(Windows), applied inbuild_command:process_group,uid,gid,groupsare all safe methods onCommandExt, so this subset needs nounsafeAPI surface and directly covers process-group isolation + privilege drop.2. An escape-hatch child-setup hook
For everything the structured fields don't cover —
prctl(PR_SET_PDEATHSIG),setrlimit, sandbox entry, mount/user namespace setup — expose a callback the SDK runs on the built command just beforespawn:A
command_customizerkeeps the SDK from having to enumerate and re-export every platform knob; the embedder appliesCommandExt::pre_exec(which isunsafeand carries the usual async-signal-safety contract) or any other config itself. If a callback surface is undesirable, an explicitpre_exec: Option<Arc<dyn Fn() -> io::Result<()> + Send + Sync>>field would also work.3. Reap the group, not just the PID (when the SDK created one)
If the agent is spawned as a process-group leader (via field #1), the SDK's own teardown paths (
stop,force_stop,Drop) should signal the process group (kill(-pgid, …)on Unix; a Job Object orGenerateConsoleCtrlEventon Windows) rather than only the immediate child, so descendants are reaped on the paths the embedder doesn't drive directly. At minimum, add aClient::process_group() -> Option<i32>accessor so an embedder that opted into a new group can perform the group reap itself.Alternatives considered
programin a launcher thatsetsids / drops privileges / execs the real agent. Fragile: it fights the bundled-CLI resolution (the embedder doesn't know the extracted binary path), breaksClient::pid()semantics, andsetsid(1)isn't portable (absent on macOS). A first-class SDK hook is cleaner and portable.setpgidfrom the parent. Not possible:Client::startreturns afterexecve, andsetpgid(2)fails withEACCESonce the child has exec'd.Acceptance
ClientOptionscan request a new process group / session for the agent, privilege drop, and arbitrary child-side setup beforeexecve.stop/force_stop/Drop, or exposes the group id so the embedder can.