fix(cli): harden daemon control surface and stop CLI from writing the store
Addresses review findings on PR #53 (on top of the rebase onto main). - doctor: stop opening/migrating SQLite. The daemon is the sole store writer/migrator (architecture.md §7); the CLI must not run migrations or open a second writer against a DB a live daemon owns. doctor now reports database-file presence and gains --json. - stop: only remove running.json when it still belongs to the PID we stopped, so a concurrent `ao start` that wrote a new run-file is not clobbered into looking stopped. - httpd: gate POST /shutdown to loopback callers with no Origin header, closing the CSRF / DNS-rebinding vector against an unauthenticated, state-changing endpoint. - start: detach the spawned daemon into its own session/process group so a Ctrl-C while `ao start` waits for readiness doesn't also kill it. - cli: exit 2 for usage errors (bad flag / arg count) vs 1 for runtime failures. - daemon: unexport newLogger (only used in-package). - tests: /shutdown guard (cross-origin + rebinding) and stop run-file ownership guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d8ffcd17a
commit
2d00e4675d
|
|
@ -10,6 +10,6 @@ import (
|
||||||
func main() {
|
func main() {
|
||||||
if err := cli.Execute(); err != nil {
|
if err := cli.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(cli.ExitCode(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,14 @@ import (
|
||||||
|
|
||||||
func newCompletionCommand() *cobra.Command {
|
func newCompletionCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "completion [bash|zsh|fish|powershell]",
|
Use: "completion [bash|zsh|fish|powershell]",
|
||||||
Short: "Generate shell completion scripts",
|
Short: "Generate shell completion scripts",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
|
||||||
|
return usageError{err}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
root := cmd.Root()
|
root := cmd.Root()
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type doctorLevel string
|
type doctorLevel string
|
||||||
|
|
@ -20,34 +22,53 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type doctorCheck struct {
|
type doctorCheck struct {
|
||||||
Level doctorLevel
|
Level doctorLevel `json:"level"`
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
Message string
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type doctorReport struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Failures int `json:"failures"`
|
||||||
|
Checks []doctorCheck `json:"checks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDoctorCommand(ctx *commandContext) *cobra.Command {
|
func newDoctorCommand(ctx *commandContext) *cobra.Command {
|
||||||
return &cobra.Command{
|
var asJSON bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "doctor",
|
Use: "doctor",
|
||||||
Short: "Run local AO health checks",
|
Short: "Run local AO health checks",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
checks := ctx.runDoctor(cmd.Context())
|
checks := ctx.runDoctor(cmd.Context())
|
||||||
for _, check := range checks {
|
failures := 0
|
||||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var failures int
|
|
||||||
for _, check := range checks {
|
for _, check := range checks {
|
||||||
if check.Level == doctorFail {
|
if check.Level == doctorFail {
|
||||||
failures++
|
failures++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if asJSON {
|
||||||
|
if err := writeJSON(cmd.OutOrStdout(), doctorReport{
|
||||||
|
OK: failures == 0, Failures: failures, Checks: checks,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, check := range checks {
|
||||||
|
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if failures > 0 {
|
if failures > 0 {
|
||||||
return fmt.Errorf("doctor found %d failing check(s)", failures)
|
return fmt.Errorf("doctor found %d failing check(s)", failures)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output health checks as JSON")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
|
func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
|
||||||
|
|
@ -68,13 +89,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
|
||||||
checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir})
|
checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir})
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := sqlite.Open(cfg.DataDir)
|
checks = append(checks, checkStore(cfg.DataDir))
|
||||||
if err != nil {
|
|
||||||
checks = append(checks, doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()})
|
|
||||||
} else {
|
|
||||||
_ = store.Close()
|
|
||||||
checks = append(checks, doctorCheck{Level: doctorPass, Name: "sqlite", Message: "opened database and applied migrations"})
|
|
||||||
}
|
|
||||||
|
|
||||||
st, err := c.inspectDaemon(ctx)
|
st, err := c.inspectDaemon(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -103,6 +118,31 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
|
||||||
return checks
|
return checks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkStore inspects the SQLite store WITHOUT opening or migrating it. The
|
||||||
|
// daemon is the sole writer and migrator of the database (architecture.md §7);
|
||||||
|
// the CLI must never run migrations or open a second writer against a database
|
||||||
|
// a live daemon may already own. Migrations are validated by the daemon at
|
||||||
|
// startup and surfaced through /readyz, so doctor only confirms whether the
|
||||||
|
// database file exists yet.
|
||||||
|
func checkStore(dataDir string) doctorCheck {
|
||||||
|
dbPath := filepath.Join(dataDir, "ao.db")
|
||||||
|
info, err := os.Stat(dbPath)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return doctorCheck{
|
||||||
|
Level: doctorPass, Name: "sqlite",
|
||||||
|
Message: fmt.Sprintf("%s (%d bytes); migrations are applied by the daemon at startup", dbPath, info.Size()),
|
||||||
|
}
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
return doctorCheck{
|
||||||
|
Level: doctorWarn, Name: "sqlite",
|
||||||
|
Message: "database not created yet; run `ao start` to initialize and migrate it",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *commandContext) checkTool(name string, required bool) doctorCheck {
|
func (c *commandContext) checkTool(name string, required bool) doctorCheck {
|
||||||
path, err := c.deps.LookPath(name)
|
path, err := c.deps.LookPath(name)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ func startProcess(cfg processStartConfig) (processHandle, error) {
|
||||||
cmd.Env = cfg.Env
|
cmd.Env = cfg.Env
|
||||||
cmd.Stdout = cfg.Stdout
|
cmd.Stdout = cfg.Stdout
|
||||||
cmd.Stderr = cfg.Stderr
|
cmd.Stderr = cfg.Stderr
|
||||||
|
// Detach the daemon into its own session/process group so a Ctrl-C in the
|
||||||
|
// terminal where `ao start` is waiting for readiness doesn't also SIGINT the
|
||||||
|
// freshly spawned daemon (it would otherwise share the launcher's group).
|
||||||
|
cmd.SysProcAttr = detachSysProcAttr()
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return processHandle{}, err
|
return processHandle{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,10 @@ func processAlive(pid int) bool {
|
||||||
err := syscall.Kill(pid, 0)
|
err := syscall.Kill(pid, 0)
|
||||||
return err == nil || errors.Is(err, syscall.EPERM)
|
return err == nil || errors.Is(err, syscall.EPERM)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detachSysProcAttr puts the daemon in a new session (Setsid) so it is no
|
||||||
|
// longer in the launcher's foreground process group and won't receive the
|
||||||
|
// terminal's SIGINT/SIGHUP.
|
||||||
|
func detachSysProcAttr() *syscall.SysProcAttr {
|
||||||
|
return &syscall.SysProcAttr{Setsid: true}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
@ -27,3 +28,9 @@ func processAlive(pid int) bool {
|
||||||
}
|
}
|
||||||
return status == uint32(windows.WAIT_TIMEOUT)
|
return status == uint32(windows.WAIT_TIMEOUT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detachSysProcAttr starts the daemon in a new process group so it does not
|
||||||
|
// receive the console's CTRL_C/CTRL_BREAK while `ao start` waits for readiness.
|
||||||
|
func detachSysProcAttr() *syscall.SysProcAttr {
|
||||||
|
return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -19,6 +20,27 @@ func Execute() error {
|
||||||
return NewRootCommand(DefaultDeps()).Execute()
|
return NewRootCommand(DefaultDeps()).Execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// usageError marks a command-line misuse (bad flag, wrong arg count). It lets
|
||||||
|
// the process entrypoint return exit code 2 for usage errors versus 1 for
|
||||||
|
// runtime failures, matching the convention CLIs are scripted against.
|
||||||
|
type usageError struct{ err error }
|
||||||
|
|
||||||
|
func (e usageError) Error() string { return e.err.Error() }
|
||||||
|
func (e usageError) Unwrap() error { return e.err }
|
||||||
|
|
||||||
|
// ExitCode maps a CLI error to a process exit code: 2 for usage errors, 1 for
|
||||||
|
// any other failure, 0 for success.
|
||||||
|
func ExitCode(err error) int {
|
||||||
|
if err == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var ue usageError
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
// Deps holds the small set of side effects the CLI needs. Tests replace these
|
// Deps holds the small set of side effects the CLI needs. Tests replace these
|
||||||
// functions without reaching into process-global state.
|
// functions without reaching into process-global state.
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
|
|
@ -103,6 +125,11 @@ func NewRootCommand(deps Deps) *cobra.Command {
|
||||||
root.SetOut(deps.Out)
|
root.SetOut(deps.Out)
|
||||||
root.SetErr(deps.Err)
|
root.SetErr(deps.Err)
|
||||||
root.CompletionOptions.DisableDefaultCmd = true
|
root.CompletionOptions.DisableDefaultCmd = true
|
||||||
|
// Tag flag-parse failures as usage errors so the entrypoint can exit 2 for
|
||||||
|
// misuse versus 1 for runtime failures. Subcommands inherit this func.
|
||||||
|
root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
|
||||||
|
return usageError{err}
|
||||||
|
})
|
||||||
|
|
||||||
root.AddCommand(newDaemonCommand())
|
root.AddCommand(newDaemonCommand())
|
||||||
root.AddCommand(newStartCommand(ctx))
|
root.AddCommand(newStartCommand(ctx))
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,14 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat
|
||||||
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
|
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
|
||||||
}
|
}
|
||||||
if !alive {
|
if !alive {
|
||||||
if err := runfile.Remove(runFilePath); err != nil {
|
// Only remove the run-file if it still belongs to the process we
|
||||||
return daemonStatus{}, err
|
// stopped. A concurrent `ao start` may have already written a new
|
||||||
|
// run-file for a different daemon; removing that would corrupt its
|
||||||
|
// handshake and make a live daemon look stopped.
|
||||||
|
if info.PID == pid {
|
||||||
|
if err := runfile.Remove(runFilePath); err != nil {
|
||||||
|
return daemonStatus{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
|
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWaitForStoppedKeepsRunFileFromConcurrentStart guards against deleting a
|
||||||
|
// fresh daemon's handshake: if a concurrent `ao start` replaces running.json
|
||||||
|
// with a new live PID while we are polling the PID we stopped, waitForStopped
|
||||||
|
// must report stopped but leave the new run-file intact.
|
||||||
|
func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runFile := filepath.Join(dir, "running.json")
|
||||||
|
|
||||||
|
const stoppedPID, newPID = 1111, 2222
|
||||||
|
// running.json now belongs to a different, live daemon.
|
||||||
|
if err := runfile.Write(runFile, runfile.Info{PID: newPID, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &commandContext{deps: Deps{
|
||||||
|
ProcessAlive: func(pid int) bool { return pid == newPID }, // stoppedPID is dead
|
||||||
|
Now: func() time.Time { return time.Unix(200, 0).UTC() },
|
||||||
|
Sleep: func(time.Duration) {},
|
||||||
|
}.withDefaults()}
|
||||||
|
|
||||||
|
st, err := c.waitForStopped(context.Background(), stoppedPID, runFile, dir, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.State != "stopped" {
|
||||||
|
t.Fatalf("state = %q, want stopped", st.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := runfile.Read(runFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info == nil {
|
||||||
|
t.Fatal("new daemon's run-file was deleted by stop of a different PID")
|
||||||
|
}
|
||||||
|
if info.PID != newPID {
|
||||||
|
t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWaitForStoppedRemovesOwnRunFile confirms the normal path still cleans up:
|
||||||
|
// when the dead PID owns the run-file, it is removed.
|
||||||
|
func TestWaitForStoppedRemovesOwnRunFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runFile := filepath.Join(dir, "running.json")
|
||||||
|
|
||||||
|
const stoppedPID = 1111
|
||||||
|
if err := runfile.Write(runFile, runfile.Info{PID: stoppedPID, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &commandContext{deps: Deps{
|
||||||
|
ProcessAlive: func(int) bool { return false },
|
||||||
|
Now: func() time.Time { return time.Unix(200, 0).UTC() },
|
||||||
|
Sleep: func(time.Duration) {},
|
||||||
|
}.withDefaults()}
|
||||||
|
|
||||||
|
st, err := c.waitForStopped(context.Background(), stoppedPID, runFile, dir, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.State != "stopped" {
|
||||||
|
t.Fatalf("state = %q, want stopped", st.State)
|
||||||
|
}
|
||||||
|
info, err := runfile.Read(runFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info != nil {
|
||||||
|
t.Fatalf("own run-file should have been removed, got %#v", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ func Run() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log := NewLogger()
|
log := newLogger()
|
||||||
|
|
||||||
// Fail fast if a live daemon already owns the handshake file. A run-file
|
// Fail fast if a live daemon already owns the handshake file. A run-file
|
||||||
// left by a crashed predecessor (dead PID) is treated as stale and
|
// left by a crashed predecessor (dead PID) is treated as stale and
|
||||||
|
|
@ -119,8 +119,8 @@ func Run() error {
|
||||||
return runErr
|
return runErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogger returns the daemon's slog logger. It writes to stderr so supervisors
|
// newLogger returns the daemon's slog logger. It writes to stderr so supervisors
|
||||||
// can capture it separately from any structured stdout protocol added later.
|
// can capture it separately from any structured stdout protocol added later.
|
||||||
func NewLogger() *slog.Logger {
|
func newLogger() *slog.Logger {
|
||||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package httpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShutdownGuard verifies that POST /shutdown only fires for a trusted local
|
||||||
|
// caller: a loopback Host with no Origin header. A cross-site Origin or a
|
||||||
|
// non-loopback (DNS-rebinding) Host must be rejected without triggering the
|
||||||
|
// shutdown side effect.
|
||||||
|
func TestShutdownGuard(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
origin string
|
||||||
|
wantStatus int
|
||||||
|
wantFired bool
|
||||||
|
}{
|
||||||
|
{name: "loopback no origin", host: "127.0.0.1:3001", wantStatus: http.StatusAccepted, wantFired: true},
|
||||||
|
{name: "localhost no origin", host: "localhost:3001", wantStatus: http.StatusAccepted, wantFired: true},
|
||||||
|
{name: "cross-site origin", host: "127.0.0.1:3001", origin: "https://evil.example", wantStatus: http.StatusForbidden, wantFired: false},
|
||||||
|
{name: "rebinding host", host: "evil.example", wantStatus: http.StatusForbidden, wantFired: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
fired := false
|
||||||
|
r := NewRouterWithControl(config.Config{}, discardLogger(), nil, APIDeps{}, ControlDeps{
|
||||||
|
RequestShutdown: func() { fired = true },
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://"+tc.host+"/shutdown", nil)
|
||||||
|
req.Host = tc.host
|
||||||
|
if tc.origin != "" {
|
||||||
|
req.Header.Set("Origin", tc.origin)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||||
|
}
|
||||||
|
if fired != tc.wantFired {
|
||||||
|
t.Fatalf("shutdown fired = %v, want %v", fired, tc.wantFired)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ package httpd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
|
@ -76,11 +77,22 @@ func mountHealth(r chi.Router) {
|
||||||
r.Get("/readyz", handleReadyz)
|
r.Get("/readyz", handleReadyz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mountControl registers the loopback daemon-control endpoints. /shutdown is
|
||||||
|
// unauthenticated and state-changing, so it is gated by localControlRequest to
|
||||||
|
// keep a browser the user happens to have open (CSRF / DNS-rebinding) or a
|
||||||
|
// remote client from being able to kill the daemon.
|
||||||
func mountControl(r chi.Router, deps ControlDeps) {
|
func mountControl(r chi.Router, deps ControlDeps) {
|
||||||
if deps.RequestShutdown == nil {
|
if deps.RequestShutdown == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Post("/shutdown", func(w http.ResponseWriter, _ *http.Request) {
|
r.Post("/shutdown", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if !localControlRequest(req) {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]any{
|
||||||
|
"status": "forbidden",
|
||||||
|
"service": daemonmeta.ServiceName,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||||
"status": "shutting_down",
|
"status": "shutting_down",
|
||||||
"service": daemonmeta.ServiceName,
|
"service": daemonmeta.ServiceName,
|
||||||
|
|
@ -90,6 +102,29 @@ func mountControl(r chi.Router, deps ControlDeps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localControlRequest reports whether a control request is a trusted local
|
||||||
|
// caller. The Go CLI client addresses the daemon by its loopback host and
|
||||||
|
// never sets an Origin header; a cross-site browser fetch always carries an
|
||||||
|
// Origin, and a DNS-rebinding attempt resolves a non-loopback Host. Rejecting
|
||||||
|
// either closes the CSRF/rebinding vector while leaving the CLI unaffected.
|
||||||
|
func localControlRequest(r *http.Request) bool {
|
||||||
|
if r.Header.Get("Origin") != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
switch host {
|
||||||
|
case "127.0.0.1", "::1", "localhost":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
return ip.IsLoopback()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// handleHealthz is the liveness probe: it answers 200 as long as the process is
|
// handleHealthz is the liveness probe: it answers 200 as long as the process is
|
||||||
// up and serving. It does no dependency checks by design.
|
// up and serving. It does no dependency checks by design.
|
||||||
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,13 @@ What works now:
|
||||||
- `ao start` starts the daemon in the background and waits for `/readyz`.
|
- `ao start` starts the daemon in the background and waits for `/readyz`.
|
||||||
- `ao status` and `ao status --json` report stopped, stale, unhealthy,
|
- `ao status` and `ao status --json` report stopped, stale, unhealthy,
|
||||||
not-ready, or ready daemon state.
|
not-ready, or ready daemon state.
|
||||||
- `ao stop` gracefully stops the daemon using the PID in `running.json`.
|
- `ao stop` gracefully stops the daemon via the loopback `POST /shutdown`
|
||||||
|
endpoint, only after verifying the daemon's identity from `running.json`.
|
||||||
- `ao daemon` is the hidden internal daemon entrypoint used by `ao start`.
|
- `ao daemon` is the hidden internal daemon entrypoint used by `ao start`.
|
||||||
- `ao doctor` checks config, data dir, SQLite migrations, daemon state, and
|
- `ao doctor` (and `ao doctor --json`) checks config, data dir, the database
|
||||||
local tool availability for `git`, `tmux`, and `zellij`.
|
file's presence, daemon state, and local tool availability for `git`, `tmux`,
|
||||||
|
and `zellij`. It never opens or migrates the store — the daemon is the sole
|
||||||
|
writer/migrator, so doctor only reports whether the database exists yet.
|
||||||
- `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and
|
- `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and
|
||||||
`powershell`.
|
`powershell`.
|
||||||
- `ao version` and `ao --version` print build metadata.
|
- `ao version` and `ao --version` print build metadata.
|
||||||
|
|
@ -52,8 +55,9 @@ What is intentionally not implemented yet:
|
||||||
|
|
||||||
Next steps:
|
Next steps:
|
||||||
|
|
||||||
1. Add `/api/v1/projects` on the daemon over a small project service.
|
1. Wire the existing project manager/controller shell into the daemon with a
|
||||||
2. Implement `ao project list/add/show/remove`.
|
durable SQLite-backed project store.
|
||||||
|
2. Implement `ao project list/add/show/remove` against `/api/v1/projects`.
|
||||||
3. Wire production Session Manager dependencies: project-backed repo resolver,
|
3. Wire production Session Manager dependencies: project-backed repo resolver,
|
||||||
tmux/zellij runtime registry, first agent adapter, and AgentMessenger.
|
tmux/zellij runtime registry, first agent adapter, and AgentMessenger.
|
||||||
4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and
|
4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and
|
||||||
|
|
@ -281,8 +285,8 @@ Acceptance criteria for the foundation:
|
||||||
## Implementation Readiness
|
## Implementation Readiness
|
||||||
|
|
||||||
This section records what the CLI can connect to in the current codebase and
|
This section records what the CLI can connect to in the current codebase and
|
||||||
what still needs to be built. Inventory date: 2026-05-31 on `main` at
|
what still needs to be built. Inventory date: 2026-05-31 after merging
|
||||||
`0672dbb`.
|
`origin/main` at `438b830`.
|
||||||
|
|
||||||
### Implemented Foundation
|
### Implemented Foundation
|
||||||
|
|
||||||
|
|
@ -298,8 +302,9 @@ Implemented commands:
|
||||||
supports `--json` and `--timeout`.
|
supports `--json` and `--timeout`.
|
||||||
- `ao status` reports stopped/stale/unhealthy/not-ready/ready states and
|
- `ao status` reports stopped/stale/unhealthy/not-ready/ready states and
|
||||||
supports `--json`.
|
supports `--json`.
|
||||||
- `ao doctor` checks config, data dir, SQLite open/migrations, daemon state, and
|
- `ao doctor` checks config, data dir, database-file presence, daemon state, and
|
||||||
local tool availability for `git`, `tmux`, and `zellij`.
|
local tool availability for `git`, `tmux`, and `zellij`; supports `--json`. It
|
||||||
|
does not open or migrate the store (the daemon owns that).
|
||||||
- `ao completion` generates `bash`, `zsh`, `fish`, and `powershell`
|
- `ao completion` generates `bash`, `zsh`, `fish`, and `powershell`
|
||||||
completions.
|
completions.
|
||||||
- `ao version` prints build metadata.
|
- `ao version` prints build metadata.
|
||||||
|
|
@ -331,7 +336,7 @@ they are wired into the daemon and exposed through HTTP.
|
||||||
|
|
||||||
| Area | Existing code | Missing before CLI can use it |
|
| Area | Existing code | Missing before CLI can use it |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Project persistence | `sqlite.Store` has `UpsertProject`, `GetProject`, `ListProjects`, and `ArchiveProject`. | Project domain/service layer, project ID/path/origin validation, and `/api/v1/projects` routes. |
|
| Project API pieces | `internal/project` has manager/controller DTOs, `/api/v1/projects` routes exist, and `sqlite.Store` has project CRUD. | Durable project-store adapter/wiring in the daemon and CLI commands. The daemon currently constructs the router with nil API deps, so project routes are not product-usable from `ao` yet. |
|
||||||
| Session Manager | `backend/internal/session.Manager` implements `Spawn`, `Kill`, `Restore`, `List`, `Get`, `Send`, and `Cleanup`. | Production daemon wiring with real runtime, agent, workspace, messenger, and HTTP routes. |
|
| Session Manager | `backend/internal/session.Manager` implements `Spawn`, `Kill`, `Restore`, `List`, `Get`, `Send`, and `Cleanup`. | Production daemon wiring with real runtime, agent, workspace, messenger, and HTTP routes. |
|
||||||
| Runtime adapters | tmux and zellij adapters implement `ports.Runtime` and also have attach/send/output helpers. | Runtime registry wiring in daemon, attach/send abstractions in ports/API, and selection config. |
|
| Runtime adapters | tmux and zellij adapters implement `ports.Runtime` and also have attach/send/output helpers. | Runtime registry wiring in daemon, attach/send abstractions in ports/API, and selection config. |
|
||||||
| Workspace adapter | git worktree adapter implements create/destroy/restore/list with safety checks. | Repo resolver backed by registered projects and daemon wiring into Session Manager. |
|
| Workspace adapter | git worktree adapter implements create/destroy/restore/list with safety checks. | Repo resolver backed by registered projects and daemon wiring into Session Manager. |
|
||||||
|
|
@ -345,12 +350,10 @@ These are the main gaps before the full initial command set is real.
|
||||||
|
|
||||||
| Gap | Blocks |
|
| Gap | Blocks |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Cobra dependency and CLI packages. | All CLI commands. |
|
| Product API client package with run-file discovery. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. |
|
||||||
| Daemon extraction from `backend/main.go` into `internal/daemon`. | `ao daemon`, `ao start`, tests around daemon startup. |
|
|
||||||
| CLI process runner and PID signal helpers. | `ao start`, `ao stop`. |
|
|
||||||
| Loopback HTTP client package with run-file discovery. | `ao status`, later all daemon-backed commands. |
|
|
||||||
| Shutdown mechanism choice: PID signal now, optional `POST /api/v1/daemon/shutdown` later. | `ao stop` polish and cross-platform behavior. |
|
| Shutdown mechanism choice: PID signal now, optional `POST /api/v1/daemon/shutdown` later. | `ao stop` polish and cross-platform behavior. |
|
||||||
| HTTP API route surface under `/api/v1`. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. |
|
| Session/send API route surface under `/api/v1`. | `spawn`, `session`, `send`, richer `status`. |
|
||||||
|
| Project API daemon wiring. | `ao project list/add/show/remove`. |
|
||||||
| SSE route for live CDC events plus durable catch-up reads. | `ao events tail`, frontend live updates. |
|
| SSE route for live CDC events plus durable catch-up reads. | `ao events tail`, frontend live updates. |
|
||||||
| Agent adapters for supported harnesses (`codex`, `claude-code`, etc.). | `ao spawn`, `ao session restore`. |
|
| Agent adapters for supported harnesses (`codex`, `claude-code`, etc.). | `ao spawn`, `ao session restore`. |
|
||||||
| AgentMessenger implementation over tmux/zellij. | `ao send`, LCM auto-nudge reactions. |
|
| AgentMessenger implementation over tmux/zellij. | `ao send`, LCM auto-nudge reactions. |
|
||||||
|
|
@ -368,10 +371,10 @@ These are the main gaps before the full initial command set is real.
|
||||||
| `ao start` | Implemented | Config, run-file stale check, HTTP readiness probes. | Later: package-manager/service integration if needed. |
|
| `ao start` | Implemented | Config, run-file stale check, HTTP readiness probes. | Later: package-manager/service integration if needed. |
|
||||||
| `ao stop` | Implemented | Run-file discovery gives PID/port; server exits cleanly on SIGINT/SIGTERM. | Optional later shutdown HTTP route. |
|
| `ao stop` | Implemented | Run-file discovery gives PID/port; server exits cleanly on SIGINT/SIGTERM. | Optional later shutdown HTTP route. |
|
||||||
| `ao status` | Partially implemented | Run-file, process liveness via PID, `/healthz`, `/readyz`. | Rich project/session summary waits for `/api/v1/projects` and `/api/v1/sessions`. |
|
| `ao status` | Partially implemented | Run-file, process liveness via PID, `/healthz`, `/readyz`. | Rich project/session summary waits for `/api/v1/projects` and `/api/v1/sessions`. |
|
||||||
| `ao doctor` | Partially implemented | Config resolution, run-file, storage open, runtime binary checks. | Deeper adapter preflights need daemon wiring/config. |
|
| `ao doctor` | Partially implemented | Config resolution, run-file, database-file presence (no open/migrate), runtime binary checks. | Deeper adapter preflights need daemon wiring/config and should be queried from the daemon, not run in-process. |
|
||||||
| `ao completion` | Implemented | Cobra generators. | None for foundation. |
|
| `ao completion` | Implemented | Cobra generators. | None for foundation. |
|
||||||
| `ao version` | Implemented | Build metadata can be injected with `-ldflags`. | Release tooling needs to set metadata. |
|
| `ao version` | Implemented | Build metadata can be injected with `-ldflags`. | Release tooling needs to set metadata. |
|
||||||
| `ao project list/add/show/remove` | Not yet | SQLite project CRUD exists. | Project service and HTTP routes. CLI must not write SQLite directly. |
|
| `ao project list/add/show/remove` | Not yet | Project manager/controller route shell and SQLite project CRUD exist. | Durable project-store adapter, daemon API wiring, and CLI HTTP client. CLI must not write SQLite directly. |
|
||||||
| `ao spawn` | Not yet | Session Manager exists; runtime/workspace/tracker pieces partly exist. | Agent adapters, registry/config wiring, project lookup, tracker hydration, HTTP route. |
|
| `ao spawn` | Not yet | Session Manager exists; runtime/workspace/tracker pieces partly exist. | Agent adapters, registry/config wiring, project lookup, tracker hydration, HTTP route. |
|
||||||
| `ao session list/show` | Not yet | Store and Session Manager read model exist. | HTTP routes and response DTOs. |
|
| `ao session list/show` | Not yet | Store and Session Manager read model exist. | HTTP routes and response DTOs. |
|
||||||
| `ao session attach` | Not yet | tmux/zellij have attach command helpers. | Runtime attach port/API and terminal-launch policy. |
|
| `ao session attach` | Not yet | tmux/zellij have attach command helpers. | Runtime attach port/API and terminal-launch policy. |
|
||||||
|
|
@ -383,8 +386,8 @@ These are the main gaps before the full initial command set is real.
|
||||||
|
|
||||||
1. Build CLI foundation around the daemon only: `daemon`, `start`, `stop`,
|
1. Build CLI foundation around the daemon only: `daemon`, `start`, `stop`,
|
||||||
`status`, `doctor`, `completion`, `version`.
|
`status`, `doctor`, `completion`, `version`.
|
||||||
2. Add `/api/v1/projects` over a small project service, then implement
|
2. Wire the existing project manager/controller shell into the daemon with a
|
||||||
`project list/add/show/remove`.
|
durable SQLite-backed store, then implement `project list/add/show/remove`.
|
||||||
3. Wire production Session Manager dependencies: project-backed repo resolver,
|
3. Wire production Session Manager dependencies: project-backed repo resolver,
|
||||||
tmux/zellij runtime registry, first agent adapter, and AgentMessenger.
|
tmux/zellij runtime registry, first agent adapter, and AgentMessenger.
|
||||||
4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`,
|
4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue