mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
Update the Go module path, imports, install scripts, docs, workflows, packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm scope (already @twigpine/zero on main, with its @gitlawb/zero migration notes), LICENSE, action author, the OpenGateway provider and historical CHANGELOG links are unchanged. The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what AIMLAPI sees for attribution, so changing it needs a maintainer's yes. docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because every tagged release so far declares that module path. The Homebrew formula comment names the Twigpine/homebrew-tap tap. Co-authored-by: Claude Opus 5.5 <[email protected]>
499 lines
15 KiB
Go
499 lines
15 KiB
Go
package daemon
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Twigpine/zero/internal/observability"
|
|
"github.com/Twigpine/zero/internal/testutil"
|
|
)
|
|
|
|
func TestServeSupportsReadOnlyCustomRuntimeDirectory(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix permission compatibility regression")
|
|
}
|
|
dir, err := os.MkdirTemp("/tmp", "zero-serve-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
if err := os.Chmod(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
paths := Paths{
|
|
Socket: filepath.Join(dir, "daemon.sock"),
|
|
Lock: filepath.Join(dir, "daemon.lock"),
|
|
Status: filepath.Join(dir, "daemon.status"),
|
|
}
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
|
srv := newTestServerWithPaths(t, launcher, paths)
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- srv.Serve() }()
|
|
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
|
srv.Shutdown()
|
|
if err := <-serveErr; err != nil {
|
|
t.Fatalf("Serve with 0755 custom runtime directory: %v", err)
|
|
}
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := info.Mode().Perm(); got != 0o755 {
|
|
t.Fatalf("custom runtime directory permissions = %04o, want unchanged 0755", got)
|
|
}
|
|
}
|
|
|
|
func TestServeKeepsDefaultRootBoundAcrossCoordination(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
|
|
}
|
|
home, err := os.MkdirTemp("/tmp", "zero-root-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("XDG_RUNTIME_DIR", "")
|
|
paths, err := DefaultPaths()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := filepath.Dir(paths.Socket)
|
|
moved := filepath.Join(home, "bound-runtime")
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
|
srv := newTestServerWithPaths(t, launcher, paths)
|
|
srv.opts.beforeSocketBind = func() {
|
|
if err := os.Rename(dir, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = srv.Serve()
|
|
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
|
|
t.Fatalf("Serve error = %v, want replaced-runtime rejection", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
|
|
t.Fatalf("rooted lock was not created in the bound directory: %v", err)
|
|
}
|
|
for _, path := range []string{paths.Lock, paths.Socket, paths.Status} {
|
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("substitute runtime entry %s was touched: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeRemovesSocketBoundAfterFinalRuntimeSwap(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
|
|
}
|
|
home, err := os.MkdirTemp("/tmp", "zero-bind-swap-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("XDG_RUNTIME_DIR", "")
|
|
paths, err := DefaultPaths()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := filepath.Dir(paths.Socket)
|
|
moved := filepath.Join(home, "trusted-runtime")
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
|
srv := newTestServerWithPaths(t, launcher, paths)
|
|
srv.opts.afterSocketPreflight = func() {
|
|
if err := os.Rename(dir, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
err = srv.Serve()
|
|
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
|
|
t.Fatalf("Serve error = %v, want post-bind runtime replacement rejection", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
|
|
t.Fatalf("rooted lock was not retained in the trusted directory: %v", err)
|
|
}
|
|
if _, err := os.Lstat(paths.Socket); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("substitute runtime retained stale socket: %v", err)
|
|
}
|
|
listener, err := net.Listen("unix", paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("substitute runtime remained unbindable after rollback: %v", err)
|
|
}
|
|
if err := listener.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestOpenRuntimeLogHardensDefaultRootBeforeOpen(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows DACL hardening has platform-specific coverage")
|
|
}
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("XDG_RUNTIME_DIR", "")
|
|
paths, err := DefaultPaths()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := filepath.Dir(paths.Socket)
|
|
if err := os.MkdirAll(dir, 0o777); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(dir, 0o777); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file, logPath, err := OpenRuntimeLog(paths)
|
|
if err != nil {
|
|
t.Fatalf("OpenRuntimeLog: %v", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if logPath != filepath.Join(dir, "daemon.log") {
|
|
t.Fatalf("log path = %q", logPath)
|
|
}
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := info.Mode().Perm(); got&0o077 != 0 {
|
|
t.Fatalf("default runtime directory permissions = %04o, want owner-only", got)
|
|
}
|
|
}
|
|
|
|
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
secureStatusTestDir(t, dir)
|
|
paths := Paths{
|
|
Socket: filepath.Join(dir, "d.sock"),
|
|
Lock: filepath.Join(dir, "d.lock"),
|
|
Status: filepath.Join(dir, "d.status"),
|
|
}
|
|
return newTestServerWithPaths(t, launcher, paths), paths
|
|
}
|
|
|
|
func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Server {
|
|
t.Helper()
|
|
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("NewPool: %v", err)
|
|
}
|
|
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
|
|
if err != nil {
|
|
t.Fatalf("NewSessionManager: %v", err)
|
|
}
|
|
srv, err := NewServer(ServerOptions{Paths: paths, Manager: mgr, Pool: pool})
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
return srv
|
|
}
|
|
|
|
func TestServerEndToEnd(t *testing.T) {
|
|
out := []string{`{"type":"event","seq":1}`, `{"type":"event","seq":2}`}
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: out, exitCode: 0})
|
|
srv, paths := newTestServer(t, launcher)
|
|
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- srv.Serve() }()
|
|
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
|
|
|
// --- run a session and collect its stream-json output ---
|
|
runClient, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial(run): %v", err)
|
|
}
|
|
var got []string
|
|
if err := runClient.Run("s1", "", "hello", nil, func(line string) { got = append(got, line) }); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
runClient.Close()
|
|
if len(got) != 2 || got[0] != out[0] || got[1] != out[1] {
|
|
t.Fatalf("streamed lines = %v, want %v", got, out)
|
|
}
|
|
|
|
// --- status reflects the session ---
|
|
statusClient, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial(status): %v", err)
|
|
}
|
|
report, err := statusClient.Status()
|
|
statusClient.Close()
|
|
if err != nil {
|
|
t.Fatalf("Status: %v", err)
|
|
}
|
|
if report.PoolSize != 2 {
|
|
t.Fatalf("status PoolSize = %d, want 2", report.PoolSize)
|
|
}
|
|
foundSession := false
|
|
for _, s := range report.Sessions {
|
|
if s.ID == "s1" {
|
|
foundSession = true
|
|
if s.State != string(SessionDone) {
|
|
t.Fatalf("session s1 state = %s, want done", s.State)
|
|
}
|
|
}
|
|
}
|
|
if !foundSession {
|
|
t.Fatalf("status missing session s1: %+v", report.Sessions)
|
|
}
|
|
|
|
// --- attach to the finished session sees the buffered history ---
|
|
attachClient, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial(attach): %v", err)
|
|
}
|
|
var attached []string
|
|
if err := attachClient.Attach("s1", func(line string) { attached = append(attached, line) }); err != nil {
|
|
t.Fatalf("Attach: %v", err)
|
|
}
|
|
attachClient.Close()
|
|
if len(attached) != 2 {
|
|
t.Fatalf("attach lines = %v, want 2 buffered", attached)
|
|
}
|
|
|
|
// --- graceful stop, daemon cleans up ---
|
|
stopClient, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial(stop): %v", err)
|
|
}
|
|
if err := stopClient.Shutdown(); err != nil {
|
|
t.Fatalf("Shutdown: %v", err)
|
|
}
|
|
stopClient.Close()
|
|
|
|
select {
|
|
case err := <-serveErr:
|
|
if err != nil {
|
|
t.Fatalf("Serve returned error: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Serve did not return after shutdown")
|
|
}
|
|
// Runtime endpoints are removed on exit. The advisory lock file remains as
|
|
// a stable inode, but the kernel lock on it has been released.
|
|
for _, p := range []string{paths.Socket, paths.Status} {
|
|
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
|
|
}
|
|
}
|
|
if _, err := os.Stat(paths.Lock); err != nil {
|
|
t.Fatalf("stable lock file missing after shutdown: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServePreservesPreviousStatusWhenReplacementFails(t *testing.T) {
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
|
|
dir, err := os.MkdirTemp("", "zero-daemon-status-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
secureStatusTestDir(t, dir)
|
|
paths := Paths{Socket: filepath.Join(dir, "d.sock"), Lock: filepath.Join(dir, "d.lock"), Status: filepath.Join(dir, "d.status")}
|
|
srv := newTestServerWithPaths(t, launcher, paths)
|
|
previous := []byte(`{"pid":7,"socket":"previous","version":1}`)
|
|
if err := os.WriteFile(paths.Status, previous, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.opts.replaceStatusFile = func(*os.Root, string, string) error {
|
|
return errors.New("injected replacement failure")
|
|
}
|
|
|
|
if err := srv.Serve(); err == nil || !strings.Contains(err.Error(), "injected replacement failure") {
|
|
t.Fatal("Serve succeeded despite injected status replacement failure")
|
|
}
|
|
got, err := os.ReadFile(paths.Status)
|
|
if err != nil {
|
|
t.Fatalf("previous status was removed after failed publication: %v", err)
|
|
}
|
|
if string(got) != string(previous) {
|
|
t.Fatalf("previous status changed after failed publication: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestServeCleansStatusThroughBoundDirectoryAfterSwap(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows does not permit renaming the directory containing the live socket")
|
|
}
|
|
parent, err := os.MkdirTemp("", "zero-daemon-swap-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(parent) })
|
|
dir := filepath.Join(parent, "live")
|
|
movedDir := filepath.Join(parent, "moved")
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secureStatusTestDir(t, dir)
|
|
paths := Paths{
|
|
Socket: filepath.Join(dir, "d.sock"),
|
|
Lock: filepath.Join(dir, "d.lock"),
|
|
Status: filepath.Join(dir, "d.status"),
|
|
}
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
|
|
srv := newTestServerWithPaths(t, launcher, paths)
|
|
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
|
|
srv.opts.beforeStatusReplace = func() {
|
|
if err := os.Rename(dir, movedDir); err != nil {
|
|
t.Fatalf("move status directory: %v", err)
|
|
}
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
t.Fatalf("create substitute directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(paths.Status, substitute, 0o600); err != nil {
|
|
t.Fatalf("write substitute status: %v", err)
|
|
}
|
|
}
|
|
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- srv.Serve() }()
|
|
movedStatus := filepath.Join(movedDir, "d.status")
|
|
testutil.WaitFor(t, "file "+movedStatus, func() bool { _, err := os.Stat(movedStatus); return err == nil })
|
|
srv.Shutdown()
|
|
select {
|
|
case err := <-serveErr:
|
|
if err != nil {
|
|
t.Fatalf("Serve: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Serve did not return after shutdown")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(movedDir, "d.status")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("bound status survived cleanup: %v", err)
|
|
}
|
|
got, err := os.ReadFile(paths.Status)
|
|
if err != nil {
|
|
t.Fatalf("substitute status was removed by pathname cleanup: %v", err)
|
|
}
|
|
if string(got) != string(substitute) {
|
|
t.Fatalf("substitute status changed during cleanup: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestServerPublishesDefaultStatusAfterCrashReportCreatesRuntimeDirectory(t *testing.T) {
|
|
home, err := os.MkdirTemp("", "zero-home-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("XDG_RUNTIME_DIR", "")
|
|
|
|
if _, err := observability.WriteCrashReport(
|
|
observability.DefaultCrashDir(),
|
|
"cli",
|
|
"boom",
|
|
[]byte("stack"),
|
|
time.Now(),
|
|
); err != nil {
|
|
t.Fatalf("WriteCrashReport: %v", err)
|
|
}
|
|
paths, err := DefaultPaths()
|
|
if err != nil {
|
|
t.Fatalf("DefaultPaths: %v", err)
|
|
}
|
|
if paths.Status != filepath.Join(home, ".zero", "daemon.status") {
|
|
t.Fatalf("default status path = %q, want path beneath temporary home", paths.Status)
|
|
}
|
|
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
|
srv := newTestServerWithPaths(t, launcher, paths)
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- srv.Serve() }()
|
|
|
|
deadline := time.NewTimer(3 * time.Second)
|
|
defer deadline.Stop()
|
|
for {
|
|
if _, err := os.Stat(paths.Status); err == nil {
|
|
break
|
|
}
|
|
select {
|
|
case err := <-serveErr:
|
|
t.Fatalf("Serve returned before publishing status: %v", err)
|
|
case <-deadline.C:
|
|
t.Fatal("daemon did not publish its default status file")
|
|
case <-time.After(2 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
srv.Shutdown()
|
|
select {
|
|
case err := <-serveErr:
|
|
if err != nil {
|
|
t.Fatalf("Serve returned error: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Serve did not return after shutdown")
|
|
}
|
|
}
|
|
|
|
func TestServerSecondInstanceFails(t *testing.T) {
|
|
block := make(chan struct{})
|
|
defer close(block)
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
|
srv1, paths := newTestServer(t, launcher)
|
|
|
|
serveErr := make(chan error, 1)
|
|
go func() { serveErr <- srv1.Serve() }()
|
|
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
|
|
|
// A second server on the same paths must refuse to start (single instance).
|
|
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
|
mgr2, _ := NewSessionManager(SessionManagerOptions{Pool: pool2})
|
|
srv2, _ := NewServer(ServerOptions{Paths: paths, Manager: mgr2, Pool: pool2})
|
|
if err := srv2.Serve(); !errors.Is(err, ErrAlreadyRunning) {
|
|
t.Fatalf("second Serve err = %v, want ErrAlreadyRunning", err)
|
|
}
|
|
|
|
srv1.Shutdown()
|
|
<-serveErr
|
|
}
|
|
|
|
func TestServerRejectsUnknownCommand(t *testing.T) {
|
|
block := make(chan struct{})
|
|
defer close(block)
|
|
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
|
srv, paths := newTestServer(t, launcher)
|
|
go func() { _ = srv.Serve() }()
|
|
defer srv.Shutdown()
|
|
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
|
|
|
client, err := Dial(paths.Socket)
|
|
if err != nil {
|
|
t.Fatalf("Dial: %v", err)
|
|
}
|
|
defer client.Close()
|
|
// Run with an empty session id is rejected with an error frame.
|
|
err = client.Run("", "", "hi", nil, nil)
|
|
if err == nil {
|
|
t.Fatal("run with empty session id must return an error")
|
|
}
|
|
}
|