fix: inherit environment in daemon subprocess calls (#876)

The daemon's exec.Command calls were not explicitly setting cmd.Env,
causing subprocesses to fail when the daemon process doesn't have
the expected PATH environment variable. This manifests as:

  Warning: failed to fetch deacon inbox: exec: "gt": executable file not found in $PATH

When the daemon is started by mechanisms with minimal environments
(launchd, systemd, or shells without full PATH), executables like
gt, bd, git, and sqlite3 couldn't be found.

The fix adds cmd.Env = os.Environ() to all 15 subprocess calls across
three files, ensuring they inherit the daemon's full environment.

Affected commands:
- gt mail inbox/delete/send (lifecycle requests, notifications)
- bd sync/show/list/activity (beads operations)
- git fetch/pull (workspace pre-sync)
- sqlite3 (convoy completion queries)

Fixes #875

Co-authored-by: Jackson Cantrell <cantrelljax@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jackson Cantrell
2026-01-22 16:41:51 -08:00
committed by GitHub
parent 16d3a92455
commit 0fb3e8d5fe
3 changed files with 16 additions and 0 deletions
+5
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -87,6 +88,7 @@ func (w *ConvoyWatcher) run() {
func (w *ConvoyWatcher) watchActivity() error { func (w *ConvoyWatcher) watchActivity() error {
cmd := exec.CommandContext(w.ctx, "bd", "activity", "--follow", "--town", "--json") cmd := exec.CommandContext(w.ctx, "bd", "activity", "--follow", "--town", "--json")
cmd.Dir = w.townRoot cmd.Dir = w.townRoot
cmd.Env = os.Environ() // Inherit PATH to find bd executable
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
@@ -168,6 +170,7 @@ func (w *ConvoyWatcher) getTrackingConvoys(issueID string) []string {
`, safeIssueID, safeIssueID) `, safeIssueID, safeIssueID)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query) queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
queryCmd.Env = os.Environ() // Inherit PATH to find sqlite3 executable
var stdout bytes.Buffer var stdout bytes.Buffer
queryCmd.Stdout = &stdout queryCmd.Stdout = &stdout
@@ -200,6 +203,7 @@ func (w *ConvoyWatcher) checkConvoyCompletion(convoyID string) {
strings.ReplaceAll(convoyID, "'", "''")) strings.ReplaceAll(convoyID, "'", "''"))
queryCmd := exec.Command("sqlite3", "-json", dbPath, convoyQuery) queryCmd := exec.Command("sqlite3", "-json", dbPath, convoyQuery)
queryCmd.Env = os.Environ() // Inherit PATH to find sqlite3 executable
var stdout bytes.Buffer var stdout bytes.Buffer
queryCmd.Stdout = &stdout queryCmd.Stdout = &stdout
@@ -224,6 +228,7 @@ func (w *ConvoyWatcher) checkConvoyCompletion(convoyID string) {
checkCmd := exec.Command("gt", "convoy", "check", convoyID) checkCmd := exec.Command("gt", "convoy", "check", convoyID)
checkCmd.Dir = w.townRoot checkCmd.Dir = w.townRoot
checkCmd.Env = os.Environ() // Inherit PATH to find gt executable
var checkStdout, checkStderr bytes.Buffer var checkStdout, checkStderr bytes.Buffer
checkCmd.Stdout = &checkStdout checkCmd.Stdout = &checkStdout
checkCmd.Stderr = &checkStderr checkCmd.Stderr = &checkStderr
+1
View File
@@ -1101,6 +1101,7 @@ Manual intervention may be required.`,
cmd := exec.Command("gt", "mail", "send", witnessAddr, "-s", subject, "-m", body) //nolint:gosec // G204: args are constructed internally cmd := exec.Command("gt", "mail", "send", witnessAddr, "-s", subject, "-m", body) //nolint:gosec // G204: args are constructed internally
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ() // Inherit PATH to find gt executable
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
d.logger.Printf("Warning: failed to notify witness of crashed polecat: %v", err) d.logger.Printf("Warning: failed to notify witness of crashed polecat: %v", err)
} }
+10
View File
@@ -40,6 +40,7 @@ func (d *Daemon) ProcessLifecycleRequests() {
// Get mail for deacon identity (using gt mail, not bd mail) // Get mail for deacon identity (using gt mail, not bd mail)
cmd := exec.Command("gt", "mail", "inbox", "--identity", "deacon/", "--json") cmd := exec.Command("gt", "mail", "inbox", "--identity", "deacon/", "--json")
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ() // Inherit PATH to find gt executable
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
@@ -576,6 +577,7 @@ func (d *Daemon) syncWorkspace(workDir string) {
fetchCmd := exec.Command("git", "fetch", "origin") fetchCmd := exec.Command("git", "fetch", "origin")
fetchCmd.Dir = workDir fetchCmd.Dir = workDir
fetchCmd.Stderr = &stderr fetchCmd.Stderr = &stderr
fetchCmd.Env = os.Environ() // Inherit PATH to find git executable
if err := fetchCmd.Run(); err != nil { if err := fetchCmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String()) errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" { if errMsg == "" {
@@ -592,6 +594,7 @@ func (d *Daemon) syncWorkspace(workDir string) {
pullCmd := exec.Command("git", "pull", "--rebase", "origin", defaultBranch) pullCmd := exec.Command("git", "pull", "--rebase", "origin", defaultBranch)
pullCmd.Dir = workDir pullCmd.Dir = workDir
pullCmd.Stderr = &stderr pullCmd.Stderr = &stderr
pullCmd.Env = os.Environ() // Inherit PATH to find git executable
if err := pullCmd.Run(); err != nil { if err := pullCmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String()) errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" { if errMsg == "" {
@@ -608,6 +611,7 @@ func (d *Daemon) syncWorkspace(workDir string) {
bdCmd := exec.Command("bd", "sync") bdCmd := exec.Command("bd", "sync")
bdCmd.Dir = workDir bdCmd.Dir = workDir
bdCmd.Stderr = &stderr bdCmd.Stderr = &stderr
bdCmd.Env = os.Environ() // Inherit PATH to find bd executable
if err := bdCmd.Run(); err != nil { if err := bdCmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String()) errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" { if errMsg == "" {
@@ -625,6 +629,7 @@ func (d *Daemon) closeMessage(id string) error {
// Use gt mail delete to actually remove the message // Use gt mail delete to actually remove the message
cmd := exec.Command("gt", "mail", "delete", id) cmd := exec.Command("gt", "mail", "delete", id)
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ() // Inherit PATH to find gt executable
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@@ -662,6 +667,7 @@ func (d *Daemon) getAgentBeadState(agentBeadID string) (string, error) {
func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) { func (d *Daemon) getAgentBeadInfo(agentBeadID string) (*AgentBeadInfo, error) {
cmd := exec.Command("bd", "show", agentBeadID, "--json") cmd := exec.Command("bd", "show", agentBeadID, "--json")
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ() // Inherit PATH to find bd executable
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
@@ -799,6 +805,7 @@ func (d *Daemon) checkRigGUPPViolations(rigName string) {
// Pattern: <prefix>-<rig>-polecat-<name> (e.g., gt-gastown-polecat-Toast) // Pattern: <prefix>-<rig>-polecat-<name> (e.g., gt-gastown-polecat-Toast)
cmd := exec.Command("bd", "list", "--type=agent", "--json") cmd := exec.Command("bd", "list", "--type=agent", "--json")
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ() // Inherit PATH to find bd executable
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
@@ -874,6 +881,7 @@ Action needed: Check if agent is alive and responsive. Consider restarting if st
cmd := exec.Command("gt", "mail", "send", witnessAddr, "-s", subject, "-m", body) cmd := exec.Command("gt", "mail", "send", witnessAddr, "-s", subject, "-m", body)
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ() // Inherit PATH to find gt executable
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
d.logger.Printf("Warning: failed to notify witness of GUPP violation: %v", err) d.logger.Printf("Warning: failed to notify witness of GUPP violation: %v", err)
@@ -897,6 +905,7 @@ func (d *Daemon) checkOrphanedWork() {
func (d *Daemon) checkRigOrphanedWork(rigName string) { func (d *Daemon) checkRigOrphanedWork(rigName string) {
cmd := exec.Command("bd", "list", "--type=agent", "--json") cmd := exec.Command("bd", "list", "--type=agent", "--json")
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ() // Inherit PATH to find bd executable
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
@@ -970,6 +979,7 @@ Action needed: Either restart the agent or reassign the work.`,
cmd := exec.Command("gt", "mail", "send", witnessAddr, "-s", subject, "-m", body) cmd := exec.Command("gt", "mail", "send", witnessAddr, "-s", subject, "-m", body)
cmd.Dir = d.config.TownRoot cmd.Dir = d.config.TownRoot
cmd.Env = os.Environ() // Inherit PATH to find gt executable
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
d.logger.Printf("Warning: failed to notify witness of orphaned work: %v", err) d.logger.Printf("Warning: failed to notify witness of orphaned work: %v", err)