185 lines
6.8 KiB
Go
185 lines
6.8 KiB
Go
package launcher
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
type runtimeBundle struct {
|
|
Dir string
|
|
Launcher string
|
|
Source string
|
|
}
|
|
|
|
type agentctlRemoteHelper struct {
|
|
Name string
|
|
Label string
|
|
// MustBeSigned requires the Mach-O helper to carry an ad-hoc (or real) code
|
|
// signature. Apple Silicon (AMFI) SIGKILLs unsigned darwin/arm64 binaries on
|
|
// launch, so an unsigned arm64 helper would fail on every Mac it's uploaded
|
|
// to — catch that at bundle-validation time, not at remote-launch time.
|
|
// darwin/amd64 is intentionally exempt: Intel macOS does not enforce code
|
|
// signing at exec, and Go's linker does not ad-hoc-sign amd64 cross-builds.
|
|
MustBeSigned bool
|
|
}
|
|
|
|
var requiredAgentctlRemoteHelpers = []agentctlRemoteHelper{
|
|
{Name: "agentctl-linux-amd64", Label: "agentctl linux/amd64 helper"},
|
|
{Name: "agentctl-linux-arm64", Label: "agentctl linux/arm64 helper"},
|
|
{Name: "agentctl-darwin-arm64", Label: "agentctl darwin/arm64 helper", MustBeSigned: true},
|
|
{Name: "agentctl-darwin-amd64", Label: "agentctl darwin/amd64 helper"},
|
|
}
|
|
|
|
func resolveRuntimeBundle() (runtimeBundle, error) {
|
|
if dir := os.Getenv("KANDEV_BUNDLE_DIR"); dir != "" {
|
|
return validateRuntimeBundle(dir, "env")
|
|
}
|
|
if dir, ok := bundleDirFromExecutable(); ok {
|
|
return validateRuntimeBundle(dir, "executable")
|
|
}
|
|
return runtimeBundle{}, fmt.Errorf(
|
|
"no Kandev runtime found; KANDEV_BUNDLE_DIR is not set and no bundle was found next to the launcher")
|
|
}
|
|
|
|
// bundleDirFromExecutable locates the bundle from the running launcher's own
|
|
// path. Homebrew, npm and Scoop each set KANDEV_BUNDLE_DIR through a different
|
|
// mechanism (write_env_script, the native shim, env_set); package managers that
|
|
// only unpack the tree and put the launcher on PATH — winget, Chocolatey, a
|
|
// plain archive extraction — have no equivalent, and the launcher already sits
|
|
// inside the bundle it is looking for.
|
|
//
|
|
// The environment variable keeps priority: `kandev service install` writes it
|
|
// into the systemd unit and launchd plist, and a self-update repoints it at the
|
|
// new bundle while the outgoing launcher is still running.
|
|
func bundleDirFromExecutable() (string, bool) {
|
|
exe, err := executablePath()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
// Windows resolves a symlinked launcher to its target at process creation,
|
|
// but not every platform does, and Homebrew links its wrapper into the
|
|
// prefix. Resolving is best-effort: EvalSymlinks fails on Windows junctions,
|
|
// and the unresolved path is still worth trying.
|
|
if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil {
|
|
exe = resolved
|
|
}
|
|
return bundleDirForLauncherPath(exe)
|
|
}
|
|
|
|
// bundleDirForLauncherPath maps <bundle>/bin/kandev back to <bundle>. Any other
|
|
// layout is refused, so a launcher sitting somewhere unrelated cannot make
|
|
// validateRuntimeBundle probe an arbitrary directory.
|
|
//
|
|
// The directory name must be exactly "bin", matching what validateRuntimeBundle
|
|
// joins onto the result. Accepting other casings here would derive a bundle on a
|
|
// case-sensitive filesystem that validation then rejects, turning a clear "not a
|
|
// bundle" into a confusing "launcher binary not found". Windows is unaffected:
|
|
// EvalSymlinks above canonicalises the path to the casing on disk, so a launcher
|
|
// invoked as BIN\kandev.exe still arrives here as bin\kandev.exe.
|
|
func bundleDirForLauncherPath(exe string) (string, bool) {
|
|
binDir := filepath.Dir(exe)
|
|
if filepath.Base(binDir) != "bin" {
|
|
return "", false
|
|
}
|
|
dir := filepath.Dir(binDir)
|
|
if dir == "" || dir == binDir {
|
|
return "", false
|
|
}
|
|
return dir, true
|
|
}
|
|
|
|
func validateRuntimeBundle(dir, source string) (runtimeBundle, error) {
|
|
launcher := filepath.Join(dir, "bin", executableName("kandev"))
|
|
if !exists(launcher) {
|
|
return runtimeBundle{}, fmt.Errorf("launcher binary not found in bundle at %s", launcher)
|
|
}
|
|
agentctl := filepath.Join(dir, "bin", executableName("agentctl"))
|
|
if !exists(agentctl) {
|
|
return runtimeBundle{}, fmt.Errorf("agentctl binary not found in bundle at %s", agentctl)
|
|
}
|
|
for _, helper := range requiredAgentctlRemoteHelpers {
|
|
path := filepath.Join(dir, "bin", helper.Name)
|
|
if !exists(path) {
|
|
return runtimeBundle{}, fmt.Errorf("%s not found in bundle at %s", helper.Label, path)
|
|
}
|
|
if helper.MustBeSigned {
|
|
signed, ok := machoHasCodeSignature(path)
|
|
if !ok {
|
|
// Fail closed: a helper we can't parse as a thin arm64 Mach-O
|
|
// (read error, truncation, unexpected/fat layout) can't be
|
|
// verified as signed, and an unverifiable darwin/arm64 helper
|
|
// would still be SIGKILLed by Apple Silicon at launch.
|
|
return runtimeBundle{}, fmt.Errorf(
|
|
"%s at %s is not a parsable thin darwin/arm64 Mach-O; cannot verify its "+
|
|
"code signature (rebuild it via 'make -C apps/backend build-agentctl-remote')",
|
|
helper.Label, path)
|
|
}
|
|
if !signed {
|
|
return runtimeBundle{}, fmt.Errorf(
|
|
"%s at %s is not code-signed; Apple Silicon will refuse to run it "+
|
|
"(build it via 'make -C apps/backend build-agentctl-remote', which ad-hoc-signs darwin helpers)",
|
|
helper.Label, path)
|
|
}
|
|
}
|
|
}
|
|
return runtimeBundle{Dir: dir, Launcher: launcher, Source: source}, nil
|
|
}
|
|
|
|
// machoHasCodeSignature reports whether a thin 64-bit Mach-O carries an
|
|
// LC_CODE_SIGNATURE load command. The second return is false when the file
|
|
// can't be parsed as a thin Mach-O (e.g. unexpected format) so callers can
|
|
// treat "undeterminable" as non-fatal rather than failing on a parse quirk.
|
|
func machoHasCodeSignature(path string) (signed bool, ok bool) {
|
|
const (
|
|
machoMagic64LE = 0xfeedfacf // MH_MAGIC_64, written little-endian on disk
|
|
lcCodeSignature = 0x1d // LC_CODE_SIGNATURE
|
|
machoHeaderSize64 = 32
|
|
loadCommandHdrSize = 8
|
|
)
|
|
const maxScanBytes = 64 * 1024 // more than enough for any real Mach-O load-command table
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
data := make([]byte, maxScanBytes)
|
|
n, err := io.ReadFull(f, data)
|
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
|
return false, false
|
|
}
|
|
data = data[:n]
|
|
if len(data) < machoHeaderSize64 {
|
|
return false, false
|
|
}
|
|
if binary.LittleEndian.Uint32(data[0:4]) != machoMagic64LE {
|
|
return false, false // not a thin LE 64-bit Mach-O (fat/other) — don't judge
|
|
}
|
|
ncmds := binary.LittleEndian.Uint32(data[16:20])
|
|
off := machoHeaderSize64
|
|
for i := uint32(0); i < ncmds; i++ {
|
|
if off+loadCommandHdrSize > len(data) {
|
|
return false, false // truncated load-command table
|
|
}
|
|
cmd := binary.LittleEndian.Uint32(data[off : off+4])
|
|
cmdSize := binary.LittleEndian.Uint32(data[off+4 : off+8])
|
|
if cmd == lcCodeSignature {
|
|
return true, true
|
|
}
|
|
if cmdSize < loadCommandHdrSize {
|
|
return false, false // malformed
|
|
}
|
|
off += int(cmdSize)
|
|
}
|
|
return false, true
|
|
}
|
|
|
|
func executableName(name string) string {
|
|
if os.PathSeparator == '\\' {
|
|
return name + ".exe"
|
|
}
|
|
return name
|
|
}
|