From 77da1614ad19915d9045a5d5627dceb7a92a3492 Mon Sep 17 00:00:00 2001 From: Suhotra Dey <50608734+Lucifer4255@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:49:19 +0530 Subject: [PATCH 1/6] feat(runtime): centralize tmux resolution with bundled-fallback support Resolve tmux in one place shared by the spawn prerequisite gate, the tmux runtime adapter, and ao doctor: AO_TMUX_BIN override, then system PATH, then the app-bundled binary (AO_BUNDLED_TMUX), then the existing RUNTIME_PREREQUISITE_MISSING error. Resolution re-runs per command so a tmux installed after daemon start wins on the next spawn. Nothing is ever installed to the user's system. --- .../runtime/runtimeselect/runtimeselect.go | 8 +- .../internal/adapters/runtime/tmux/resolve.go | 83 ++++++++++ .../adapters/runtime/tmux/resolve_test.go | 152 ++++++++++++++++++ .../internal/adapters/runtime/tmux/tmux.go | 48 +++--- .../adapters/runtime/tmux/tmux_test.go | 56 +++++++ backend/internal/cli/doctor.go | 11 +- backend/internal/cli/doctor_test.go | 54 +++++++ backend/internal/cli/root_test.go | 4 + backend/internal/config/config.go | 15 ++ backend/internal/daemon/daemon.go | 15 +- backend/internal/daemon/lifecycle_wiring.go | 5 + backend/internal/daemon/wiring_test.go | 4 +- backend/internal/session_manager/manager.go | 51 ++++-- .../internal/session_manager/manager_test.go | 56 +++++++ 14 files changed, 522 insertions(+), 40 deletions(-) create mode 100644 backend/internal/adapters/runtime/tmux/resolve.go create mode 100644 backend/internal/adapters/runtime/tmux/resolve_test.go diff --git a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go index 3092590f01..dabbe9e5cd 100644 --- a/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go +++ b/backend/internal/adapters/runtime/runtimeselect/runtimeselect.go @@ -28,10 +28,12 @@ var _ Runtime = (*tmux.Runtime)(nil) var _ Runtime = (*conpty.Runtime)(nil) // New returns the per-platform runtime: tmux on Darwin/Linux, conpty on Windows. -// log is accepted for signature stability with callers but is currently unused. -func New(_ *slog.Logger) Runtime { +// tmuxResolver supplies the tmux binary (override → PATH → bundled); nil keeps +// the default PATH-only resolution. log is accepted for signature stability +// with callers but is currently unused. +func New(_ *slog.Logger, tmuxResolver tmux.BinaryResolver) Runtime { if runtime.GOOS != "windows" { - return tmux.New(tmux.Options{}) + return tmux.New(tmux.Options{Resolver: tmuxResolver}) } return conpty.New(conpty.Options{}) } diff --git a/backend/internal/adapters/runtime/tmux/resolve.go b/backend/internal/adapters/runtime/tmux/resolve.go new file mode 100644 index 0000000000..96a0b8bbd9 --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/resolve.go @@ -0,0 +1,83 @@ +package tmux + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Source identifies where a resolved tmux binary came from. +type Source string + +const ( + // SourceOverride is an explicit AO_TMUX_BIN override. + SourceOverride Source = "AO_TMUX_BIN" + // SourceSystem is a tmux found on PATH. + SourceSystem Source = "PATH" + // SourceBundled is the app-bundled fallback binary. + SourceBundled Source = "bundled" +) + +// BinaryResolver resolves the tmux binary path at use time. Implementations +// re-check PATH and the bundled candidate on every call, so a tmux installed +// after daemon start wins on the next resolution. +type BinaryResolver func() (string, error) + +// ResolveBinary picks the tmux binary in fixed order: explicit override +// (AO_TMUX_BIN) → system PATH → bundled fallback → ports.ErrRuntimePrerequisite. +// +// A set-but-broken override fails loudly rather than falling through: the user +// asked for that exact binary, so silently using another would be misleading. +// The bundled candidate is accepted only if it is an executable regular file, +// rejecting corrupt or half-copied payloads before tmux errors opaquely +// mid-spawn. lookPath and stat default to exec.LookPath and os.Stat when nil. +func ResolveBinary(override, bundled string, lookPath func(string) (string, error), stat func(string) (fs.FileInfo, error)) (string, Source, error) { + if lookPath == nil { + lookPath = exec.LookPath + } + if stat == nil { + stat = os.Stat + } + if override != "" { + if err := checkExecutable(stat, override); err != nil { + return "", "", fmt.Errorf("%w: AO_TMUX_BIN=%q is not an executable file: %v", ports.ErrRuntimePrerequisite, override, err) + } + return override, SourceOverride, nil + } + if path, err := lookPath("tmux"); err == nil && path != "" { + return path, SourceSystem, nil + } + if bundled != "" { + if err := checkExecutable(stat, bundled); err == nil { + return bundled, SourceBundled, nil + } + return "", "", fmt.Errorf("%w: tmux not found in PATH and bundled tmux at %s is not executable", ports.ErrRuntimePrerequisite, bundled) + } + return "", "", fmt.Errorf("%w: tmux not found in PATH and no bundled tmux available (install tmux or set AO_TMUX_BIN)", ports.ErrRuntimePrerequisite) +} + +// NewResolver returns a BinaryResolver closed over the configured override and +// bundled paths. PATH lookup and file checks run on every call. +func NewResolver(override, bundled string) BinaryResolver { + return func() (string, error) { + path, _, err := ResolveBinary(override, bundled, nil, nil) + return path, err + } +} + +func checkExecutable(stat func(string) (fs.FileInfo, error), path string) error { + info, err := stat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("not a regular file") + } + if info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("no execute permission") + } + return nil +} diff --git a/backend/internal/adapters/runtime/tmux/resolve_test.go b/backend/internal/adapters/runtime/tmux/resolve_test.go new file mode 100644 index 0000000000..eb68e1a3ff --- /dev/null +++ b/backend/internal/adapters/runtime/tmux/resolve_test.go @@ -0,0 +1,152 @@ +package tmux + +import ( + "errors" + "io/fs" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// -- fakes -- + +type fakeFileInfo struct { + mode fs.FileMode +} + +func (f fakeFileInfo) Name() string { return "tmux" } +func (f fakeFileInfo) Size() int64 { return 1 } +func (f fakeFileInfo) Mode() fs.FileMode { return f.mode } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return f.mode.IsDir() } +func (f fakeFileInfo) Sys() any { return nil } + +// fakeStat maps paths to file modes; missing paths error like os.Stat. +func fakeStat(files map[string]fs.FileMode) func(string) (fs.FileInfo, error) { + return func(path string) (fs.FileInfo, error) { + mode, ok := files[path] + if !ok { + return nil, fs.ErrNotExist + } + return fakeFileInfo{mode: mode}, nil + } +} + +// fakeLookPath maps names to paths; missing names error like exec.LookPath. +func fakeLookPath(paths map[string]string) func(string) (string, error) { + return func(name string) (string, error) { + if path, ok := paths[name]; ok { + return path, nil + } + return "", errors.New("executable file not found in $PATH") + } +} + +// -- ResolveBinary tests -- + +func TestResolveBinary(t *testing.T) { + systemTmux := map[string]string{"tmux": "/usr/bin/tmux"} + noTmux := map[string]string{} + + tests := []struct { + name string + override string + bundled string + lookPath map[string]string + files map[string]fs.FileMode + wantPath string + wantSource Source + wantErr string // substring; empty means success expected + }{ + { + name: "override wins even when system tmux exists", + override: "/opt/custom/tmux", + lookPath: systemTmux, + files: map[string]fs.FileMode{"/opt/custom/tmux": 0o755}, + wantPath: "/opt/custom/tmux", + wantSource: SourceOverride, + }, + { + name: "broken override fails loudly instead of falling through", + override: "/opt/custom/tmux", + lookPath: systemTmux, + files: map[string]fs.FileMode{}, + wantErr: "AO_TMUX_BIN", + }, + { + name: "non-executable override rejected", + override: "/opt/custom/tmux", + lookPath: systemTmux, + files: map[string]fs.FileMode{"/opt/custom/tmux": 0o644}, + wantErr: "AO_TMUX_BIN", + }, + { + name: "system tmux wins over bundled", + bundled: "/app/resources/tmux-dist/tmux", + lookPath: systemTmux, + files: map[string]fs.FileMode{"/app/resources/tmux-dist/tmux": 0o755}, + wantPath: "/usr/bin/tmux", + wantSource: SourceSystem, + }, + { + name: "bundled accepted when system tmux absent", + bundled: "/app/resources/tmux-dist/tmux", + lookPath: noTmux, + files: map[string]fs.FileMode{"/app/resources/tmux-dist/tmux": 0o755}, + wantPath: "/app/resources/tmux-dist/tmux", + wantSource: SourceBundled, + }, + { + name: "non-executable bundled rejected", + bundled: "/app/resources/tmux-dist/tmux", + lookPath: noTmux, + files: map[string]fs.FileMode{"/app/resources/tmux-dist/tmux": 0o644}, + wantErr: "not executable", + }, + { + name: "missing bundled file rejected", + bundled: "/app/resources/tmux-dist/tmux", + lookPath: noTmux, + files: map[string]fs.FileMode{}, + wantErr: "not executable", + }, + { + name: "directory bundled path rejected", + bundled: "/app/resources/tmux-dist/tmux", + lookPath: noTmux, + files: map[string]fs.FileMode{"/app/resources/tmux-dist/tmux": fs.ModeDir | 0o755}, + wantErr: "not executable", + }, + { + name: "neither system nor bundled", + lookPath: noTmux, + wantErr: "no bundled tmux available", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, source, err := ResolveBinary(tt.override, tt.bundled, fakeLookPath(tt.lookPath), fakeStat(tt.files)) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("ResolveBinary = (%q, %q, nil), want error containing %q", path, source, tt.wantErr) + } + if !errors.Is(err, ports.ErrRuntimePrerequisite) { + t.Fatalf("err = %v, want ports.ErrRuntimePrerequisite sentinel", err) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ResolveBinary error = %v, want success", err) + } + if path != tt.wantPath || source != tt.wantSource { + t.Fatalf("ResolveBinary = (%q, %q), want (%q, %q)", path, source, tt.wantPath, tt.wantSource) + } + }) + } +} diff --git a/backend/internal/adapters/runtime/tmux/tmux.go b/backend/internal/adapters/runtime/tmux/tmux.go index 892add6b90..27a817febf 100644 --- a/backend/internal/adapters/runtime/tmux/tmux.go +++ b/backend/internal/adapters/runtime/tmux/tmux.go @@ -32,16 +32,17 @@ var getenv = os.Getenv // Options configures a tmux Runtime. Every field has a sensible default (see // New), so the zero value is usable. type Options struct { - Binary string // default "tmux" (resolved via exec.LookPath) - Shell string // default $SHELL else /bin/sh - Timeout time.Duration // default 5s - ChunkSize int // default 16*1024 + Binary string // fixed binary path; when set, Resolver is ignored + Resolver BinaryResolver // consulted per command; default PATH-only resolution + Shell string // default $SHELL else /bin/sh + Timeout time.Duration // default 5s + ChunkSize int // default 16*1024 } // Runtime runs agent sessions inside tmux sessions, driving them via the tmux // CLI. It implements ports.Runtime. type Runtime struct { - binary string + resolve BinaryResolver shell string timeout time.Duration chunkSize int @@ -63,17 +64,19 @@ func (execRunner) Run(ctx context.Context, env []string, name string, args ...st return cmd.CombinedOutput() } -// New builds a tmux Runtime, filling unset Options with defaults: binary "tmux" -// (resolved via exec.LookPath), shell from $SHELL (else /bin/sh), and the +// New builds a tmux Runtime, filling unset Options with defaults: the binary +// comes from Options.Binary when set (fixed path, mainly tests), else +// Options.Resolver (consulted per command so a tmux installed later is picked +// up), else PATH-only resolution; shell from $SHELL (else /bin/sh), and the // default timeout and output chunk size. func New(opts Options) *Runtime { - binary := opts.Binary - if binary == "" { - if path, err := exec.LookPath("tmux"); err == nil { - binary = path - } else { - binary = "tmux" - } + resolve := opts.Resolver + if opts.Binary != "" { + binary := opts.Binary + resolve = func() (string, error) { return binary, nil } + } + if resolve == nil { + resolve = NewResolver("", "") } timeout := opts.Timeout if timeout == 0 { @@ -91,7 +94,7 @@ func New(opts Options) *Runtime { chunkSize = defaultChunkBytes } return &Runtime{ - binary: binary, + resolve: resolve, shell: shellPath, timeout: timeout, chunkSize: chunkSize, @@ -246,7 +249,11 @@ func (r *Runtime) attachCommand(handle ports.RuntimeHandle) ([]string, error) { if err != nil { return nil, err } - return []string{r.binary, "attach-session", "-t", id}, nil + bin, err := r.resolve() + if err != nil { + return nil, err + } + return []string{bin, "attach-session", "-t", id}, nil } func attachEnv(base []string) []string { @@ -260,11 +267,16 @@ func attachEnv(base []string) []string { return append(env, "TERM=xterm-256color") } -// run wraps runner.Run with a per-call timeout context. +// run wraps runner.Run with a per-call timeout context, resolving the tmux +// binary fresh for each command. func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) { + bin, err := r.resolve() + if err != nil { + return nil, err + } cmdCtx, cancel := context.WithTimeout(ctx, r.timeout) defer cancel() - out, err := r.runner.Run(cmdCtx, nil, r.binary, args...) + out, err := r.runner.Run(cmdCtx, nil, bin, args...) if cmdCtx.Err() != nil { return out, cmdCtx.Err() } diff --git a/backend/internal/adapters/runtime/tmux/tmux_test.go b/backend/internal/adapters/runtime/tmux/tmux_test.go index a6ae8bf68e..2352e057c7 100644 --- a/backend/internal/adapters/runtime/tmux/tmux_test.go +++ b/backend/internal/adapters/runtime/tmux/tmux_test.go @@ -67,6 +67,62 @@ func TestNewPicksUpShellFromEnv(t *testing.T) { } } +// -- resolver plumbing tests -- + +// TestRuntimeConsultsResolverPerCommand proves the binary is re-resolved for +// every tmux invocation, so a tmux installed (or removed) after construction +// takes effect on the next command. +func TestRuntimeConsultsResolverPerCommand(t *testing.T) { + answers := []string{"/first/tmux", "/second/tmux"} + resolver := func() (string, error) { + next := answers[0] + if len(answers) > 1 { + answers = answers[1:] + } + return next, nil + } + fr := &fakeRunner{} + r := New(Options{Resolver: resolver, Timeout: time.Second, Shell: "/bin/sh"}) + r.runner = fr + + handle := ports.RuntimeHandle{ID: "sess-1"} + if _, err := r.IsAlive(context.Background(), handle); err != nil { + t.Fatalf("IsAlive: %v", err) + } + if _, err := r.GetOutput(context.Background(), handle, 10); err != nil { + t.Fatalf("GetOutput: %v", err) + } + if len(fr.calls) != 2 || fr.calls[0].name != "/first/tmux" || fr.calls[1].name != "/second/tmux" { + t.Fatalf("runner calls = %#v, want /first/tmux then /second/tmux", fr.calls) + } +} + +func TestRuntimeSurfacesResolverError(t *testing.T) { + resolveErr := errors.New("tmux nowhere") + r := New(Options{Resolver: func() (string, error) { return "", resolveErr }, Timeout: time.Second, Shell: "/bin/sh"}) + r.runner = &fakeRunner{} + + if _, err := r.Create(context.Background(), ports.RuntimeConfig{SessionID: "sess-1", WorkspacePath: "/tmp/ws", Argv: []string{"echo"}}); !errors.Is(err, resolveErr) { + t.Fatalf("Create err = %v, want resolver error", err) + } + if _, err := r.attachCommand(ports.RuntimeHandle{ID: "sess-1"}); !errors.Is(err, resolveErr) { + t.Fatalf("attachCommand err = %v, want resolver error", err) + } +} + +// TestNewBinaryOverridesResolver: an explicit Binary pins the path and the +// resolver is never consulted (the seam existing tests rely on). +func TestNewBinaryOverridesResolver(t *testing.T) { + r := New(Options{ + Binary: "tmux-test", + Resolver: func() (string, error) { t.Fatal("resolver must not be called when Binary is set"); return "", nil }, + }) + bin, err := r.resolve() + if err != nil || bin != "tmux-test" { + t.Fatalf("resolve = (%q, %v), want (tmux-test, nil)", bin, err) + } +} + // -- command builder tests -- func TestCommandBuilders(t *testing.T) { diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 248730d5e7..fd656271c2 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -19,6 +19,7 @@ import ( "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" "github.com/aoagents/agent-orchestrator/backend/internal/config" ) @@ -305,9 +306,13 @@ func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { } func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { - path, err := c.deps.LookPath("tmux") + // Same resolution order as the daemon (AO_TMUX_BIN → PATH → bundled). + // AO_BUNDLED_TMUX is stamped only into the daemon env by the desktop app, + // so a shell-run doctor usually sees it empty — the WARN wording must not + // claim the app itself has no bundled fallback. + path, source, err := tmux.ResolveBinary(os.Getenv("AO_TMUX_BIN"), os.Getenv("AO_BUNDLED_TMUX"), c.deps.LookPath, os.Stat) if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH; required on macOS/Linux to start sessions"} + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH (the desktop app falls back to its bundled tmux; otherwise install tmux or set AO_TMUX_BIN)"} } reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() @@ -319,7 +324,7 @@ func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { if version == "" { version = "version unknown" } - return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s)", path, version)} + return doctorCheck{Level: doctorPass, Section: doctorSectionTools, Name: "tmux", Message: fmt.Sprintf("%s (%s, via %s)", path, version, source)} } // checkHooksLog surfaces recent agent hook delivery failures. `ao hooks` diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go index 7686dfb763..5705d84c8a 100644 --- a/backend/internal/cli/doctor_test.go +++ b/backend/internal/cli/doctor_test.go @@ -114,6 +114,60 @@ func TestDoctorWarnsWhenTmuxMissing(t *testing.T) { } } +// TestDoctorTmuxOverrideWins: AO_TMUX_BIN takes precedence over PATH and the +// PASS message reports the provenance. +func TestDoctorTmuxOverrideWins(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("ao doctor emits a conpty check on Windows, not tmux") + } + setConfigEnv(t) + override := writeFakeTmux(t) + t.Setenv("AO_TMUX_BIN", override) + c := doctorContext(t, map[string]string{"git": "/bin/git", "tmux": "/bin/tmux"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == override { + return []byte("tmux 3.5a\n"), nil + } + return []byte("git version 2.43.0\n"), nil + }) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + if check.Level != doctorPass || !strings.Contains(check.Message, override) || !strings.Contains(check.Message, "AO_TMUX_BIN") { + t.Fatalf("tmux check = %+v, want PASS via AO_TMUX_BIN", check) + } +} + +// TestDoctorTmuxBundledFallback: with no tmux on PATH, an executable +// AO_BUNDLED_TMUX is accepted and reported as bundled. +func TestDoctorTmuxBundledFallback(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("ao doctor emits a conpty check on Windows, not tmux") + } + setConfigEnv(t) + bundled := writeFakeTmux(t) + t.Setenv("AO_BUNDLED_TMUX", bundled) + c := doctorContext(t, map[string]string{"git": "/bin/git"}, func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == bundled { + return []byte("tmux 3.5a\n"), nil + } + return []byte("git version 2.43.0\n"), nil + }) + + check := findDoctorCheck(t, c.runDoctor(context.Background()), "tmux") + if check.Level != doctorPass || !strings.Contains(check.Message, "via bundled") { + t.Fatalf("tmux check = %+v, want PASS via bundled", check) + } +} + +// writeFakeTmux drops an executable placeholder file for resolver stat checks. +func writeFakeTmux(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "tmux") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + func TestDoctorChecksHarnessVersions(t *testing.T) { setConfigEnv(t) cmdPath := map[string]string{ diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go index a5c1379ab2..181c5ce0b2 100644 --- a/backend/internal/cli/root_test.go +++ b/backend/internal/cli/root_test.go @@ -308,6 +308,10 @@ func setConfigEnv(t *testing.T) testConfig { t.Setenv("AO_PORT", "3001") t.Setenv("AO_REQUEST_TIMEOUT", "") t.Setenv("AO_SHUTDOWN_TIMEOUT", "") + // Keep tmux resolution hermetic: a host with these set would otherwise + // change which binary the doctor check reports. + t.Setenv("AO_TMUX_BIN", "") + t.Setenv("AO_BUNDLED_TMUX", "") return cfg } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 45cef3f5fe..636f9a0235 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -89,6 +89,13 @@ type Config struct { // Agent is the compatibility agent adapter id selected by AO_AGENT; // startSession fails fast if no adapter with this id is registered. Agent string + // TmuxBin is an explicit tmux binary override (AO_TMUX_BIN). Empty means + // resolve at use time: system PATH first, then the bundled fallback. + TmuxBin string + // BundledTmux is the app-bundled tmux fallback path (AO_BUNDLED_TMUX), + // stamped by the Electron supervisor for the packaged app; empty elsewhere. + // Used only when no tmux is found on PATH. + BundledTmux string // AllowedOrigins are the browser origins granted CORS read access (see // DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS. AllowedOrigins []string @@ -114,6 +121,8 @@ func (c Config) Addr() string { // AO_RUN_FILE running.json path (default ~/.ao/running.json) // AO_DATA_DIR durable state dir (default ~/.ao/data) // AO_AGENT compatibility agent id (default claude-code) +// AO_TMUX_BIN explicit tmux binary override (default: resolve PATH, then bundled) +// AO_BUNDLED_TMUX app-bundled tmux fallback path (set by the desktop app; not for manual use) // AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins) // AO_TELEMETRY_EVENTS local event capture off|on (default off) // AO_TELEMETRY_METRICS local metric capture off|on (default off) @@ -167,6 +176,12 @@ func Load() (Config, error) { cfg.Agent = raw } + // No validation here: a missing path is not "malformed", and tmux + // resolution is a use-time decision (the spawn prerequisite gate reports + // the clear error). See adapters/runtime/tmux.ResolveBinary. + cfg.TmuxBin = os.Getenv("AO_TMUX_BIN") + cfg.BundledTmux = os.Getenv("AO_BUNDLED_TMUX") + if raw, ok := os.LookupEnv("AO_ALLOWED_ORIGINS"); ok && raw != "" { // Explicit override replaces the defaults entirely so a deployment can // also narrow the list. The "null" origin is rejected, never silently diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 0e31f06399..b2c4f42499 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -10,10 +10,12 @@ import ( "net/http" "os" "os/signal" + goruntime "runtime" "syscall" "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/daemon/supervisor" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -98,7 +100,18 @@ func Run() error { // attach Stream and liveness; the CDC broadcaster feeds the session-state channel. The manager // is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow // through the CDC change_log -- only session-state events do. - runtimeAdapter := runtimeselect.New(log) + // tmux binary resolution is shared by every consumer (runtime adapter, + // spawn prerequisite gate) so they can never disagree: AO_TMUX_BIN + // override → system PATH → app-bundled fallback. Logged once here so a + // support bundle shows which tmux the daemon will use; resolution re-runs + // per command, so this is advisory and never fails boot. + tmuxResolver := tmux.NewResolver(cfg.TmuxBin, cfg.BundledTmux) + if path, source, err := tmux.ResolveBinary(cfg.TmuxBin, cfg.BundledTmux, nil, nil); err == nil { + log.Info("tmux resolved", "path", path, "source", source) + } else if goruntime.GOOS != "windows" { + log.Warn("tmux not available; session spawn will fail until installed", "err", err) + } + runtimeAdapter := runtimeselect.New(log, tmuxResolver) termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log) defer termMgr.Close() diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 5113d9dcb6..9a96ddf8c0 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -11,6 +11,7 @@ import ( agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -110,6 +111,10 @@ func startSession(cfg config.Config, runtime runtimeselect.Runtime, store *sqlit Lifecycle: lcm, DataDir: cfg.DataDir, Logger: log, + // Same resolution the tmux runtime adapter uses (override → PATH → + // bundled), so the spawn prerequisite gate can never disagree with + // the binary the runtime would actually exec. + TmuxResolver: tmux.NewResolver(cfg.TmuxBin, cfg.BundledTmux), }) scmProvider, err := newGitHubSCMProvider(log) if err != nil { diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 9a3eb20588..ffaec99fa6 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -150,7 +150,7 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { lcm := lifecycle.New(store, nil) cfg := config.Config{DataDir: t.TempDir()} - rt := runtimeselect.New(nil) + rt := runtimeselect.New(nil, nil) messenger := newSessionMessenger(store, rt, log) svc, reviewSvc, lc, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) if err != nil { @@ -183,7 +183,7 @@ func TestStartTrackerIntake_RunsEvenWithoutEnabledProjects(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) lcm := lifecycle.New(store, nil) cfg := config.Config{DataDir: t.TempDir()} - rt := runtimeselect.New(nil) + rt := runtimeselect.New(nil, nil) messenger := newSessionMessenger(store, rt, log) svc, _, _, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) if err != nil { diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 11da9edd88..0beff01b6e 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -118,6 +118,9 @@ type Manager struct { // they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound // when the binary is missing so the sentinel propagates through toAPIError. lookPath func(string) (string, error) + // tmuxResolver resolves the tmux binary for the spawn prerequisite gate + // (see Deps.TmuxResolver). Defaults to a PATH-only check via lookPath. + tmuxResolver func() (string, error) // executable resolves the daemon's own binary (os.Executable in // production); its directory is prepended to spawned sessions' PATH so the // workspace hook commands resolve back to this daemon. Tests inject a stub. @@ -141,6 +144,13 @@ type Deps struct { // Production wiring leaves this nil and the manager defaults to // exec.LookPath; tests inject a stub so they need not seed real binaries. LookPath func(string) (string, error) + // TmuxResolver resolves the tmux binary for the spawn prerequisite gate. + // Production wiring passes the shared resolver (AO_TMUX_BIN override → + // PATH → app-bundled fallback) so the gate can never disagree with the + // binary the tmux runtime adapter execs. Nil defaults to a PATH-only + // check through LookPath. A plain func type keeps this package decoupled + // from the tmux adapter. + TmuxResolver func() (string, error) // Executable overrides os.Executable for the session PATH pin (see // hookPATH). Production wiring leaves this nil; tests inject a stub so they // control what the test binary appears to be. @@ -154,17 +164,18 @@ type Deps struct { // time.Now when Deps.Clock is nil. func New(d Deps) *Manager { m := &Manager{ - runtime: d.Runtime, - agents: d.Agents, - workspace: d.Workspace, - store: d.Store, - messenger: d.Messenger, - lcm: d.Lifecycle, - dataDir: d.DataDir, - clock: d.Clock, - lookPath: d.LookPath, - executable: d.Executable, - logger: d.Logger, + runtime: d.Runtime, + agents: d.Agents, + workspace: d.Workspace, + store: d.Store, + messenger: d.Messenger, + lcm: d.Lifecycle, + dataDir: d.DataDir, + clock: d.Clock, + lookPath: d.LookPath, + tmuxResolver: d.TmuxResolver, + executable: d.Executable, + logger: d.Logger, } if m.clock == nil { // UTC so spawn-stamped CreatedAt/UpdatedAt match every other session @@ -175,6 +186,17 @@ func New(d Deps) *Manager { if m.lookPath == nil { m.lookPath = exec.LookPath } + if m.tmuxResolver == nil { + // PATH-only fallback preserving the pre-resolver behavior (and error + // text) for callers that never wire a resolver. + m.tmuxResolver = func() (string, error) { + path, err := m.lookPath("tmux") + if err != nil || path == "" { + return "", fmt.Errorf("%w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite) + } + return path, nil + } + } if m.executable == nil { m.executable = os.Executable } @@ -1374,8 +1396,11 @@ func (m *Manager) validateRuntimePrerequisites() error { if runtime.GOOS == "windows" { return nil } - if path, err := m.lookPath("tmux"); err != nil || path == "" { - return fmt.Errorf("%w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite) + if _, err := m.tmuxResolver(); err != nil { + if errors.Is(err, ports.ErrRuntimePrerequisite) { + return err + } + return fmt.Errorf("%w: %v", ports.ErrRuntimePrerequisite, err) } return nil } diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 4b290885dd..b971e031a9 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -1185,6 +1185,62 @@ func TestSpawn_RejectsMissingTmuxBeforeSessionRow(t *testing.T) { } } +// TestSpawn_AcceptsBundledTmuxViaResolver: when a TmuxResolver is wired (as +// production does for the packaged app's bundled fallback), the prerequisite +// gate accepts its answer even though PATH has no tmux. +func TestSpawn_AcceptsBundledTmuxViaResolver(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows uses ConPTY, not tmux") + } + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + lookPath := func(name string) (string, error) { + if name == "tmux" { + return "", fmt.Errorf("exec: %q: not found", name) + } + return "/bin/true", nil + } + resolver := func() (string, error) { return "/app/resources/tmux-dist/tmux", nil } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath, TmuxResolver: resolver}) + + if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil { + t.Fatalf("Spawn = %v, want success past the tmux prerequisite gate", err) + } + if rt.created != 1 { + t.Fatalf("runtime.Create calls = %d, want 1", rt.created) + } +} + +// TestSpawn_RejectsWhenResolverFails: a resolver error (no PATH tmux, no +// usable bundled fallback) aborts before any session row is created and +// surfaces the prerequisite sentinel. +func TestSpawn_RejectsWhenResolverFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows uses ConPTY, not tmux") + } + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + resolver := func() (string, error) { + return "", fmt.Errorf("%w: tmux not found in PATH and no bundled tmux available", ports.ErrRuntimePrerequisite) + } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, TmuxResolver: resolver}) + + _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + if !errors.Is(err, ports.ErrRuntimePrerequisite) { + t.Fatalf("err = %v, want ports.ErrRuntimePrerequisite", err) + } + if len(st.sessions) != 0 { + t.Fatalf("no session row should be created, got %d", len(st.sessions)) + } + if rt.created != 0 { + t.Fatal("runtime must not be created when tmux resolution fails") + } +} + func TestSpawn_RejectsUnknownHarness(t *testing.T) { st := newFakeStore() rt := &fakeRuntime{} From 82e2ddad866a57572958a7254ce85e60a3eaa142 Mon Sep 17 00:00:00 2001 From: Suhotra Dey <50608734+Lucifer4255@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:50:47 +0530 Subject: [PATCH 2/6] ci: add tmux-artifacts workflow building pinned static tmux binaries Manual-dispatch workflow compiling tmux + libevent + ncurses from checksum-verified source tarballs, natively per target (macOS arm64/x64, Linux x64 musl-static), and publishing a tmux-artifacts-v- release with checksums and provenance notes for the packaging fetch step. --- .github/workflows/tmux-artifacts.yml | 209 +++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 .github/workflows/tmux-artifacts.yml diff --git a/.github/workflows/tmux-artifacts.yml b/.github/workflows/tmux-artifacts.yml new file mode 100644 index 0000000000..dda4e6b00d --- /dev/null +++ b/.github/workflows/tmux-artifacts.yml @@ -0,0 +1,209 @@ +# Builds pinned static tmux binaries for the desktop app's bundled fallback +# (issue #2443) and publishes them as a GitHub release the packaging step +# (frontend/scripts/fetch-tmux.mjs) downloads at build time. +# +# Run manually and rarely: only when bumping the pinned tmux/libevent/ncurses +# versions. After a run, update TMUX_DIST_TAG and the sha256 map in +# fetch-tmux.mjs from the checksums.txt asset (provenance is documented in +# frontend/docs/desktop-release.md). +# +# Targets mirror the desktop release matrix (native builds, no cross-compile, +# same convention as frontend-release.yml): macOS arm64 + x64, Linux x64. +# Linux arm64 is deferred until the app publishes that target. +name: tmux-artifacts + +on: + workflow_dispatch: + inputs: + tmux_version: + description: "tmux version (release tag on tmux/tmux)" + default: "3.5a" + required: true + libevent_version: + description: "libevent version (release- tag on libevent/libevent)" + default: "2.1.12-stable" + required: true + ncurses_version: + description: "ncurses version (invisible-island.net archive)" + default: "6.5" + required: true + rev: + description: "artifact revision suffix (bump to rebuild the same versions)" + default: "1" + required: true + +# Pinned sha256 of the SOURCE tarballs. Bumping a version input requires +# updating the matching hash here, keeping provenance auditable in-repo. +env: + TMUX_SHA256: "16216bd0877170dfcc64157085ba9013610b12b082548c7c9542cc0103198951" + LIBEVENT_SHA256: "92e6de1be9ec176428fd2367677e61ceffc2ee1cb119035037a27d346b0403bb" + NCURSES_SHA256: "136d91bc269a9a5785e5f9e980bc76ab57428f604ce3e5a5a90cebc767971cc6" + +jobs: + build-macos: + strategy: + matrix: + include: + - runner: macos-latest + arch: arm64 + - runner: macos-15-intel + arch: x64 + runs-on: ${{ matrix.runner }} + steps: + - name: Fetch and verify sources + run: | + set -euo pipefail + curl -sSLo tmux.tar.gz "https://github.com/tmux/tmux/releases/download/${{ inputs.tmux_version }}/tmux-${{ inputs.tmux_version }}.tar.gz" + curl -sSLo libevent.tar.gz "https://github.com/libevent/libevent/releases/download/release-${{ inputs.libevent_version }}/libevent-${{ inputs.libevent_version }}.tar.gz" + curl -sSLo ncurses.tar.gz "https://invisible-island.net/archives/ncurses/ncurses-${{ inputs.ncurses_version }}.tar.gz" + echo "$TMUX_SHA256 tmux.tar.gz" | shasum -a 256 -c - + echo "$LIBEVENT_SHA256 libevent.tar.gz" | shasum -a 256 -c - + echo "$NCURSES_SHA256 ncurses.tar.gz" | shasum -a 256 -c - + tar xzf libevent.tar.gz && tar xzf ncurses.tar.gz && tar xzf tmux.tar.gz + + # Static libevent + ncurses archives; only /usr/lib/libSystem stays + # dynamic (fully static binaries are not possible on macOS). + - name: Build static deps + run: | + set -euo pipefail + export MACOSX_DEPLOYMENT_TARGET=11.0 + DEPS="$PWD/deps" + cd "libevent-${{ inputs.libevent_version }}" + ./configure --prefix="$DEPS" --enable-static --disable-shared --disable-openssl --disable-libevent-regress --disable-samples + make -j"$(sysctl -n hw.ncpu)" && make install + cd "../ncurses-${{ inputs.ncurses_version }}" + ./configure --prefix="$DEPS" --without-shared --without-debug --without-ada --without-manpages --without-progs --without-tests --enable-termcap \ + --with-terminfo-dirs="/usr/share/terminfo" \ + --with-fallbacks="xterm,xterm-256color,screen,screen-256color,tmux,tmux-256color" + make -j"$(sysctl -n hw.ncpu)" && make install + + - name: Build tmux + run: | + set -euo pipefail + export MACOSX_DEPLOYMENT_TARGET=11.0 + DEPS="$PWD/deps" + cd "tmux-${{ inputs.tmux_version }}" + ./configure --prefix="$PWD/out" \ + CPPFLAGS="-I$DEPS/include -I$DEPS/include/ncurses" \ + LDFLAGS="-L$DEPS/lib" \ + LIBEVENT_CFLAGS="-I$DEPS/include" LIBEVENT_LIBS="$DEPS/lib/libevent_core.a" \ + LIBNCURSES_CFLAGS="-I$DEPS/include -I$DEPS/include/ncurses" LIBNCURSES_LIBS="$DEPS/lib/libncurses.a" + make -j"$(sysctl -n hw.ncpu)" + cp tmux "../tmux-darwin-${{ matrix.arch }}" + + - name: Verify binary + run: | + set -euo pipefail + # Only libSystem may remain dynamic; a stray Homebrew dylib here + # would break clean machines. + otool -L "tmux-darwin-${{ matrix.arch }}" + if otool -L "tmux-darwin-${{ matrix.arch }}" | tail -n +2 | grep -v "libSystem"; then + echo "unexpected dynamic dependency" >&2; exit 1 + fi + ./"tmux-darwin-${{ matrix.arch }}" -V + + - uses: actions/upload-artifact@v4 + with: + name: tmux-darwin-${{ matrix.arch }} + path: tmux-darwin-${{ matrix.arch }} + if-no-files-found: error + + build-linux-x64: + runs-on: ubuntu-latest + # musl-static: genuinely portable (old glibc distros, minimal containers), + # unlike glibc -static which still dlopens NSS at runtime. + container: alpine:3.20 + steps: + - name: Install toolchain + run: apk add --no-cache build-base bison pkgconf linux-headers curl + + - name: Fetch and verify sources + run: | + set -eu + curl -sSLo tmux.tar.gz "https://github.com/tmux/tmux/releases/download/${{ inputs.tmux_version }}/tmux-${{ inputs.tmux_version }}.tar.gz" + curl -sSLo libevent.tar.gz "https://github.com/libevent/libevent/releases/download/release-${{ inputs.libevent_version }}/libevent-${{ inputs.libevent_version }}.tar.gz" + curl -sSLo ncurses.tar.gz "https://invisible-island.net/archives/ncurses/ncurses-${{ inputs.ncurses_version }}.tar.gz" + echo "$TMUX_SHA256 tmux.tar.gz" | sha256sum -c - + echo "$LIBEVENT_SHA256 libevent.tar.gz" | sha256sum -c - + echo "$NCURSES_SHA256 ncurses.tar.gz" | sha256sum -c - + tar xzf libevent.tar.gz && tar xzf ncurses.tar.gz && tar xzf tmux.tar.gz + + - name: Build static deps + run: | + set -eu + DEPS="$PWD/deps" + cd "libevent-${{ inputs.libevent_version }}" + ./configure --prefix="$DEPS" --enable-static --disable-shared --disable-openssl --disable-libevent-regress --disable-samples + make -j"$(nproc)" && make install + cd "../ncurses-${{ inputs.ncurses_version }}" + # A static ncurses cannot rely on its compiled-in terminfo location + # existing on the host: search the common distro dirs and compile in + # fallback entries so tmux works even on terminfo-less systems. + ./configure --prefix="$DEPS" --without-shared --without-debug --without-ada --without-manpages --without-progs --without-tests --enable-termcap \ + --with-terminfo-dirs="/etc/terminfo:/lib/terminfo:/usr/share/terminfo" \ + --with-default-terminfo-dir="/usr/share/terminfo" \ + --with-fallbacks="xterm,xterm-256color,screen,screen-256color,tmux,tmux-256color" + make -j"$(nproc)" && make install + + - name: Build tmux + run: | + set -eu + DEPS="$PWD/deps" + cd "tmux-${{ inputs.tmux_version }}" + ./configure --prefix="$PWD/out" --enable-static \ + CPPFLAGS="-I$DEPS/include -I$DEPS/include/ncurses" \ + LDFLAGS="-L$DEPS/lib -static" \ + LIBEVENT_CFLAGS="-I$DEPS/include" LIBEVENT_LIBS="$DEPS/lib/libevent_core.a" \ + LIBNCURSES_CFLAGS="-I$DEPS/include -I$DEPS/include/ncurses" LIBNCURSES_LIBS="$DEPS/lib/libncurses.a" + make -j"$(nproc)" + cp tmux ../tmux-linux-x64 + + - name: Verify binary + run: | + set -eu + file tmux-linux-x64 | grep -q "statically linked" + ./tmux-linux-x64 -V + + - uses: actions/upload-artifact@v4 + with: + name: tmux-linux-x64 + path: tmux-linux-x64 + if-no-files-found: error + + publish: + needs: [build-macos, build-linux-x64] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + merge-multiple: true + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + TAG: tmux-artifacts-v${{ inputs.tmux_version }}-${{ inputs.rev }} + run: | + set -euo pipefail + sha256sum tmux-darwin-arm64 tmux-darwin-x64 tmux-linux-x64 > checksums.txt + cat checksums.txt + { + echo "Static tmux binaries bundled into the desktop installers as a private" + echo "fallback when no system tmux is on PATH (issue #2443)." + echo + echo "| component | version | source | sha256 |" + echo "| --- | --- | --- | --- |" + echo "| tmux | ${{ inputs.tmux_version }} | https://github.com/tmux/tmux/releases/download/${{ inputs.tmux_version }}/tmux-${{ inputs.tmux_version }}.tar.gz | \`$TMUX_SHA256\` |" + echo "| libevent | ${{ inputs.libevent_version }} | https://github.com/libevent/libevent/releases/download/release-${{ inputs.libevent_version }}/libevent-${{ inputs.libevent_version }}.tar.gz | \`$LIBEVENT_SHA256\` |" + echo "| ncurses | ${{ inputs.ncurses_version }} | https://invisible-island.net/archives/ncurses/ncurses-${{ inputs.ncurses_version }}.tar.gz | \`$NCURSES_SHA256\` |" + echo + echo "macOS: static libevent/ncurses, dynamic only against libSystem, deployment target 11.0." + echo "Linux: musl fully-static (alpine:3.20), terminfo search dirs /etc/terminfo:/lib/terminfo:/usr/share/terminfo with compiled-in xterm/screen/tmux fallbacks." + echo + echo "Licenses: tmux ISC, libevent BSD-3-Clause, ncurses MIT-X11 — all redistribution-compatible." + echo + echo "Built by the tmux-artifacts workflow, run ${{ github.run_id }}." + } > notes.md + gh release create "$TAG" tmux-darwin-arm64 tmux-darwin-x64 tmux-linux-x64 checksums.txt \ + --repo "${{ github.repository }}" --title "$TAG" --notes-file notes.md From db07863f6440c7a7e9bda5b851d11c1d396d17b9 Mon Sep 17 00:00:00 2001 From: Suhotra Dey <50608734+Lucifer4255@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:52:12 +0530 Subject: [PATCH 3/6] build(packaging): fetch pinned static tmux into installers via prepackage hook fetch-tmux.mjs downloads the checksum-pinned artifact from the tmux-artifacts release into frontend/tmux-dist before packaging/signing, ships it as an extraResource, and deb/rpm additionally declare a system tmux dependency. Checksums are placeholders until the first artifacts release is cut. --- .gitignore | 3 ++ frontend/forge.config.ts | 18 ++++++- frontend/package.json | 7 +-- frontend/scripts/fetch-tmux.mjs | 85 +++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 frontend/scripts/fetch-tmux.mjs diff --git a/.gitignore b/.gitignore index d54d7600c3..bf87db9855 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ frontend/test-results/ # built daemon binary copied into the frontend bundle dir frontend/daemon/ + +# fetched static tmux binary bundled into the installers (fetch-tmux.mjs) +frontend/tmux-dist/ diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index fa7fd35aae..7dce4b0034 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -2,7 +2,7 @@ import type { ForgeConfig } from "@electron-forge/shared-types"; import { VitePlugin } from "@electron-forge/plugin-vite"; import MakerNSIS from "./makers/maker-nsis"; import MakerAppImage from "./makers/maker-appimage"; -import { writeFileSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; // Default GitHub release target (production). aoagents was the temporary rewrite // home; releases land on AgentWrapper (spec §1.1). @@ -31,7 +31,15 @@ const config: ForgeConfig = { // (.icns on macOS, .ico on Windows); Linux menu icons come from the // deb/rpm makers below, and the runtime window icon from src/main.ts. icon: "assets/icon", - extraResource: ["daemon", "assets/icon.png", "app-update.yml"], + // tmux-dist holds the static tmux fallback fetched by fetch-tmux.mjs in + // the prepackage/premake hooks; conditional because Windows (ConPTY) + // and AO_SKIP_TMUX_FETCH builds have no such directory. + extraResource: [ + "daemon", + ...(existsSync("tmux-dist") ? ["tmux-dist"] : []), + "assets/icon.png", + "app-update.yml", + ], // Notarization. Two paths: // - CI: an App Store Connect API key. APPLE_API_KEY is a PATH to the .p8 // (the workflow decodes APPLE_API_KEY_BASE64 to a temp file), plus the @@ -112,6 +120,9 @@ const config: ForgeConfig = { icon: "assets/icon.png", maintainer: "Agent Orchestrator", homepage: "https://github.com/aoagents/agent-orchestrator", + // Complement to the bundled fallback: a system tmux always + // wins over the bundle when present (issue #2443). + depends: ["tmux"], }, }, }, @@ -123,6 +134,9 @@ const config: ForgeConfig = { // rpmbuild rejects a spec with an empty License field. license: "MIT", homepage: "https://github.com/aoagents/agent-orchestrator", + // Complement to the bundled fallback: a system tmux always + // wins over the bundle when present (issue #2443). + requires: ["tmux"], }, }, }, diff --git a/frontend/package.json b/frontend/package.json index a70a05a573..39a5a48422 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,13 +14,14 @@ }, "scripts": { "build:daemon": "node ./scripts/build-daemon.mjs", + "fetch:tmux": "node ./scripts/fetch-tmux.mjs", "dev": "electron-forge start", "dev:web": "VITE_NO_ELECTRON=1 vite --config vite.renderer.config.ts", - "prepackage": "npm run build:daemon", + "prepackage": "npm run build:daemon && npm run fetch:tmux", "package": "electron-forge package", - "premake": "npm run build:daemon", + "premake": "npm run build:daemon && npm run fetch:tmux", "make": "electron-forge make", - "publish": "npm run build:daemon && electron-forge publish", + "publish": "npm run build:daemon && npm run fetch:tmux && electron-forge publish", "typecheck": "tsc --noEmit", "test": "vitest run --config vite.renderer.config.ts", "test:e2e": "playwright test", diff --git a/frontend/scripts/fetch-tmux.mjs b/frontend/scripts/fetch-tmux.mjs new file mode 100644 index 0000000000..2ffd33ae38 --- /dev/null +++ b/frontend/scripts/fetch-tmux.mjs @@ -0,0 +1,85 @@ +// Fetches the pinned static tmux binary bundled into the macOS/Linux +// installers as a private fallback for machines with no system tmux +// (issue #2443). Artifacts are built from source by the tmux-artifacts +// workflow (.github/workflows/tmux-artifacts.yml); provenance and the +// rollover procedure are documented in frontend/docs/desktop-release.md. +// +// Runs from the prepackage/premake hooks so the binary exists BEFORE +// electron-forge copies extraResources and signs the bundle (resources +// written after signing make macOS report the app as "damaged"). +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const TMUX_DIST_TAG = "tmux-artifacts-v3.5a-1"; +const TMUX_DIST_REPO = "AgentWrapper/agent-orchestrator"; +// sha256 of the built binaries, from the release's checksums.txt. Bumping +// TMUX_DIST_TAG requires refreshing every hash here. +const TMUX_DIST = { + "darwin-arm64": { + asset: "tmux-darwin-arm64", + sha256: "0000000000000000000000000000000000000000000000000000000000000000", + }, + "darwin-x64": { + asset: "tmux-darwin-x64", + sha256: "0000000000000000000000000000000000000000000000000000000000000000", + }, + "linux-x64": { + asset: "tmux-linux-x64", + sha256: "0000000000000000000000000000000000000000000000000000000000000000", + }, +}; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const frontendRoot = resolve(scriptsDir, ".."); +const outDir = join(frontendRoot, "tmux-dist"); +const outPath = join(outDir, "tmux"); + +if (process.platform === "win32") { + console.log("fetch-tmux: Windows uses the built-in ConPTY runtime; nothing to bundle."); + process.exit(0); +} + +const key = `${process.platform}-${process.arch}`; +const dist = TMUX_DIST[key]; +if (!dist) { + // e.g. linux-arm64: not a published desktop target yet. The app still + // works wherever a system tmux exists. + console.warn(`fetch-tmux: no pinned tmux artifact for ${key}; skipping bundle.`); + process.exit(0); +} + +if (process.env.AO_SKIP_TMUX_FETCH === "1") { + console.warn("fetch-tmux: AO_SKIP_TMUX_FETCH=1, skipping; the package will have no bundled tmux fallback."); + process.exit(0); +} + +function sha256(buf) { + return createHash("sha256").update(buf).digest("hex"); +} + +if (existsSync(outPath) && sha256(readFileSync(outPath)) === dist.sha256) { + console.log(`fetch-tmux: cached ${key} binary matches pin; skipping download.`); + process.exit(0); +} + +const url = `https://github.com/${TMUX_DIST_REPO}/releases/download/${TMUX_DIST_TAG}/${dist.asset}`; +console.log(`fetch-tmux: downloading ${url}`); +const res = await fetch(url, { redirect: "follow" }); +if (!res.ok) { + console.error(`fetch-tmux: download failed: HTTP ${res.status} ${res.statusText}`); + console.error("fetch-tmux: set AO_SKIP_TMUX_FETCH=1 to package without the bundled fallback (dev only)."); + process.exit(1); +} +const body = Buffer.from(await res.arrayBuffer()); +const got = sha256(body); +if (got !== dist.sha256) { + console.error(`fetch-tmux: checksum mismatch for ${dist.asset}: got ${got}, want ${dist.sha256}`); + process.exit(1); +} + +mkdirSync(outDir, { recursive: true }); +writeFileSync(outPath, body); +chmodSync(outPath, 0o755); +console.log(`fetch-tmux: wrote ${outPath} (${body.length} bytes, sha256 verified).`); From 5f2f9016a3c7640af01cad1bec41a13d041c0b51 Mon Sep 17 00:00:00 2001 From: Suhotra Dey <50608734+Lucifer4255@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:56:43 +0530 Subject: [PATCH 4/6] feat(supervisor): stamp AO_BUNDLED_TMUX into the packaged daemon env bundledTmuxPath locates the tmux-dist extraResource for packaged macOS/Linux builds; the daemon uses it only when no system tmux is on PATH. Dev and Windows pass nothing. --- frontend/src/main.ts | 11 +++++++++-- frontend/src/shared/daemon-launch.test.ts | 23 ++++++++++++++++++++++- frontend/src/shared/daemon-launch.ts | 18 ++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 835440a017..60bf15386a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -32,7 +32,7 @@ import { readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { type DaemonLaunchSpec, resolveDaemonLaunch } from "./shared/daemon-launch"; +import { type DaemonLaunchSpec, bundledTmuxPath, resolveDaemonLaunch } from "./shared/daemon-launch"; import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery"; import type { DaemonStatus } from "./shared/daemon-status"; import { @@ -337,7 +337,14 @@ function daemonEnv(): NodeJS.ProcessEnv { if (process.platform === "win32") { return { ...process.env, ...telemetryOverrides(), ...ownerTag }; } - return buildDaemonEnv(process.env, cachedShellEnv, { ...telemetryOverrides(), ...ownerTag }); + // Point the daemon at the bundled tmux fallback; it is used only when no + // system tmux resolves from PATH (see backend tmux.ResolveBinary). + const bundledTmux = bundledTmuxPath(app.isPackaged, process.resourcesPath, process.platform, existsSync); + return buildDaemonEnv(process.env, cachedShellEnv, { + ...telemetryOverrides(), + ...ownerTag, + ...(bundledTmux ? { AO_BUNDLED_TMUX: bundledTmux } : {}), + }); } function pathKey(value: string): string { diff --git a/frontend/src/shared/daemon-launch.test.ts b/frontend/src/shared/daemon-launch.test.ts index a85f8ff5cb..91f2e18027 100644 --- a/frontend/src/shared/daemon-launch.test.ts +++ b/frontend/src/shared/daemon-launch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveDaemonLaunch } from "./daemon-launch"; +import { bundledTmuxPath, resolveDaemonLaunch } from "./daemon-launch"; describe("resolveDaemonLaunch", () => { it("uses AO_DAEMON_COMMAND when configured", () => { @@ -54,3 +54,24 @@ describe("resolveDaemonLaunch", () => { }); }); }); + +describe("bundledTmuxPath", () => { + const resources = "/Applications/Agent Orchestrator.app/Contents/Resources"; + const bundled = `${resources}/tmux-dist/tmux`; + + it("returns the resource path when packaged and the binary exists", () => { + expect(bundledTmuxPath(true, resources, "darwin", (p) => p === bundled)).toBe(bundled); + }); + + it("returns null in dev so the daemon resolves system tmux only", () => { + expect(bundledTmuxPath(false, resources, "darwin", () => true)).toBeNull(); + }); + + it("returns null on Windows where ConPTY needs no tmux", () => { + expect(bundledTmuxPath(true, "C:\\AO\\resources", "win32", () => true)).toBeNull(); + }); + + it("returns null when the resource is missing (AO_SKIP_TMUX_FETCH builds)", () => { + expect(bundledTmuxPath(true, resources, "linux", () => false)).toBeNull(); + }); +}); diff --git a/frontend/src/shared/daemon-launch.ts b/frontend/src/shared/daemon-launch.ts index c23817101c..3846475c53 100644 --- a/frontend/src/shared/daemon-launch.ts +++ b/frontend/src/shared/daemon-launch.ts @@ -50,3 +50,21 @@ export function resolveDaemonLaunch( source: "bundled", }; } + +// bundledTmuxPath locates the static tmux fallback shipped as an +// extraResource (see fetch-tmux.mjs), stamped into the daemon env as +// AO_BUNDLED_TMUX. Null when not packaged (dev uses system tmux), on +// Windows (ConPTY needs no tmux), or when the resource is absent +// (AO_SKIP_TMUX_FETCH builds) — the daemon then resolves PATH only. +export function bundledTmuxPath( + isPackaged: boolean, + resourcesPath: string, + platform: NodeJS.Platform, + exists: (path: string) => boolean, +): string | null { + if (!isPackaged || platform === "win32") { + return null; + } + const path = joinPath(resourcesPath, "tmux-dist", "tmux"); + return exists(path) ? path : null; +} From d20462d6b71e2e478d454629d61aa58d221e5fbc Mon Sep 17 00:00:00 2001 From: Suhotra Dey <50608734+Lucifer4255@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:56:43 +0530 Subject: [PATCH 5/6] docs: bundled tmux provenance, resolution order, and rollover procedure --- frontend/docs/desktop-release.md | 31 +++++++++++++++++++ .../src/landing/content/docs/installation.mdx | 4 ++- .../content/docs/plugins/runtimes/tmux.mdx | 6 ++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/docs/desktop-release.md b/frontend/docs/desktop-release.md index a2bed4cc95..43bb0b6fae 100644 --- a/frontend/docs/desktop-release.md +++ b/frontend/docs/desktop-release.md @@ -47,3 +47,34 @@ git push origin desktop-v0.1.0 The workflow publishes a GitHub Release with the installers. Installed apps check the Releases feed on launch (`update-electron-app`) and prompt to restart when an update is downloaded. + +## Bundled tmux provenance (issue #2443) + +macOS/Linux installers ship a static tmux at `Resources/tmux-dist/tmux` as a +**private fallback**: the daemon resolves `AO_TMUX_BIN` (explicit override) → +system tmux on `PATH` → this bundle → clear `RUNTIME_PREREQUISITE_MISSING` +error. A system tmux always wins; nothing is installed onto the user's machine. + +- **Where the binaries come from.** The manual-dispatch + `.github/workflows/tmux-artifacts.yml` workflow compiles tmux + libevent + + ncurses from official source tarballs whose sha256s are pinned in the + workflow env, natively per target (macOS arm64/x64: static deps, dynamic + only against libSystem, deployment target 11.0; Linux x64: musl fully-static + on alpine, with terminfo search dirs `/etc/terminfo:/lib/terminfo:/usr/share/terminfo` + and compiled-in xterm/screen/tmux fallbacks so terminfo-less hosts work). + It publishes a `tmux-artifacts-v-` GitHub release with a + `checksums.txt` and full provenance notes. +- **How packaging consumes them.** `scripts/fetch-tmux.mjs` (run by the + `prepackage`/`premake`/`publish` hooks, before signing) downloads the asset + for the build host's platform/arch and verifies it against the sha256 map + pinned at the top of that script, then Forge ships `tmux-dist` as an + `extraResource` so macOS signing/notarization seals it into the bundle. + `AO_SKIP_TMUX_FETCH=1` skips the fetch for offline dev packaging (the build + then has no bundled fallback). deb/rpm additionally declare a system tmux + dependency. +- **Rolling a new tmux version.** Dispatch the workflow with the new versions + (update the source-tarball sha256s in the workflow env first), wait for the + `tmux-artifacts-v*` release, then update `TMUX_DIST_TAG` and the binary + sha256 map in `scripts/fetch-tmux.mjs` from the release's `checksums.txt`. +- **Licenses.** tmux ISC, libevent BSD-3-Clause, ncurses MIT-X11 — all + redistribution-compatible; the artifact release notes restate them. diff --git a/frontend/src/landing/content/docs/installation.mdx b/frontend/src/landing/content/docs/installation.mdx index 1fffc19c02..2fc2074079 100644 --- a/frontend/src/landing/content/docs/installation.mdx +++ b/frontend/src/landing/content/docs/installation.mdx @@ -152,7 +152,9 @@ Use Slack, Discord, webhook, or another network notifier for alerts. Desktop not authenticated. - Install tmux on macOS or Linux, or set `defaults.runtime: process` if you are on Windows or running in a container. + The desktop app falls back to its bundled tmux automatically, so this only affects CLI-only setups: install tmux + on macOS or Linux (a system tmux always takes precedence over the bundled copy), or set `defaults.runtime: + process` if you are on Windows or running in a container. Install one supported agent CLI and run it once outside AO so it can complete its own sign-in flow. diff --git a/frontend/src/landing/content/docs/plugins/runtimes/tmux.mdx b/frontend/src/landing/content/docs/plugins/runtimes/tmux.mdx index bc253bf71f..bfad718a6c 100644 --- a/frontend/src/landing/content/docs/plugins/runtimes/tmux.mdx +++ b/frontend/src/landing/content/docs/plugins/runtimes/tmux.mdx @@ -45,6 +45,12 @@ sudo pacman -S tmux AO needs tmux 3.2 or newer. +The **desktop app** ships its own static tmux as a private fallback, so a clean +machine works without installing anything. A system tmux on `PATH` always wins +over the bundled copy, and `AO_TMUX_BIN` overrides both. Nothing is ever +installed onto your system. CLI-only (`ao start`) setups still need a system +tmux. + ## Use ```yaml title="agent-orchestrator.yaml" From 511ad065c0f01d4b5584c3d54d3d61ae6b495ec1 Mon Sep 17 00:00:00 2001 From: Suhotra Dey <50608734+Lucifer4255@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:11:56 +0530 Subject: [PATCH 6/6] feat(fetch-tmux): enhance checksum handling and add tests Updated fetch-tmux.mjs to improve checksum verification with optional sha256 pins for provenance. Introduced functions to parse checksums and resolve expected hashes. Added a new test suite for these functionalities to ensure correctness. --- frontend/scripts/fetch-tmux.mjs | 160 ++++++++++++++++++++------- frontend/scripts/fetch-tmux.test.mjs | 50 +++++++++ 2 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 frontend/scripts/fetch-tmux.test.mjs diff --git a/frontend/scripts/fetch-tmux.mjs b/frontend/scripts/fetch-tmux.mjs index 2ffd33ae38..eff56d2b2b 100644 --- a/frontend/scripts/fetch-tmux.mjs +++ b/frontend/scripts/fetch-tmux.mjs @@ -14,72 +14,146 @@ import { fileURLToPath } from "node:url"; const TMUX_DIST_TAG = "tmux-artifacts-v3.5a-1"; const TMUX_DIST_REPO = "AgentWrapper/agent-orchestrator"; -// sha256 of the built binaries, from the release's checksums.txt. Bumping -// TMUX_DIST_TAG requires refreshing every hash here. +// Optional sha256 pins for in-repo provenance. After dispatching +// tmux-artifacts, copy each asset hash from the release's checksums.txt +// here; when set, the pin must match checksums.txt or packaging fails. +// Bumping TMUX_DIST_TAG requires refreshing every pin. const TMUX_DIST = { "darwin-arm64": { asset: "tmux-darwin-arm64", - sha256: "0000000000000000000000000000000000000000000000000000000000000000", }, "darwin-x64": { asset: "tmux-darwin-x64", - sha256: "0000000000000000000000000000000000000000000000000000000000000000", }, "linux-x64": { asset: "tmux-linux-x64", - sha256: "0000000000000000000000000000000000000000000000000000000000000000", }, }; -const scriptsDir = dirname(fileURLToPath(import.meta.url)); -const frontendRoot = resolve(scriptsDir, ".."); -const outDir = join(frontendRoot, "tmux-dist"); -const outPath = join(outDir, "tmux"); +export const PLACEHOLDER_SHA256 = "0000000000000000000000000000000000000000000000000000000000000000"; + +/** @param {string} text */ +export function parseChecksumsFile(text) { + /** @type {Map} */ + const map = new Map(); + for (const line of text.trim().split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^([a-f0-9]{64})\s+(.+)$/); + if (!match) continue; + map.set(match[2], match[1]); + } + return map; +} -if (process.platform === "win32") { - console.log("fetch-tmux: Windows uses the built-in ConPTY runtime; nothing to bundle."); - process.exit(0); +/** @param {string | undefined} hash */ +export function isPlaceholderSha256(hash) { + return !hash || hash === PLACEHOLDER_SHA256; } -const key = `${process.platform}-${process.arch}`; -const dist = TMUX_DIST[key]; -if (!dist) { - // e.g. linux-arm64: not a published desktop target yet. The app still - // works wherever a system tmux exists. - console.warn(`fetch-tmux: no pinned tmux artifact for ${key}; skipping bundle.`); - process.exit(0); +/** + * @param {string | undefined} pinnedSha256 + * @param {Map} checksums + * @param {string} asset + */ +export function resolveExpectedHash(pinnedSha256, checksums, asset) { + const fromRelease = checksums.get(asset); + if (!fromRelease) { + throw new Error(`asset ${asset} not found in release checksums.txt`); + } + if (!isPlaceholderSha256(pinnedSha256)) { + if (pinnedSha256 !== fromRelease) { + throw new Error(`pinned sha256 for ${asset} does not match release checksums.txt`); + } + return pinnedSha256; + } + return fromRelease; } -if (process.env.AO_SKIP_TMUX_FETCH === "1") { - console.warn("fetch-tmux: AO_SKIP_TMUX_FETCH=1, skipping; the package will have no bundled tmux fallback."); - process.exit(0); +/** @param {string} tag @param {string} repo */ +export function releaseAssetUrl(tag, repo, asset) { + return `https://github.com/${repo}/releases/download/${tag}/${asset}`; } +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const frontendRoot = resolve(scriptsDir, ".."); +const outDir = join(frontendRoot, "tmux-dist"); +const outPath = join(outDir, "tmux"); + function sha256(buf) { return createHash("sha256").update(buf).digest("hex"); } -if (existsSync(outPath) && sha256(readFileSync(outPath)) === dist.sha256) { - console.log(`fetch-tmux: cached ${key} binary matches pin; skipping download.`); - process.exit(0); -} +async function main() { + if (process.platform === "win32") { + console.log("fetch-tmux: Windows uses the built-in ConPTY runtime; nothing to bundle."); + return; + } -const url = `https://github.com/${TMUX_DIST_REPO}/releases/download/${TMUX_DIST_TAG}/${dist.asset}`; -console.log(`fetch-tmux: downloading ${url}`); -const res = await fetch(url, { redirect: "follow" }); -if (!res.ok) { - console.error(`fetch-tmux: download failed: HTTP ${res.status} ${res.statusText}`); - console.error("fetch-tmux: set AO_SKIP_TMUX_FETCH=1 to package without the bundled fallback (dev only)."); - process.exit(1); -} -const body = Buffer.from(await res.arrayBuffer()); -const got = sha256(body); -if (got !== dist.sha256) { - console.error(`fetch-tmux: checksum mismatch for ${dist.asset}: got ${got}, want ${dist.sha256}`); - process.exit(1); + const key = `${process.platform}-${process.arch}`; + const dist = TMUX_DIST[key]; + if (!dist) { + // e.g. linux-arm64: not a published desktop target yet. The app still + // works wherever a system tmux exists. + console.warn(`fetch-tmux: no pinned tmux artifact for ${key}; skipping bundle.`); + return; + } + + if (process.env.AO_SKIP_TMUX_FETCH === "1") { + console.warn("fetch-tmux: AO_SKIP_TMUX_FETCH=1, skipping; the package will have no bundled tmux fallback."); + return; + } + + const checksumsUrl = releaseAssetUrl(TMUX_DIST_TAG, TMUX_DIST_REPO, "checksums.txt"); + console.log(`fetch-tmux: fetching ${checksumsUrl}`); + const checksumsRes = await fetch(checksumsUrl, { redirect: "follow" }); + if (!checksumsRes.ok) { + console.error(`fetch-tmux: checksums.txt download failed: HTTP ${checksumsRes.status} ${checksumsRes.statusText}`); + console.error(`fetch-tmux: dispatch .github/workflows/tmux-artifacts.yml to publish ${TMUX_DIST_TAG}, or set AO_SKIP_TMUX_FETCH=1 for dev packaging.`); + process.exit(1); + } + + let expectedHash; + try { + expectedHash = resolveExpectedHash(dist.sha256, parseChecksumsFile(await checksumsRes.text()), dist.asset); + } catch (err) { + console.error(`fetch-tmux: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + + if (isPlaceholderSha256(dist.sha256)) { + console.warn( + `fetch-tmux: no sha256 pin for ${dist.asset}; using release checksums.txt (pin it in fetch-tmux.mjs after verifying provenance).`, + ); + } + + if (existsSync(outPath) && sha256(readFileSync(outPath)) === expectedHash) { + console.log(`fetch-tmux: cached ${key} binary matches pin; skipping download.`); + return; + } + + const url = releaseAssetUrl(TMUX_DIST_TAG, TMUX_DIST_REPO, dist.asset); + console.log(`fetch-tmux: downloading ${url}`); + const res = await fetch(url, { redirect: "follow" }); + if (!res.ok) { + console.error(`fetch-tmux: download failed: HTTP ${res.status} ${res.statusText}`); + console.error("fetch-tmux: set AO_SKIP_TMUX_FETCH=1 to package without the bundled fallback (dev only)."); + process.exit(1); + } + const body = Buffer.from(await res.arrayBuffer()); + const got = sha256(body); + if (got !== expectedHash) { + console.error(`fetch-tmux: checksum mismatch for ${dist.asset}: got ${got}, want ${expectedHash}`); + process.exit(1); + } + + mkdirSync(outDir, { recursive: true }); + writeFileSync(outPath, body); + chmodSync(outPath, 0o755); + console.log(`fetch-tmux: wrote ${outPath} (${body.length} bytes, sha256 verified).`); } -mkdirSync(outDir, { recursive: true }); -writeFileSync(outPath, body); -chmodSync(outPath, 0o755); -console.log(`fetch-tmux: wrote ${outPath} (${body.length} bytes, sha256 verified).`); +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + await main(); +} diff --git a/frontend/scripts/fetch-tmux.test.mjs b/frontend/scripts/fetch-tmux.test.mjs new file mode 100644 index 0000000000..ea309bb2a7 --- /dev/null +++ b/frontend/scripts/fetch-tmux.test.mjs @@ -0,0 +1,50 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { + isPlaceholderSha256, + parseChecksumsFile, + PLACEHOLDER_SHA256, + resolveExpectedHash, +} from "./fetch-tmux.mjs"; + +const HASH_ARM64 = "a".repeat(64); +const HASH_LINUX = "b".repeat(64); +const CHECKSUMS = `${HASH_ARM64} tmux-darwin-arm64\n${HASH_LINUX} tmux-linux-x64\n`; + +describe("parseChecksumsFile", () => { + it("parses sha256sum lines into an asset map", () => { + const map = parseChecksumsFile(CHECKSUMS); + expect(map.get("tmux-darwin-arm64")).toBe(HASH_ARM64); + expect(map.get("tmux-linux-x64")).toBe(HASH_LINUX); + }); +}); + +describe("isPlaceholderSha256", () => { + it("treats all-zero pins as unset", () => { + expect(isPlaceholderSha256(PLACEHOLDER_SHA256)).toBe(true); + expect(isPlaceholderSha256(undefined)).toBe(true); + expect(isPlaceholderSha256("c".repeat(64))).toBe(false); + }); +}); + +describe("resolveExpectedHash", () => { + const checksums = parseChecksumsFile(CHECKSUMS); + + it("uses release checksums when no pin is set", () => { + expect(resolveExpectedHash(undefined, checksums, "tmux-darwin-arm64")).toBe(HASH_ARM64); + }); + + it("rejects placeholder pins instead of treating them as real hashes", () => { + expect(resolveExpectedHash(PLACEHOLDER_SHA256, checksums, "tmux-darwin-arm64")).toBe(HASH_ARM64); + }); + + it("accepts a matching in-repo pin", () => { + expect(resolveExpectedHash(HASH_ARM64, checksums, "tmux-darwin-arm64")).toBe(HASH_ARM64); + }); + + it("fails when a pin disagrees with the release", () => { + expect(() => resolveExpectedHash("c".repeat(64), checksums, "tmux-darwin-arm64")).toThrow( + /does not match release checksums.txt/, + ); + }); +});