From 9348bc60b5d76aa8f49a220508b59e176ef2a7ba Mon Sep 17 00:00:00 2001 From: Aaron Jacobs Date: Thu, 26 Mar 2026 17:05:52 -0400 Subject: [PATCH 1/7] Add support for systemd socket activation. This commit facilitates running roborev as a socket-activated systemd user service. When LISTEN_FDS is set (handled by go-systemd internals), the daemon uses the systemd-provided socket instead of creating its own, enabling on-demand startup and lifecycle management driven by systemd. We enforce that socket activation is used only with loopback TCP listeners and unix sockets with safe permissions. We also avoid cleaning up the socket file in this mode, since we don't own it. Closes #569. Signed-off-by: Aaron Jacobs --- go.mod | 1 + go.sum | 2 + internal/daemon/runtime.go | 7 +- internal/daemon/server.go | 183 +++++++++++++++++++++++++------------ 4 files changed, 131 insertions(+), 62 deletions(-) diff --git a/go.mod b/go.mod index 43af4000e..23d363cf0 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/go.sum b/go.sum index 71a4a896f..858bf9682 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ= github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/daemon/runtime.go b/internal/daemon/runtime.go index c814548ed..b4479278e 100644 --- a/internal/daemon/runtime.go +++ b/internal/daemon/runtime.go @@ -435,7 +435,7 @@ func KillDaemon(info *RuntimeInfo) bool { // CleanupZombieDaemons finds and kills all unresponsive daemons. // Returns the number of zombies cleaned up. -func CleanupZombieDaemons() int { +func CleanupZombieDaemons(target DaemonEndpoint) int { runtimes, err := ListAllRuntimes() if err != nil { return 0 @@ -448,7 +448,10 @@ func CleanupZombieDaemons() int { // For Unix sockets, check PID liveness first to avoid slow HTTP probes // against sockets whose owner process is already dead. if ep.IsUnix() && info.PID > 0 && !isProcessAlive(info.PID) { - os.Remove(ep.Address) + if ep.Address != target.Address { + // Clean up non-matching sockets. + os.Remove(ep.Address) + } if info.SourcePath != "" { os.Remove(info.SourcePath) } else { diff --git a/internal/daemon/server.go b/internal/daemon/server.go index eaa7f928b..c15c6ac17 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -17,6 +17,8 @@ import ( "sync" "time" + "github.com/coreos/go-systemd/v22/activation" + "github.com/roborev-dev/roborev/internal/agent" "github.com/roborev-dev/roborev/internal/config" "github.com/roborev-dev/roborev/internal/git" @@ -27,19 +29,20 @@ import ( // Server is the HTTP API server for the daemon type Server struct { - db *storage.DB - configWatcher *ConfigWatcher - broadcaster Broadcaster - workerPool *WorkerPool - httpServer *http.Server - syncWorker *storage.SyncWorker - ciPoller *CIPoller - hookRunner *HookRunner - errorLog *ErrorLog - activityLog *ActivityLog - startTime time.Time - endpointMu sync.Mutex // protects endpoint (written by Start, read by Stop) - endpoint DaemonEndpoint + db *storage.DB + configWatcher *ConfigWatcher + broadcaster Broadcaster + workerPool *WorkerPool + httpServer *http.Server + syncWorker *storage.SyncWorker + ciPoller *CIPoller + hookRunner *HookRunner + errorLog *ErrorLog + activityLog *ActivityLog + startTime time.Time + endpointMu sync.Mutex // protects endpoint (written by Start, read by Stop) + endpoint DaemonEndpoint + socketActivated bool // true if started via systemd socket activation // Cached machine ID to avoid INSERT on every status request machineIDMu sync.Mutex @@ -124,13 +127,23 @@ func NewServer(db *storage.DB, cfg *config.Config, configPath string) *Server { func (s *Server) Start(ctx context.Context) error { cfg := s.configWatcher.Config() - ep, err := ParseEndpoint(cfg.ServerAddr) + // Check for socket activation before falling back to the config + listener, ep, err := getSystemdListener() if err != nil { return err } + if listener != nil { + s.socketActivated = true + log.Printf("Using systemd socket activation on %s", ep) + } else { + ep, err = ParseEndpoint(cfg.ServerAddr) + if err != nil { + return err + } + } // Clean up any zombie daemons first (there can be only one) - if cleaned := CleanupZombieDaemons(); cleaned > 0 { + if cleaned := CleanupZombieDaemons(ep); cleaned > 0 { log.Printf("Cleaned up %d zombie daemon(s)", cleaned) if s.activityLog != nil { s.activityLog.Log( @@ -143,6 +156,9 @@ func (s *Server) Start(ctx context.Context) error { // Check if a responsive daemon is still running after cleanup if info, err := GetAnyRunningDaemon(); err == nil && IsDaemonAlive(info.Endpoint()) { + if listener != nil { + _ = listener.Close() + } return fmt.Errorf("daemon already running (pid %d on %s)", info.PID, info.Addr) } @@ -157,53 +173,54 @@ func (s *Server) Start(ctx context.Context) error { // Continue without hot-reloading - not a fatal error } - // Bind the listener before publishing runtime metadata so concurrent CLI - // invocations cannot race a half-started daemon and kill it as a zombie. - var listener net.Listener - if ep.IsUnix() { - socketPath := ep.Address - socketDir := filepath.Dir(socketPath) - if err := os.MkdirAll(socketDir, 0700); err != nil { - s.configWatcher.Stop() - return fmt.Errorf("create socket directory: %w", err) - } - // Verify the parent directory has safe permissions (owner-only) - if fi, err := os.Stat(socketDir); err == nil { - if perm := fi.Mode().Perm(); perm&0077 != 0 { + if !s.socketActivated { + // Bind the listener before publishing runtime metadata so concurrent CLI + // invocations cannot race a half-started daemon and kill it as a zombie. + if ep.IsUnix() { + socketPath := ep.Address + socketDir := filepath.Dir(socketPath) + if err := os.MkdirAll(socketDir, 0700); err != nil { s.configWatcher.Stop() - return fmt.Errorf("socket directory %s has unsafe permissions %o (must not be group/world accessible)", socketDir, perm) + return fmt.Errorf("create socket directory: %w", err) } - } - // Remove stale socket from a previous run - os.Remove(socketPath) - listener, err = ep.Listener() - if err != nil { - s.configWatcher.Stop() - return fmt.Errorf("listen on %s: %w", ep, err) - } - if err := os.Chmod(socketPath, 0600); err != nil { - _ = listener.Close() - s.configWatcher.Stop() - return fmt.Errorf("chmod socket: %w", err) - } - } else { - // TCP: find an available port first - addr, _, err := FindAvailablePort(ep.Address) - if err != nil { - s.configWatcher.Stop() - return fmt.Errorf("find available port: %w", err) - } - ep = DaemonEndpoint{Network: "tcp", Address: addr} - s.httpServer.Addr = addr + // Verify the parent directory has safe permissions (owner-only) + if fi, err := os.Stat(socketDir); err == nil { + if perm := fi.Mode().Perm(); perm&0077 != 0 { + s.configWatcher.Stop() + return fmt.Errorf("socket directory %s has unsafe permissions %o (must not be group/world accessible)", socketDir, perm) + } + } + // Remove stale socket from a previous run + os.Remove(socketPath) + listener, err = ep.Listener() + if err != nil { + s.configWatcher.Stop() + return fmt.Errorf("listen on %s: %w", ep, err) + } + if err := os.Chmod(socketPath, 0600); err != nil { + _ = listener.Close() + s.configWatcher.Stop() + return fmt.Errorf("chmod socket: %w", err) + } + } else { + // TCP: find an available port first + addr, _, err := FindAvailablePort(ep.Address) + if err != nil { + s.configWatcher.Stop() + return fmt.Errorf("find available port: %w", err) + } + ep = DaemonEndpoint{Network: "tcp", Address: addr} + s.httpServer.Addr = addr - listener, err = ep.Listener() - if err != nil { - s.configWatcher.Stop() - return fmt.Errorf("listen on %s: %w", ep, err) + listener, err = ep.Listener() + if err != nil { + s.configWatcher.Stop() + return fmt.Errorf("listen on %s: %w", ep, err) + } + // Update ep with actual bound address + ep = DaemonEndpoint{Network: "tcp", Address: listener.Addr().String()} + s.httpServer.Addr = ep.Address } - // Update ep with actual bound address - ep = DaemonEndpoint{Network: "tcp", Address: listener.Addr().String()} - s.httpServer.Addr = ep.Address } s.endpointMu.Lock() @@ -342,6 +359,52 @@ func logHookWarnings(repos []storage.Repo) { } } +// getSystemdListener returns the listener and endpoint passed by systemd during +// socket activation, or (nil, empty, nil) if not running under socket activation. +// Validates the listener matches the daemon's local-only trust model. +func getSystemdListener() (net.Listener, DaemonEndpoint, error) { + listeners, err := activation.Listeners() + if err != nil { + return nil, DaemonEndpoint{}, fmt.Errorf("socket activation: %w", err) + } + if len(listeners) == 0 { + return nil, DaemonEndpoint{}, nil + } + if len(listeners) > 1 { + return nil, DaemonEndpoint{}, fmt.Errorf( + "socket activation: multiple sockets not supported") + } + + listener := listeners[0] + addr := listener.Addr().String() + if listener.Addr().Network() == "unix" { + addr = "unix://" + addr + } + ep, err := ParseEndpoint(addr) + if err != nil { + // Errors on non-localhost, etc. + _ = listener.Close() + return nil, ep, err + } + + // Ensure that Unix sockets have safe permissions. + if ep.IsUnix() { + fi, err := os.Stat(ep.Address) + if err != nil { + _ = listener.Close() + return nil, ep, fmt.Errorf("socket activation: %w", err) + } + if perm := fi.Mode().Perm(); perm&0077 != 0 { + _ = listener.Close() + return nil, ep, fmt.Errorf( + "socket activation: socket %q has unsafe permissions: %04o", + ep.Address, perm) + } + } + + return listener, ep, nil +} + // Stop gracefully shuts down the server func (s *Server) Stop() error { // Log daemon stop before shutting down components @@ -368,11 +431,11 @@ func (s *Server) Stop() error { log.Printf("HTTP server shutdown error: %v", err) } - // Clean up Unix domain socket + // Clean up Unix domain socket (if we created it) s.endpointMu.Lock() ep := s.endpoint s.endpointMu.Unlock() - if ep.IsUnix() { + if ep.IsUnix() && !s.socketActivated { os.Remove(ep.Address) } From addeb3409f9c738eba5234a1926e3413ec962d65 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 29 Mar 2026 11:23:56 -0500 Subject: [PATCH 2/7] Fix zombie cleanup removing systemd-managed sockets, add sd_notify CleanupZombieDaemons previously called KillDaemon for unresponsive zombies, which unconditionally unlinked Unix sockets. When the zombie's socket matched the systemd-managed target, this broke future connects. Now we kill the process and clean up the runtime file while preserving the socket. Also add daemon.SdNotify(READY=1) so Type=notify systemd units work correctly, and fix go-systemd dependency classification (direct, not indirect). Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 2 +- internal/daemon/runtime.go | 17 +++++++++++++++-- internal/daemon/server.go | 5 +++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 23d363cf0..1f1cbf828 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/ansi v0.11.6 github.com/coder/acp-go-sdk v0.6.3 + github.com/coreos/go-systemd/v22 v22.7.0 github.com/fsnotify/fsnotify v1.9.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 @@ -37,7 +38,6 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/internal/daemon/runtime.go b/internal/daemon/runtime.go index b4479278e..1b0216448 100644 --- a/internal/daemon/runtime.go +++ b/internal/daemon/runtime.go @@ -466,8 +466,21 @@ func CleanupZombieDaemons(target DaemonEndpoint) int { continue } - // Unresponsive - try to kill it - if KillDaemon(info) { + // Unresponsive — try to kill it. When the zombie's + // socket matches the target (e.g. a systemd-managed + // socket we're about to serve on), kill the process + // and clean up the runtime file but preserve the socket. + if ep.IsUnix() && ep.Address == target.Address { + if info.PID > 0 { + killProcess(info.PID) + } + if info.SourcePath != "" { + os.Remove(info.SourcePath) + } else if info.PID > 0 { + RemoveRuntimeForPID(info.PID) + } + cleaned++ + } else if KillDaemon(info) { cleaned++ } } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index c15c6ac17..760c216ab 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -18,6 +18,7 @@ import ( "time" "github.com/coreos/go-systemd/v22/activation" + "github.com/coreos/go-systemd/v22/daemon" "github.com/roborev-dev/roborev/internal/agent" "github.com/roborev-dev/roborev/internal/config" @@ -257,6 +258,10 @@ func (s *Server) Start(ctx context.Context) error { log.Printf("Warning: failed to write runtime info: %v", err) } + // Notify systemd that the daemon is ready. No-op when not running + // under systemd (NOTIFY_SOCKET is unset). + _, _ = daemon.SdNotify(false, daemon.SdNotifyReady) + // Log daemon start after runtime publication. if s.activityLog != nil { binary, _ := os.Executable() From fe316a9d38cf0a28bc6ba2b71bf5647e00bed92c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 29 Mar 2026 11:28:36 -0500 Subject: [PATCH 3/7] Fix zombie cleanup proceeding after failed kill, nil listener check When killing a zombie whose socket matches the systemd target, verify the process is actually gone before removing runtime metadata. If the kill fails, leave the runtime file so the next startup attempt can retry. Also guard against nil listener entries from activation.Listeners(), which returns nil for unsupported socket types (e.g. UDP FDs). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/daemon/runtime.go | 5 +++++ internal/daemon/server.go | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/internal/daemon/runtime.go b/internal/daemon/runtime.go index 1b0216448..94a9d13d9 100644 --- a/internal/daemon/runtime.go +++ b/internal/daemon/runtime.go @@ -473,6 +473,11 @@ func CleanupZombieDaemons(target DaemonEndpoint) int { if ep.IsUnix() && ep.Address == target.Address { if info.PID > 0 { killProcess(info.PID) + if isProcessAlive(info.PID) { + // Could not confirm kill; leave runtime + // metadata so the next attempt can retry. + continue + } } if info.SourcePath != "" { os.Remove(info.SourcePath) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 760c216ab..8f8063cdf 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -381,6 +381,10 @@ func getSystemdListener() (net.Listener, DaemonEndpoint, error) { } listener := listeners[0] + if listener == nil { + return nil, DaemonEndpoint{}, fmt.Errorf( + "socket activation: unsupported socket type") + } addr := listener.Addr().String() if listener.Addr().Network() == "unix" { addr = "unix://" + addr From 6df13280a08e9951843a366149877a5b7f452ee6 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 29 Mar 2026 15:43:00 -0500 Subject: [PATCH 4/7] Use killProcess return value for zombie cleanup gate killProcess returns true when the PID has been reused by a non-roborev process, since the original daemon is gone. The previous isProcessAlive check would see the reused process as alive and leave stale runtime metadata behind, blocking startup on the systemd-managed socket. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/daemon/runtime.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/daemon/runtime.go b/internal/daemon/runtime.go index 94a9d13d9..42d68b1e0 100644 --- a/internal/daemon/runtime.go +++ b/internal/daemon/runtime.go @@ -471,13 +471,10 @@ func CleanupZombieDaemons(target DaemonEndpoint) int { // socket we're about to serve on), kill the process // and clean up the runtime file but preserve the socket. if ep.IsUnix() && ep.Address == target.Address { - if info.PID > 0 { - killProcess(info.PID) - if isProcessAlive(info.PID) { - // Could not confirm kill; leave runtime - // metadata so the next attempt can retry. - continue - } + if info.PID > 0 && !killProcess(info.PID) { + // Could not confirm kill; leave runtime + // metadata so the next attempt can retry. + continue } if info.SourcePath != "" { os.Remove(info.SourcePath) From 3481423b71b2e513586fc07f2008c77081187817 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 29 Mar 2026 15:46:45 -0500 Subject: [PATCH 5/7] Add regression test for zombie cleanup preserving target socket Exercises the CleanupZombieDaemons target-socket path where killProcess returns true because the PID was reused by a non-roborev process. Verifies the runtime file is removed while the socket is preserved. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/daemon/runtime_test.go | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/internal/daemon/runtime_test.go b/internal/daemon/runtime_test.go index 0e52ba605..b0e38e435 100644 --- a/internal/daemon/runtime_test.go +++ b/internal/daemon/runtime_test.go @@ -492,6 +492,60 @@ func TestIsDaemonAliveLegacyStatusCodes(t *testing.T) { } } +func TestCleanupZombieDaemonsPreservesTargetSocket(t *testing.T) { + // Regression test: when a zombie's socket matches the target + // (e.g. a systemd-managed socket), cleanup must remove the + // runtime file but preserve the socket — even when killProcess + // returns true because the PID was reused by a non-roborev + // process. + if runtime.GOOS == "windows" { + t.Skip("Unix sockets not supported on Windows") + } + + dataDir := testenv.SetDataDir(t) + assert := assert.New(t) + + // Create a real Unix socket as the "target" (stands in for the + // systemd-managed socket). Use a short path to stay under the + // Unix socket name length limit on macOS. + socketDir, err := os.MkdirTemp("/tmp", "rr-test-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(socketDir) }) + socketPath := filepath.Join(socketDir, "d.sock") + ln, err := net.Listen("unix", socketPath) + require.NoError(t, err) + defer ln.Close() + + target := DaemonEndpoint{Network: "unix", Address: socketPath} + + // Write a stale runtime file that points at the target socket. + // Use the current PID so isProcessAlive returns true (the PID + // exists), but mock identifyProcess to say it's not roborev + // (simulating PID reuse). + stalePID := os.Getpid() + runtimeJSON, err := json.Marshal(map[string]any{ + "pid": stalePID, + "addr": socketPath, + "port": 0, + "network": "unix", + "version": "stale", + }) + require.NoError(t, err) + runtimePath := filepath.Join( + dataDir, fmt.Sprintf("daemon.%d.json", stalePID)) + require.NoError(t, os.WriteFile(runtimePath, runtimeJSON, 0644)) + + mockIdentifyProcess(t, func(pid int) processIdentity { + return processNotRoborev + }) + + cleaned := CleanupZombieDaemons(target) + + assert.Equal(1, cleaned, "should count stale daemon as cleaned") + assert.NoFileExists(runtimePath, "runtime file should be removed") + assert.FileExists(socketPath, "target socket must be preserved") +} + func TestRuntimeInfo_Endpoint(t *testing.T) { assert := assert.New(t) From ba346f3c34f5317c7337d583c7d02789d84c77ef Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 29 Mar 2026 16:13:59 -0500 Subject: [PATCH 6/7] Reject abstract Unix sockets in socket activation Abstract sockets (@name) don't exist on the filesystem, so they can't be permission-checked or recorded in runtime metadata. Reject them early with an actionable error pointing at ListenStream= rather than letting them fall through to a confusing "path must be absolute" error from ParseEndpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/daemon/server.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 8f8063cdf..fc6866ce9 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -387,6 +387,12 @@ func getSystemdListener() (net.Listener, DaemonEndpoint, error) { } addr := listener.Addr().String() if listener.Addr().Network() == "unix" { + if strings.HasPrefix(addr, "@") || strings.HasPrefix(addr, "\x00") { + _ = listener.Close() + return nil, DaemonEndpoint{}, fmt.Errorf( + "socket activation: abstract Unix sockets are not supported"+ + " (got %q); use a filesystem path in ListenStream=", addr) + } addr = "unix://" + addr } ep, err := ParseEndpoint(addr) From 84f6120c082e83969ab88cda5fbf101d6350b514 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Sun, 29 Mar 2026 16:24:52 -0500 Subject: [PATCH 7/7] Update nix vendorHash for go-systemd dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index a9690339e..7180c0fd0 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ src = ./.; - vendorHash = "sha256-3eyTM8oYEkmAHshFGDTrbVWU106zvP48nDnrGtAta9M="; + vendorHash = "sha256-PE3kbfJQlvUeSPmLawxtVnqTEz+6EI+TS8dc7jphl7w="; subPackages = [ "cmd/roborev" ];