Skip to content

Execute SIGKILLs every command when ps is unavailable: factory-execute-supervisor treats a failed liveness probe as owner death #33

Description

@joshwilhelmi

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:

  1. 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.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions