Summary
In any environment where ps cannot be executed, 100% of Execute tool calls fail instantly with:
Error: Command terminated by signal: SIGKILL
The command itself is never at fault: trivial commands (pwd, /bin/echo hi) die in 57–88 ms. Every other tool (Read, Edit, Grep, MCP tools, fire-and-forget) works normally in the same session.
The cause is factory-execute-supervisor: its owner-liveness probe shells out to ps, and it cannot distinguish "the probe failed" from "my owner died." When the probe fails it concludes the owner is gone and SIGKILLs the whole process group, which contains the command it was supervising.
Environment
- Droid CLI: 0.219.0 (also reported on 0.218.0, see Related)
- Host: macOS 27.0 / arm64,
droid exec --auto high, non-interactive, running under a seatbelt (sandbox-exec) profile
- Independently reproduced on Ubuntu 24.04 + Docker (linked issue)
Root cause
droid spawns every Execute command through an inline bash supervisor (createUnixProcess):
Kdc(shell, ["-c", epf, "factory-execute-supervisor", String(process.pid), shell, cmd, ...args],
{ ..., detached: true })
detached: true makes the supervisor a process-group leader, and the command shell runs in that group. The supervisor script is:
owner_is_alive() {
parent_pid=$(ps -o ppid= -p "$$" 2>/dev/null) || return 1
parent_pid=${parent_pid//[[:space:]]/}
[[ "$parent_pid" == "$owner_pid" ]] && kill -0 "$owner_pid" 2>/dev/null
}
watch_owner() {
while owner_is_alive; do
sleep 2
done
kill -KILL -- -$$ 2>/dev/null || true
}
Two properties combine into the failure:
- A failed probe is read as owner death.
ps ... || return 1 fires when ps is missing, non-executable, or denied. None of those mean the owner exited.
- No grace period. The
sleep 2 is inside the loop body, so the first failed probe falls straight through to kill -KILL -- -$$. The kill lands within milliseconds, before the command can produce output.
Because the supervisor leads the process group, kill -KILL -- -$$ takes down the command shell with it, surfacing as SIGKILL to the user.
Why fire-and-forget is unaffected
The fire-and-forget branch spawns exec nohup bash -c '…' directly, with no supervisor wrapper. It is the only shell path that survives, which matches the symptom pattern reported in both environments.
Reproduction
The supervisor logic can be exercised on its own, with no sandbox and no container. Save the script above as epf.sh (with the trailing owner_pid=$1 … exit "$status" lines from the binary), then:
# The supervisor must be its own process-group leader, matching droid's detached: true.
# ps reachable -> works
setsid bash epf.sh $$ /bin/bash 'echo SUPERVISOR_OK'
# -> SUPERVISOR_OK, exit 0 (measured: 7 ms)
# ps unreachable -> command is SIGKILLed
env PATH=/nonexistent setsid bash epf.sh $$ /bin/bash '/bin/echo SUPERVISOR_OK'
# -> no output, exit 137 (SIGKILL) (measured: 5 ms)
End-to-end, in a container without procps:
docker run --rm -it debian:12-slim # ships no `ps`
# install droid, then:
droid exec --auto high "use a shell command to print hello"
# -> Error: Command terminated by signal: SIGKILL
Affected environments
Any environment where ps is absent or not executable:
- Slim/distroless containers without procps (the linked Docker report).
- Sandboxed execution on macOS.
/bin/ps is setuid-root (-rwsr-xr-x), and a seatbelt (sandbox-exec) profile refuses to exec setuid binaries even under (allow default): ps exits 126 (Operation not permitted) while every non-setuid binary runs fine. The denial is the kernel's setuid-exec rule, not a filesystem allowlist, so the sandbox cannot grant it without allowing setuid-root exec. Droid's Execute tool therefore cannot function under a restrictive macOS sandbox at all.
Observed on this machine: 19 of 19 Execute calls SIGKILLed in sandboxed sessions, versus 0 SIGKILLs across 1,131 Execute calls in unsandboxed sessions.
Suggested fix
Given the blast radius (SIGKILL of the entire process group), the probe should fail open, not closed.
Preferred: distinguish probe failure from owner death. When ps itself fails, treat the result as unknown and continue the loop instead of killing. A probe that cannot run says nothing about the owner:
owner_is_alive() {
parent_pid=$(ps -o ppid= -p "$$" 2>/dev/null) || return 0 # probe unavailable: assume alive
parent_pid=${parent_pid//[[:space:]]/}
[[ "$parent_pid" == "$owner_pid" ]] && kill -0 "$owner_pid" 2>/dev/null
}
Alternatives:
- Fall back to
kill -0 "$owner_pid" alone when ps is unavailable. This still detects owner death; it only loses the PID-reuse guard that the parent-identity check provides.
- Require N consecutive negative results, and move
sleep so the first iteration is never immediately fatal.
One note on a tempting shortcut: replacing the ps probe with bash's $PPID does not preserve the current semantics. Bash fixes $PPID at shell startup, so after the owner dies and the shell is reparented, $PPID still equals owner_pid while ps -o ppid= reports the new parent. Detecting that reparenting is the only thing the ps call buys; a $PPID comparison reduces to kill -0 "$owner_pid" and should be presented as such.
Related
Summary
In any environment where
pscannot be executed, 100% ofExecutetool calls fail instantly with:The command itself is never at fault: trivial commands (
pwd,/bin/echo hi) die in 57–88 ms. Every other tool (Read, Edit, Grep, MCP tools, fire-and-forget) works normally in the same session.The cause is
factory-execute-supervisor: its owner-liveness probe shells out tops, and it cannot distinguish "the probe failed" from "my owner died." When the probe fails it concludes the owner is gone and SIGKILLs the whole process group, which contains the command it was supervising.Environment
droid exec --auto high, non-interactive, running under a seatbelt (sandbox-exec) profileRoot cause
droidspawns everyExecutecommand through an inline bash supervisor (createUnixProcess):detached: truemakes the supervisor a process-group leader, and the command shell runs in that group. The supervisor script is:Two properties combine into the failure:
ps ... || return 1fires whenpsis missing, non-executable, or denied. None of those mean the owner exited.sleep 2is inside the loop body, so the first failed probe falls straight through tokill -KILL -- -$$. The kill lands within milliseconds, before the command can produce output.Because the supervisor leads the process group,
kill -KILL -- -$$takes down the command shell with it, surfacing as SIGKILL to the user.Why fire-and-forget is unaffected
The fire-and-forget branch spawns
exec nohup bash -c '…'directly, with no supervisor wrapper. It is the only shell path that survives, which matches the symptom pattern reported in both environments.Reproduction
The supervisor logic can be exercised on its own, with no sandbox and no container. Save the script above as
epf.sh(with the trailingowner_pid=$1 … exit "$status"lines from the binary), then:End-to-end, in a container without procps:
Affected environments
Any environment where
psis absent or not executable:/bin/psis setuid-root (-rwsr-xr-x), and a seatbelt (sandbox-exec) profile refuses to exec setuid binaries even under(allow default):psexits 126 (Operation not permitted) while every non-setuid binary runs fine. The denial is the kernel's setuid-exec rule, not a filesystem allowlist, so the sandbox cannot grant it without allowing setuid-root exec. Droid'sExecutetool therefore cannot function under a restrictive macOS sandbox at all.Observed on this machine: 19 of 19
Executecalls SIGKILLed in sandboxed sessions, versus 0 SIGKILLs across 1,131Executecalls in unsandboxed sessions.Suggested fix
Given the blast radius (SIGKILL of the entire process group), the probe should fail open, not closed.
Preferred: distinguish probe failure from owner death. When
psitself fails, treat the result as unknown and continue the loop instead of killing. A probe that cannot run says nothing about the owner:Alternatives:
kill -0 "$owner_pid"alone whenpsis unavailable. This still detects owner death; it only loses the PID-reuse guard that the parent-identity check provides.sleepso the first iteration is never immediately fatal.One note on a tempting shortcut: replacing the
psprobe with bash's$PPIDdoes not preserve the current semantics. Bash fixes$PPIDat shell startup, so after the owner dies and the shell is reparented,$PPIDstill equalsowner_pidwhileps -o ppid=reports the new parent. Detecting that reparenting is the only thing thepscall buys; a$PPIDcomparison reduces tokill -0 "$owner_pid"and should be presented as such.Related
ExecuteSIGKILLs immediately while Read, Edit, Grep, and fire-and-forget work. Open, no root cause identified there.