Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/internal/daemon/lifecycle_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit
Messenger: messenger,
Lifecycle: lcm,
DataDir: cfg.DataDir,
RunFile: cfg.RunFilePath,
Logger: log,
})
scmProvider, err := newGitHubSCMProvider(log)
Expand Down
21 changes: 18 additions & 3 deletions backend/internal/session_manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ const (
EnvIssueID = "AO_ISSUE_ID"
// EnvDataDir tells a spawned agent's AO hook commands where the store lives.
EnvDataDir = "AO_DATA_DIR"
// EnvRunFile tells a spawned agent's AO hook commands where the daemon's
// running.json handshake lives, so hook delivery finds the SAME daemon that
// spawned the session even when AO runs with a non-default state location.
// Without it, `ao hooks` falls back to ~/.ao/running.json and every
// callback fails against a stale or missing default run file (observed
// 2026-07-04: all sessions no_signal, permission dialogs undetected).
EnvRunFile = "AO_RUN_FILE"
)

// hookBinaryName is the executable name the workspace hook commands invoke:
Expand Down Expand Up @@ -111,6 +118,7 @@ type Manager struct {
messenger ports.AgentMessenger
lcm lifecycleRecorder
dataDir string
runFile string
clock func() time.Time
// lookPath is exec.LookPath in production; tests substitute a stub so
// they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound
Expand All @@ -134,6 +142,9 @@ type Deps struct {
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook
// commands can open the same store.
DataDir string
// RunFile is exported to spawned agents as AO_RUN_FILE so their hook
// commands locate this daemon's running.json rather than the default.
RunFile string
Clock func() time.Time
// LookPath overrides exec.LookPath for the pre-launch agent-binary check.
// Production wiring leaves this nil and the manager defaults to
Expand All @@ -159,6 +170,7 @@ func New(d Deps) *Manager {
messenger: d.Messenger,
lcm: d.Lifecycle,
dataDir: d.DataDir,
runFile: d.RunFile,
clock: d.Clock,
lookPath: d.LookPath,
executable: d.Executable,
Expand Down Expand Up @@ -1088,15 +1100,18 @@ Keep branch names within your session's branch namespace so AO can track every P
// spawnEnv builds the runtime environment: the per-project env vars first, then
// the AO-internal vars last so they always win (a project cannot override
// AO_SESSION_ID and friends).
func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir string, projectEnv map[string]string) map[string]string {
env := make(map[string]string, len(projectEnv)+4)
func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, dataDir, runFile string, projectEnv map[string]string) map[string]string {
env := make(map[string]string, len(projectEnv)+5)
for k, v := range projectEnv {
env[k] = v
}
env[EnvSessionID] = string(id)
env[EnvProjectID] = string(project)
env[EnvIssueID] = string(issue)
env[EnvDataDir] = dataDir
if runFile != "" {
env[EnvRunFile] = runFile
}
return env
}

Expand All @@ -1108,7 +1123,7 @@ func spawnEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueI
// When the pin cannot be applied the inherited PATH is kept and a warning is
// logged so the degradation isn't silent.
func (m *Manager) runtimeEnv(id domain.SessionID, project domain.ProjectID, issue domain.IssueID, projectEnv map[string]string) map[string]string {
env := spawnEnv(id, project, issue, m.dataDir, projectEnv)
env := spawnEnv(id, project, issue, m.dataDir, m.runFile, projectEnv)
path, err := HookPATH(m.executable, os.Getenv, projectEnv)
if err != nil {
m.logger.Warn("session PATH not pinned to the daemon binary; `ao hooks` callbacks may resolve to a different ao and activity tracking will stall",
Expand Down
15 changes: 14 additions & 1 deletion backend/internal/session_manager/provision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import (
)

func TestSpawnEnvProjectVarsCannotOverrideInternal(t *testing.T) {
env := spawnEnv("mer-1", "mer", "issue-9", "/data", map[string]string{
env := spawnEnv("mer-1", "mer", "issue-9", "/data", "/state/running.json", map[string]string{
"FOO": "bar",
EnvSessionID: "hacked", // a project must not override AO-internal vars
EnvProjectID: "hacked",
EnvRunFile: "hacked",
})
if env["FOO"] != "bar" {
t.Fatalf("FOO = %q, want bar", env["FOO"])
Expand All @@ -25,6 +26,18 @@ func TestSpawnEnvProjectVarsCannotOverrideInternal(t *testing.T) {
if env[EnvProjectID] != "mer" {
t.Fatalf("AO_PROJECT_ID = %q, want mer (internal wins)", env[EnvProjectID])
}
if env[EnvRunFile] != "/state/running.json" {
t.Fatalf("AO_RUN_FILE = %q, want /state/running.json (internal wins; hook delivery must find THIS daemon)", env[EnvRunFile])
}
}

// A daemon with no resolved run-file path must not export an empty AO_RUN_FILE
// (empty would override a useful inherited value with garbage).
func TestSpawnEnvOmitsEmptyRunFile(t *testing.T) {
env := spawnEnv("mer-1", "mer", "", "/data", "", nil)
if _, ok := env[EnvRunFile]; ok {
t.Fatalf("AO_RUN_FILE exported despite empty run-file path")
}
}

func TestHookPATH(t *testing.T) {
Expand Down