diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e78c38e4f5..69a6bc1c4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -413,16 +413,16 @@ jobs: go build -tags fts5 -ldflags "${LDFLAGS}" -o bin/kandev${EXT} ./cmd/kandev go build -ldflags "${LDFLAGS}" -o bin/agentctl${EXT} ./cmd/agentctl - # Cross-compile a linux/amd64 agentctl regardless of the host platform. - # Remote executors (local Docker, remote Docker, Sprites) bind-mount this - # binary into the container — without it, Docker sessions fail with - # "agentctl linux binary not found" on Mac/Windows installs and even on - # Linux installs (the resolver looks for the -linux-amd64 suffix). - # CGO is off so this works from any host without extra toolchain setup. - - name: Build linux/amd64 agentctl helper + # Cross-compile remote agentctl helpers regardless of the host platform. + # Remote executors upload or bind-mount these sidecars into Linux and + # macOS SSH hosts. CGO is off so this works from any host without extra + # toolchain setup. + - name: Build remote agentctl helpers run: | cd apps/backend GOOS=linux GOARCH=amd64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-linux-amd64 ./cmd/agentctl + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-darwin-arm64 ./cmd/agentctl + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-darwin-amd64 ./cmd/agentctl - name: Package bundle run: | @@ -432,6 +432,8 @@ jobs: cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/ + cp apps/backend/bin/agentctl-darwin-arm64 dist/kandev/bin/ + cp apps/backend/bin/agentctl-darwin-amd64 dist/kandev/bin/ cd dist tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" @@ -594,7 +596,7 @@ jobs: run: | set -euo pipefail runtime_bin="apps/desktop/src-tauri/resources/kandev/bin" - for binary in kandev agentctl; do + for binary in kandev agentctl agentctl-darwin-arm64 agentctl-darwin-amd64; do path="$runtime_bin/$binary" if [ ! -f "$path" ]; then echo "::error::Missing macOS runtime binary at $path" >&2 diff --git a/Dockerfile b/Dockerfile index 7ddf95ad4f..a1fa4a0edd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -88,21 +88,28 @@ RUN mkdir -p /app/apps/backend/bin /data/worktrees # bundle tarball): # bundle/bin/kandev - native Go backend (per-arch) # bundle/bin/agentctl - native agentctl (per-arch) -# bundle/bin/agentctl-linux-amd64 - amd64 agentctl helper (always amd64) +# bundle/bin/agentctl-linux-amd64 - linux/amd64 agentctl helper +# bundle/bin/agentctl-darwin-arm64 - darwin/arm64 agentctl helper +# bundle/bin/agentctl-darwin-amd64 - darwin/amd64 agentctl helper # -# The bundle's `bin/agentctl-linux-amd64` variant is bind-mounted into -# Docker-executor sandboxes by the lifecycle manager; ship it next to kandev -# so the AgentctlResolver finds it without manual configuration. -COPY bundle/bin/kandev /app/apps/backend/bin/kandev -COPY bundle/bin/agentctl-linux-amd64 /app/apps/backend/bin/agentctl-linux-amd64 -COPY bundle/bin/agentctl /usr/local/bin/agentctl -COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +# The bundle's platform-specific agentctl helpers are uploaded into remote +# SSH hosts and bind-mounted into Docker-executor sandboxes by the lifecycle +# manager; ship them next to kandev so the AgentctlResolver finds them without +# manual configuration. +COPY bundle/bin/kandev /app/apps/backend/bin/kandev +COPY bundle/bin/agentctl-linux-amd64 /app/apps/backend/bin/agentctl-linux-amd64 +COPY bundle/bin/agentctl-darwin-arm64 /app/apps/backend/bin/agentctl-darwin-arm64 +COPY bundle/bin/agentctl-darwin-amd64 /app/apps/backend/bin/agentctl-darwin-amd64 +COPY bundle/bin/agentctl /usr/local/bin/agentctl +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh # Re-apply executable bits stripped by tar/COPY edge cases, then link the native # launcher onto PATH. RUN chmod +x \ /app/apps/backend/bin/kandev \ /app/apps/backend/bin/agentctl-linux-amd64 \ + /app/apps/backend/bin/agentctl-darwin-arm64 \ + /app/apps/backend/bin/agentctl-darwin-amd64 \ /usr/local/bin/agentctl \ /usr/local/bin/docker-entrypoint.sh && \ ln -s /app/apps/backend/bin/kandev /usr/local/bin/kandev && \ diff --git a/Makefile b/Makefile index 77b6cbf282..265c67977e 100644 --- a/Makefile +++ b/Makefile @@ -147,31 +147,14 @@ bootstrap: bootstrap-e2e: @scripts/bootstrap-dev-env --with-e2e -# Build the linux/amd64 agentctl on every host except Linux/x86_64 (where the -# host build IS already linux/amd64). Computed up here, OS-conditionally, so -# the recipe doesn't shell out to `uname` — that fails on Windows under cmd -# and produces spurious "CreateProcess(NULL, uname -s, ...) failed" warnings -# on every Make invocation. -ifeq ($(OS),Windows_NT) - HOST_IS_LINUX_AMD64 := 0 -else - ifeq ($(shell uname -s)/$(shell uname -m),Linux/x86_64) - HOST_IS_LINUX_AMD64 := 1 - else - HOST_IS_LINUX_AMD64 := 0 - endif -endif - .PHONY: doctor doctor: @scripts/doctor .PHONY: dev dev: doctor -ifneq ($(HOST_IS_LINUX_AMD64),1) - @echo "Building Linux agentctl for remote executors..." - @$(MAKE) -C $(BACKEND_DIR) build-agentctl-linux -endif + @echo "Building remote agentctl helpers..." + @$(MAKE) -C $(BACKEND_DIR) build-agentctl-remote ifeq ($(OS),Windows_NT) @echo "Building winjob (Ctrl-C-safe wrapper for Windows)..." @$(MAKE) -C $(BACKEND_DIR) build-winjob @@ -278,10 +261,14 @@ service-bundle: install build $(call phase,Packaging Service Bundle) @test -n "$(SERVICE_BUNDLE_DIR)" || { echo "SERVICE_BUNDLE_DIR is empty; aborting."; exit 1; } @test "$(SERVICE_BUNDLE_DIR)" != "/" || { echo "SERVICE_BUNDLE_DIR must not be /; aborting."; exit 1; } - @test -f "$(BACKEND_DIR)/bin/agentctl-linux-amd64" || $(MAKE) -C $(BACKEND_DIR) build-agentctl-linux + @$(MAKE) -C $(BACKEND_DIR) build-agentctl-remote @$(RMDIR) "$(SERVICE_BUNDLE_DIR)/bin" @mkdir -p "$(SERVICE_BUNDLE_DIR)/bin" - @cp "$(BACKEND_DIR)/bin/kandev" "$(BACKEND_DIR)/bin/agentctl" "$(BACKEND_DIR)/bin/agentctl-linux-amd64" "$(SERVICE_BUNDLE_DIR)/bin/" + @cp "$(BACKEND_DIR)/bin/kandev" "$(BACKEND_DIR)/bin/agentctl" \ + "$(BACKEND_DIR)/bin/agentctl-linux-amd64" \ + "$(BACKEND_DIR)/bin/agentctl-darwin-arm64" \ + "$(BACKEND_DIR)/bin/agentctl-darwin-amd64" \ + "$(SERVICE_BUNDLE_DIR)/bin/" @scripts/release/package-bundle.sh $(call success,Service bundle packaged at $(SERVICE_BUNDLE_DIR)) @@ -327,10 +314,12 @@ build-backend: @printf "$(CYAN)Building backend...$(RESET)\n" @$(MAKE) -C $(BACKEND_DIR) build -.PHONY: build-backend-linux-helpers -build-backend-linux-helpers: - @printf "$(CYAN)Building linux/amd64 helper binaries (agentctl + mock-agent) for Docker E2E...$(RESET)\n" - @$(MAKE) -C $(BACKEND_DIR) build-agentctl-linux build-mock-agent-linux +.PHONY: build-backend-remote-helpers build-backend-linux-helpers +build-backend-remote-helpers: + @printf "$(CYAN)Building remote helper binaries (agentctl helpers + mock-agent) for executor E2E...$(RESET)\n" + @$(MAKE) -C $(BACKEND_DIR) build-agentctl-remote build-mock-agent-linux + +build-backend-linux-helpers: build-backend-remote-helpers .PHONY: acpdbg acpdbg: diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 44a8b4b982..e4ba2248e7 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -18,7 +18,7 @@ LDFLAGS := -s -w # # Install GNU Make on Windows with `winget install ezwinports.make` (or via # scoop/chocolatey). Targets that rely on Unix builtins (run, dev, -# start-debug, build-cross, build-agentctl-linux) remain Unix-only. +# start-debug, build-cross) remain Unix-only. ifeq ($(OS),Windows_NT) RM = cmd /c del /s /q RMDIR = cmd /c rmdir /s /q @@ -27,10 +27,12 @@ ifeq ($(OS),Windows_NT) # "already exists" failure is tolerated; on Unix the prefix is a no-op. MKDIR = cmd /c mkdir CGO_PREFIX = set CGO_ENABLED=1& - # Cross-compile prefix for the linux/amd64 agentctl bundled with the host - # build (used by remote-Sprites executors). cmd has no Unix env-var-prefix - # syntax; `set X=Y&` chains `set` and the next command in the same shell. + # Cross-compile prefixes for agentctl helpers bundled with the host build. + # cmd has no Unix env-var-prefix syntax; `set X=Y&` chains `set` and the next + # command in the same shell. GO_LINUX_AMD64 = set CGO_ENABLED=0& set GOOS=linux& set GOARCH=amd64& + GO_DARWIN_ARM64 = set CGO_ENABLED=0& set GOOS=darwin& set GOARCH=arm64& + GO_DARWIN_AMD64 = set CGO_ENABLED=0& set GOOS=darwin& set GOARCH=amd64& EXE = .exe else RM = rm -f @@ -38,6 +40,8 @@ else MKDIR = mkdir -p CGO_PREFIX = CGO_ENABLED=1 GO_LINUX_AMD64 = CGO_ENABLED=0 GOOS=linux GOARCH=amd64 + GO_DARWIN_ARM64 = CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 + GO_DARWIN_AMD64 = CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 EXE = endif @@ -48,7 +52,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build build-all build-agentctl build-agentctl-linux build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint fmt vet help +.PHONY: all build build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint fmt vet help ## Default target all: build @@ -84,11 +88,10 @@ build-acpdbg: -@$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/acpdbg$(EXE) ./cmd/acpdbg -## Build the unified kandev binary. Always includes the linux/amd64 agentctl -## variant because remote executors (local Docker, remote Docker, Sprites) -## bind-mount it into containers — without it, the first Docker session on a -## non-Linux host fails with "agentctl linux binary not found". -build: build-agentctl build-agentctl-linux build-mock-agent build-acpdbg build-winjob +## Build the unified kandev binary. Always includes remote agentctl helpers +## because SSH/Docker/Sprites executors upload or bind-mount platform-specific +## sidecars into remote environments. +build: build-agentctl build-agentctl-remote build-mock-agent build-acpdbg build-winjob @echo "Building $(BINARY_NAME)..." -@$(MKDIR) $(BUILD_DIR) $(CGO_PREFIX) $(GO) build $(GOFLAGS) -tags fts5 -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)$(EXE) ./cmd/kandev @@ -104,12 +107,27 @@ build-agentctl: -@$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl$(EXE) ./cmd/agentctl -## Build agentctl for linux/amd64 (used by Sprites remote executor) +## Build all remote agentctl helpers used by SSH/Docker/Sprites executors. +build-agentctl-remote: build-agentctl-linux build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 + +## Build agentctl for linux/amd64 (used by Docker/Sprites and Linux SSH hosts) build-agentctl-linux: @echo "Building agentctl for linux/amd64..." -@$(MKDIR) $(BUILD_DIR) $(GO_LINUX_AMD64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-linux-amd64 ./cmd/agentctl +## Build agentctl for darwin/arm64 (used by Apple Silicon SSH hosts) +build-agentctl-darwin-arm64: + @echo "Building agentctl for darwin/arm64..." + -@$(MKDIR) $(BUILD_DIR) + $(GO_DARWIN_ARM64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-darwin-arm64 ./cmd/agentctl + +## Build agentctl for darwin/amd64 (used by Intel Mac SSH hosts) +build-agentctl-darwin-amd64: + @echo "Building agentctl for darwin/amd64..." + -@$(MKDIR) $(BUILD_DIR) + $(GO_DARWIN_AMD64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-darwin-amd64 ./cmd/agentctl + ## Build mock-agent for linux/amd64 (used by Docker E2E tests) build-mock-agent-linux: @echo "Building mock-agent for linux/amd64..." diff --git a/apps/backend/internal/agent/runtime/lifecycle/agentctl_resolver.go b/apps/backend/internal/agent/runtime/lifecycle/agentctl_resolver.go index 9928cf76c4..bec5a8f99c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/agentctl_resolver.go +++ b/apps/backend/internal/agent/runtime/lifecycle/agentctl_resolver.go @@ -5,17 +5,20 @@ import ( "os" "path/filepath" "runtime" + "strings" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" ) -// AgentctlResolver finds the path to a linux/amd64 agentctl binary for remote executors. +// AgentctlResolver finds the path to platform-specific agentctl binaries for +// remote executors. // Resolution order: -// 1. KANDEV_AGENTCTL_LINUX_BINARY env var -// 2. build/agentctl-linux-amd64 relative to the running binary (dev mode) -// 3. bin/agentctl-linux-amd64 relative to the running binary +// 1. KANDEV_AGENTCTL___BINARY env var +// 2. KANDEV_AGENTCTL_LINUX_BINARY legacy env var for linux/amd64 +// 3. platform-suffixed helper relative to the running binary +// 4. native agentctl when the remote platform matches the control plane type AgentctlResolver struct { logger *logger.Logger } @@ -29,36 +32,84 @@ func NewAgentctlResolver(log *logger.Logger) *AgentctlResolver { // ResolveLinuxBinary returns the path to a linux/amd64 agentctl binary. func (r *AgentctlResolver) ResolveLinuxBinary() (string, error) { - // 1. Env var override - if envPath := os.Getenv("KANDEV_AGENTCTL_LINUX_BINARY"); envPath != "" { + return r.ResolveRemoteBinary(SSHRemotePlatform{GOOS: sshRemoteGOOSLinux, GOARCH: sshRemoteGOARCHAMD64}) +} + +// ResolveRemoteBinary returns the path to an agentctl binary for the given +// remote platform. +func (r *AgentctlResolver) ResolveRemoteBinary(platform SSHRemotePlatform) (string, error) { + if err := requireSupportedRemotePlatform(platform); err != nil { + return "", err + } + for _, envName := range agentctlBinaryEnvNames(platform) { + envPath := os.Getenv(envName) + if envPath == "" { + continue + } if _, err := os.Stat(envPath); err == nil { - r.logger.Debug("using agentctl from env var", zap.String("path", envPath)) + r.logger.Debug("using agentctl from env var", + zap.String("env", envName), + zap.String("path", envPath), + zap.String("remote_platform", platform.String())) return envPath, nil } - return "", fmt.Errorf("KANDEV_AGENTCTL_LINUX_BINARY=%q does not exist", envPath) + return "", fmt.Errorf("%s=%q does not exist", envName, envPath) } - // 2. Relative to the running binary (dev mode: build/agentctl-linux-amd64) exePath, err := os.Executable() if err == nil { exeDir := filepath.Dir(exePath) - candidates := []string{ - filepath.Join(exeDir, "agentctl-linux-amd64"), - filepath.Join(exeDir, "..", "build", "agentctl-linux-amd64"), - filepath.Join(exeDir, "..", "bin", "agentctl-linux-amd64"), - } + candidates := agentctlBinaryCandidates(exeDir, platform) for _, candidate := range candidates { if _, statErr := os.Stat(candidate); statErr == nil { abs, _ := filepath.Abs(candidate) - r.logger.Debug("found agentctl binary", zap.String("path", abs)) + r.logger.Debug("found agentctl binary", + zap.String("path", abs), + zap.String("remote_platform", platform.String())) return abs, nil } } } return "", fmt.Errorf( - "agentctl linux binary not found; build it with 'make build-agentctl-linux' "+ - "or set KANDEV_AGENTCTL_LINUX_BINARY (os=%s arch=%s)", - runtime.GOOS, runtime.GOARCH, + "agentctl helper for %s not found; build it with 'make build-agentctl-remote' "+ + "or set %s (control plane os=%s arch=%s)", + platform.String(), agentctlBinaryEnvNames(platform)[0], runtime.GOOS, runtime.GOARCH, ) } + +func agentctlBinaryName(platform SSHRemotePlatform) string { + return fmt.Sprintf("agentctl-%s-%s", platform.GOOS, platform.GOARCH) +} + +func agentctlBinaryEnvNames(platform SSHRemotePlatform) []string { + primary := fmt.Sprintf( + "KANDEV_AGENTCTL_%s_%s_BINARY", + strings.ToUpper(platform.GOOS), + strings.ToUpper(platform.GOARCH), + ) + if platform.GOOS == sshRemoteGOOSLinux && platform.GOARCH == sshRemoteGOARCHAMD64 { + return []string{primary, "KANDEV_AGENTCTL_LINUX_BINARY"} + } + return []string{primary} +} + +func agentctlBinaryCandidates(exeDir string, platform SSHRemotePlatform) []string { + name := agentctlBinaryName(platform) + candidates := []string{ + filepath.Join(exeDir, name), + filepath.Join(exeDir, "..", "build", name), + filepath.Join(exeDir, "..", "bin", name), + } + // Same-platform remotes can use the native binary in development even + // when the suffixed helper has not been built yet. Released bundles are + // still validated separately and must include every supported helper. + if platform.GOOS == runtime.GOOS && platform.GOARCH == runtime.GOARCH { + candidates = append(candidates, + filepath.Join(exeDir, "agentctl"), + filepath.Join(exeDir, "..", "build", "agentctl"), + filepath.Join(exeDir, "..", "bin", "agentctl"), + ) + } + return candidates +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/agentctl_resolver_test.go b/apps/backend/internal/agent/runtime/lifecycle/agentctl_resolver_test.go new file mode 100644 index 0000000000..ce1cfec851 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/agentctl_resolver_test.go @@ -0,0 +1,156 @@ +package lifecycle + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newResolverTestLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("NewFromZap: %v", err) + } + return log +} + +func TestAgentctlResolverRemoteBinaryUsesPlatformEnvOverride(t *testing.T) { + tmp := t.TempDir() + helper := filepath.Join(tmp, "agentctl-darwin-arm64") + if err := os.WriteFile(helper, []byte("stub"), 0o755); err != nil { + t.Fatalf("write helper: %v", err) + } + t.Setenv("KANDEV_AGENTCTL_DARWIN_ARM64_BINARY", helper) + + resolver := NewAgentctlResolver(newResolverTestLogger(t)) + got, err := resolver.ResolveRemoteBinary(SSHRemotePlatform{GOOS: "darwin", GOARCH: "arm64"}) + if err != nil { + t.Fatalf("ResolveRemoteBinary: %v", err) + } + if got != helper { + t.Fatalf("ResolveRemoteBinary = %q, want %q", got, helper) + } +} + +func TestAgentctlResolverLinuxAMD64KeepsLegacyEnvOverride(t *testing.T) { + tmp := t.TempDir() + helper := filepath.Join(tmp, "agentctl-linux-amd64") + if err := os.WriteFile(helper, []byte("stub"), 0o755); err != nil { + t.Fatalf("write helper: %v", err) + } + t.Setenv("KANDEV_AGENTCTL_LINUX_BINARY", helper) + + resolver := NewAgentctlResolver(newResolverTestLogger(t)) + got, err := resolver.ResolveRemoteBinary(SSHRemotePlatform{GOOS: "linux", GOARCH: "amd64"}) + if err != nil { + t.Fatalf("ResolveRemoteBinary: %v", err) + } + if got != helper { + t.Fatalf("ResolveRemoteBinary = %q, want %q", got, helper) + } +} + +func TestAgentctlResolverLinuxAMD64PrefersPrimaryEnvOverride(t *testing.T) { + tmp := t.TempDir() + primary := filepath.Join(tmp, "agentctl-linux-amd64-primary") + legacy := filepath.Join(tmp, "agentctl-linux-amd64-legacy") + for _, helper := range []string{primary, legacy} { + if err := os.WriteFile(helper, []byte("stub"), 0o755); err != nil { + t.Fatalf("write helper %s: %v", helper, err) + } + } + t.Setenv("KANDEV_AGENTCTL_LINUX_AMD64_BINARY", primary) + t.Setenv("KANDEV_AGENTCTL_LINUX_BINARY", legacy) + + resolver := NewAgentctlResolver(newResolverTestLogger(t)) + got, err := resolver.ResolveRemoteBinary(SSHRemotePlatform{GOOS: "linux", GOARCH: "amd64"}) + if err != nil { + t.Fatalf("ResolveRemoteBinary: %v", err) + } + if got != primary { + t.Fatalf("ResolveRemoteBinary = %q, want primary %q", got, primary) + } +} + +func TestAgentctlResolverLinuxAMD64BadPrimaryEnvDoesNotUseLegacyFallback(t *testing.T) { + tmp := t.TempDir() + primary := filepath.Join(tmp, "missing-agentctl") + legacy := filepath.Join(tmp, "agentctl-linux-amd64-legacy") + if err := os.WriteFile(legacy, []byte("stub"), 0o755); err != nil { + t.Fatalf("write legacy helper: %v", err) + } + t.Setenv("KANDEV_AGENTCTL_LINUX_AMD64_BINARY", primary) + t.Setenv("KANDEV_AGENTCTL_LINUX_BINARY", legacy) + + resolver := NewAgentctlResolver(newResolverTestLogger(t)) + got, err := resolver.ResolveRemoteBinary(SSHRemotePlatform{GOOS: "linux", GOARCH: "amd64"}) + if err == nil { + t.Fatalf("ResolveRemoteBinary = %q, want error", got) + } + for _, want := range []string{"KANDEV_AGENTCTL_LINUX_AMD64_BINARY", primary} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want substring %q", err.Error(), want) + } + } +} + +func TestAgentctlResolverRemoteBinaryNotFoundErrorNamesPlatformAndEnv(t *testing.T) { + t.Setenv("KANDEV_AGENTCTL_DARWIN_AMD64_BINARY", "") + + resolver := NewAgentctlResolver(newResolverTestLogger(t)) + _, err := resolver.ResolveRemoteBinary(SSHRemotePlatform{GOOS: "darwin", GOARCH: "amd64"}) + if err == nil { + t.Fatal("expected error") + } + for _, want := range []string{"darwin/amd64", "KANDEV_AGENTCTL_DARWIN_AMD64_BINARY"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want substring %q", err.Error(), want) + } + } +} + +func TestAgentctlBinaryCandidatesIncludesRemoteHelpersAndHostFallback(t *testing.T) { + exeDir := filepath.Join("tmp", "kandev", "bin") + platform := SSHRemotePlatform{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH} + + got := agentctlBinaryCandidates(exeDir, platform) + name := agentctlBinaryName(platform) + want := []string{ + filepath.Join(exeDir, name), + filepath.Join(exeDir, "..", "build", name), + filepath.Join(exeDir, "..", "bin", name), + filepath.Join(exeDir, "agentctl"), + filepath.Join(exeDir, "..", "build", "agentctl"), + filepath.Join(exeDir, "..", "bin", "agentctl"), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("agentctlBinaryCandidates = %#v, want %#v", got, want) + } +} + +func TestAgentctlBinaryCandidatesOmitsHostFallbackForDifferentPlatform(t *testing.T) { + exeDir := filepath.Join("tmp", "kandev", "bin") + platform := SSHRemotePlatform{GOOS: "darwin", GOARCH: "arm64"} + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { + platform = SSHRemotePlatform{GOOS: "linux", GOARCH: "amd64"} + } + + got := agentctlBinaryCandidates(exeDir, platform) + name := agentctlBinaryName(platform) + want := []string{ + filepath.Join(exeDir, name), + filepath.Join(exeDir, "..", "build", name), + filepath.Join(exeDir, "..", "bin", name), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("agentctlBinaryCandidates = %#v, want %#v", got, want) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go index b0df91d568..9c2d6f49ec 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go @@ -43,7 +43,7 @@ type sshSessionState struct { remoteDir string } -// SSHExecutor implements ExecutorBackend for SSH-reachable Linux hosts. +// SSHExecutor implements ExecutorBackend for SSH-reachable Linux and macOS hosts. // // Each session owns its own *ssh.Client (no shared pool). One SSH connection // per session keeps teardown simple — closing the executor instance closes @@ -140,7 +140,7 @@ func (r *SSHExecutor) targetFromMetadata(md map[string]interface{}) (*SSHTarget, PinnedFingerprint: getMetadataString(md, MetadataKeySSHHostFingerprint), } if cfg.PinnedFingerprint == "" { - return nil, errors.New("ssh executor: host_fingerprint is required — re-run Test Connection in settings to trust the host") + return nil, errors.New("ssh executor: host_fingerprint is required — open the SSH executor connection settings, run Test connection, and trust the host") } return ResolveSSHTarget(cfg) } @@ -186,12 +186,12 @@ func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateReq }() r.report(req.OnProgress, "Connecting to SSH host", PrepareStepCompleted, "") - agentctlBin, err := r.prepareRemoteHost(ctx, client, req) + agentctlBin, platform, err := r.prepareRemoteHost(ctx, client, req) if err != nil { return nil, err } - if err := r.preflightAgentBinary(ctx, client, req); err != nil { + if err := r.preflightAgentBinary(ctx, client, req, platform); err != nil { return nil, err } @@ -200,9 +200,9 @@ func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateReq if err != nil { return nil, err } - r.maybeUploadCredentials(ctx, client, req) + r.maybeUploadCredentials(ctx, client, req, platform) - port, pid, fwd, err := r.startAndForwardAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, req) + port, pid, fwd, err := r.startAndForwardAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, req, platform) if err != nil { return nil, err } @@ -224,25 +224,29 @@ func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateReq // prepareRemoteHost runs the steps that are independent of any particular // task: detect remote OS/arch and ensure the agentctl binary is on the host. // Returns the resolved remote agentctl path. -func (r *SSHExecutor) prepareRemoteHost(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) (string, error) { +func (r *SSHExecutor) prepareRemoteHost( + ctx context.Context, + client *ssh.Client, + req *ExecutorCreateRequest, +) (string, SSHRemotePlatform, error) { info, err := detectRemoteInfo(ctx, client) if err != nil { r.report(req.OnProgress, "Detecting remote OS", PrepareStepFailed, err.Error()) - return "", fmt.Errorf("ssh: detect remote: %w", err) + return "", SSHRemotePlatform{}, fmt.Errorf("ssh: detect remote: %w", err) } - if err := requireSupportedArch(info.Arch); err != nil { + if err := requireSupportedRemotePlatform(info.Platform); err != nil { r.report(req.OnProgress, "Detecting remote OS", PrepareStepFailed, err.Error()) - return "", err + return "", info.Platform, err } r.report(req.OnProgress, "Detecting remote OS", PrepareStepCompleted, info.UnameAll) - agentctlBin, err := ensureAgentctlOnHost(ctx, client, r.agentctlResolver, r.logger) + agentctlBin, err := ensureAgentctlOnHost(ctx, client, r.agentctlResolver, info.Platform, r.logger) if err != nil { r.report(req.OnProgress, "Uploading agent controller", PrepareStepFailed, err.Error()) - return "", err + return "", info.Platform, err } r.report(req.OnProgress, "Uploading agent controller", PrepareStepCompleted, "") - return agentctlBin, nil + return agentctlBin, info.Platform, nil } // prepareRemoteDirs makes /tasks/ and /.kandev/sessions/. @@ -274,9 +278,13 @@ func (r *SSHExecutor) prepareRemoteDirs(ctx context.Context, client *ssh.Client, // 3. The SSH executor's clients (agent stream, workspace, etc.) all talk to // that sub-server, so the local port forward points there. func (r *SSHExecutor) startAndForwardAgentctl( - ctx context.Context, client *ssh.Client, agentctlBin, taskDir, sessionDir string, req *ExecutorCreateRequest, + ctx context.Context, + client *ssh.Client, + agentctlBin, taskDir, sessionDir string, + req *ExecutorCreateRequest, + platform SSHRemotePlatform, ) (int, int, *SSHPortForwarder, error) { - shell := sshShellFromMetadata(req.Metadata) + shell := sshShellForRemote(req.Metadata, platform) controlPort, pid, err := startRemoteAgentctl(ctx, client, shell, agentctlBin, taskDir, sessionDir, r.logger) if err != nil { r.report(req.OnProgress, "Starting agent controller", PrepareStepFailed, err.Error()) @@ -551,8 +559,13 @@ func (r *SSHExecutor) IsAlwaysResumable() bool { return true } // one remote-credentials method (or remote_auth_secrets / setup-script env // vars). A failure here doesn't abort the launch — credentials are // best-effort and missing ones surface later as the agent's own auth error. -func (r *SSHExecutor) maybeUploadCredentials(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) { - if err := r.uploadCredentials(ctx, client, req); err != nil { +func (r *SSHExecutor) maybeUploadCredentials( + ctx context.Context, + client *ssh.Client, + req *ExecutorCreateRequest, + platform SSHRemotePlatform, +) { + if err := r.uploadCredentials(ctx, client, req, platform); err != nil { r.logger.Warn( "ssh executor: credential upload failed; launch will proceed but agent may not authenticate", zap.String("task_id", req.TaskID), @@ -574,12 +587,17 @@ func (r *SSHExecutor) maybeUploadCredentials(ctx context.Context, client *ssh.Cl // The first token is the binary; we shell out to POSIX `command -v` on the // remote and treat empty stdout as "missing". On miss we surface the // agent's name + InstallScript() so the error is actionable in the UI. -func (r *SSHExecutor) preflightAgentBinary(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) error { +func (r *SSHExecutor) preflightAgentBinary( + ctx context.Context, + client *ssh.Client, + req *ExecutorCreateRequest, + platform SSHRemotePlatform, +) error { if req.AgentConfig == nil { return nil } stepName := "Verifying agent binary" - shell := sshShellFromMetadata(req.Metadata) + shell := sshShellForRemote(req.Metadata, platform) // Prefer the agent's standalone CLI when present on the remote: running it // directly skips the per-launch `npx` registry round-trip. A hit is recorded @@ -657,6 +675,13 @@ func sshShellFromMetadata(md map[string]interface{}) string { return strings.TrimSpace(getMetadataString(md, MetadataKeySSHShell)) } +func sshShellForRemote(md map[string]interface{}, platform SSHRemotePlatform) string { + if shell := sshShellFromMetadata(md); shell != "" { + return shell + } + return SSHDefaultShellForPlatform(platform) +} + // agentIdentity is the slice of agents.Agent that formatMissingAgentBinaryError // reads. Narrowing the parameter keeps the helper trivially testable without a // dependency on the full Agent interface or a stub for every method on it. diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go index 719fc47f57..c8683b165b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go @@ -112,16 +112,72 @@ func TestExpandHome(t *testing.T) { } } -func TestRequireSupportedArch(t *testing.T) { - if err := requireSupportedArch("x86_64"); err != nil { - t.Errorf("x86_64 should be supported, got %v", err) +func TestSSHExecutorTargetFromMetadataMissingFingerprintNamesConnectionSettings(t *testing.T) { + exec := &SSHExecutor{} + _, err := exec.targetFromMetadata(map[string]interface{}{ + MetadataKeySSHHost: "example.com", + MetadataKeySSHUser: "alice", + MetadataKeySSHIdentitySource: string(SSHIdentitySourceAgent), + }) + if err == nil { + t.Fatal("expected missing host_fingerprint error") + } + msg := err.Error() + for _, want := range []string{"host_fingerprint is required", "SSH executor connection settings", "Test connection", "trust the host"} { + if !strings.Contains(msg, want) { + t.Errorf("error message %q missing %q", msg, want) + } } - err := requireSupportedArch("aarch64") +} + +func TestNormalizeSSHRemotePlatform(t *testing.T) { + cases := []struct { + name string + osName string + arch string + wantOS string + wantArch string + wantOK bool + }{ + {"linux amd64", "Linux", "x86_64", "linux", "amd64", true}, + {"darwin arm64", "Darwin", "arm64", "darwin", "arm64", true}, + {"darwin amd64", "Darwin", "x86_64", "darwin", "amd64", true}, + {"linux arm64 currently unsupported", "Linux", "aarch64", "linux", "arm64", false}, + {"freebsd amd64 unsupported", "FreeBSD", "x86_64", "", "amd64", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := normalizeSSHRemotePlatform(tc.osName, tc.arch) + if ok != tc.wantOK { + t.Fatalf("normalizeSSHRemotePlatform(%q, %q) ok = %v, want %v", tc.osName, tc.arch, ok, tc.wantOK) + } + if got.GOOS != tc.wantOS || got.GOARCH != tc.wantArch { + t.Errorf("normalizeSSHRemotePlatform(%q, %q) = %s/%s, want %s/%s", + tc.osName, tc.arch, got.GOOS, got.GOARCH, tc.wantOS, tc.wantArch) + } + }) + } +} + +func TestRequireSupportedRemotePlatform(t *testing.T) { + for _, platform := range []SSHRemotePlatform{ + {GOOS: "linux", GOARCH: "amd64", UnameOS: "Linux", UnameArch: "x86_64"}, + {GOOS: "darwin", GOARCH: "arm64", UnameOS: "Darwin", UnameArch: "arm64"}, + {GOOS: "darwin", GOARCH: "amd64", UnameOS: "Darwin", UnameArch: "x86_64"}, + } { + if err := requireSupportedRemotePlatform(platform); err != nil { + t.Errorf("%s should be supported, got %v", platform.String(), err) + } + } + unsupported := SSHRemotePlatform{GOOS: "linux", GOARCH: "arm64", UnameOS: "Linux", UnameArch: "aarch64"} + err := requireSupportedRemotePlatform(unsupported) if err == nil { - t.Fatal("aarch64 should not be supported") + t.Fatal("linux/arm64 should not be supported yet") } - if !strings.Contains(err.Error(), "aarch64") || !strings.Contains(err.Error(), "linux/amd64") { - t.Errorf("error should name the arch and the supported one: %v", err) + for _, want := range []string{"linux/arm64", "linux/amd64", "darwin/arm64", "darwin/amd64"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing %q", err.Error(), want) + } } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go index fa23253bb4..c7b93c1eb0 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go @@ -29,10 +29,11 @@ func (r *SSHExecutor) uploadCredentials( ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest, + platform SSHRemotePlatform, ) error { catalog := r.buildRemoteAuthCatalog() r.resolveAuthSecrets(ctx, req, catalog) - r.runAuthSetupScripts(ctx, client, req, catalog) + r.runAuthSetupScripts(ctx, client, req, catalog, platform) credsJSON := getMetadataString(req.Metadata, "remote_credentials") if credsJSON == "" { @@ -52,7 +53,7 @@ func (r *SSHExecutor) uploadCredentials( } uploader := &sshFileUploader{client: client} - homeDir, err := r.resolveRemoteAuthHomeDir(ctx, client, req) + homeDir, err := r.resolveRemoteAuthHomeDir(ctx, client, req, platform) if err != nil { return err } @@ -67,6 +68,7 @@ func (r *SSHExecutor) runAuthSetupScripts( client *ssh.Client, req *ExecutorCreateRequest, catalog remoteauth.Catalog, + platform SSHRemotePlatform, ) { for _, spec := range catalog.Specs { for _, method := range spec.Methods { @@ -76,7 +78,7 @@ func (r *SSHExecutor) runAuthSetupScripts( if req.Env[method.EnvVar] == "" { continue } - r.runOneAuthSetupScript(ctx, client, req, spec.DisplayName, method) + r.runOneAuthSetupScript(ctx, client, req, spec.DisplayName, method, platform) } } } @@ -94,8 +96,9 @@ func (r *SSHExecutor) runOneAuthSetupScript( req *ExecutorCreateRequest, displayName string, method remoteauth.Method, + platform SSHRemotePlatform, ) { - shell := sshShellFromMetadata(req.Metadata) + shell := sshShellForRemote(req.Metadata, platform) envScript := buildSSHEnvInitScript(req.Env) // `. /dev/stdin` sources the env lines fed via session.Stdin; `set -a` // makes those assignments automatically exported so the user's setup @@ -198,12 +201,13 @@ func (r *SSHExecutor) resolveRemoteAuthHomeDir( ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest, + platform SSHRemotePlatform, ) (string, error) { if override := strings.TrimSpace(getMetadataString(req.Metadata, MetadataKeyRemoteAuthHome)); override != "" { r.logger.Debug("using remote auth home override", zap.String("home_dir", override)) return override, nil } - shell := sshShellFromMetadata(req.Metadata) + shell := sshShellForRemote(req.Metadata, platform) out, _, err := runSSHCommand(ctx, client, WrapLoginShell(shell, `printf %s "$HOME"`)) if err != nil { return "", fmt.Errorf("ssh: resolve remote $HOME for credentials: %w", err) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go index be3f439cf7..98200c5b5f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go @@ -47,6 +47,30 @@ func TestWrapLoginShell(t *testing.T) { }) } +func TestSSHShellForRemote(t *testing.T) { + t.Run("explicit metadata wins", func(t *testing.T) { + md := map[string]interface{}{MetadataKeySSHShell: "fish"} + got := sshShellForRemote(md, SSHRemotePlatform{GOOS: sshRemoteGOOSDarwin, GOARCH: sshRemoteGOARCHARM64}) + if got != "fish" { + t.Errorf("sshShellForRemote() = %q, want fish", got) + } + }) + + t.Run("darwin defaults to zsh", func(t *testing.T) { + got := sshShellForRemote(nil, SSHRemotePlatform{GOOS: sshRemoteGOOSDarwin, GOARCH: sshRemoteGOARCHARM64}) + if got != "zsh" { + t.Errorf("sshShellForRemote(darwin) = %q, want zsh", got) + } + }) + + t.Run("linux delegates to WrapLoginShell default", func(t *testing.T) { + got := sshShellForRemote(nil, SSHRemotePlatform{GOOS: sshRemoteGOOSLinux, GOARCH: sshRemoteGOARCHAMD64}) + if got != "bash" { + t.Errorf("sshShellForRemote(linux) = %q, want bash", got) + } + }) +} + func TestParentDir(t *testing.T) { cases := []struct { in string diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go index 7aad607c01..550a99061a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go @@ -33,13 +33,35 @@ const ( sshRemoteAgentctlSha256 = "~/.kandev/bin/agentctl.sha256" sshAgentctlReadyTimeout = 30 * time.Second sshAgentctlReadyPoll = 500 * time.Millisecond - sshSupportedArch = "x86_64" + + sshRemoteGOOSLinux = "linux" + sshRemoteGOOSDarwin = "darwin" + sshRemoteGOARCHAMD64 = "amd64" + sshRemoteGOARCHARM64 = "arm64" ) +// SSHRemotePlatform is the normalized remote OS/arch tuple used to choose the +// agentctl helper uploaded to SSH hosts. +type SSHRemotePlatform struct { + GOOS string + GOARCH string + UnameOS string + UnameArch string +} + +func (p SSHRemotePlatform) String() string { + if p.GOOS == "" || p.GOARCH == "" { + return "unknown" + } + return p.GOOS + "/" + p.GOARCH +} + // SSHRemoteInfo describes the remote host detected during connection. type SSHRemoteInfo struct { UnameAll string // `uname -a` + OS string // `uname -s` Arch string // `uname -m` + Platform SSHRemotePlatform GitVer string // `git --version` AgentctlOK bool // true if the cached agentctl matches the local sha256 } @@ -50,10 +72,9 @@ func SSHProbeRemote(ctx context.Context, client *ssh.Client) (*SSHRemoteInfo, er return detectRemoteInfo(ctx, client) } -// SSHRequireSupportedArch is the exported arm64-not-yet-supported gate. -// Returns nil for the supported arch (x86_64) and a user-facing error otherwise. -func SSHRequireSupportedArch(arch string) error { - return requireSupportedArch(arch) +// SSHRequireSupportedRemotePlatform is the exported platform support gate. +func SSHRequireSupportedRemotePlatform(platform SSHRemotePlatform) error { + return requireSupportedRemotePlatform(platform) } // SSHCheckAgentctlCached reports whether the remote already has an agentctl @@ -63,8 +84,8 @@ func SSHRequireSupportedArch(arch string) error { // Errors here are non-fatal at test time — the actual upload happens on // CreateInstance — but they still bubble up so the UI can surface "agentctl // not yet on remote" as a status row. -func SSHCheckAgentctlCached(ctx context.Context, client *ssh.Client, resolver *AgentctlResolver) (bool, error) { - localSha, _, _, err := localAgentctlSha256(resolver) +func SSHCheckAgentctlCached(ctx context.Context, client *ssh.Client, resolver *AgentctlResolver, platform SSHRemotePlatform) (bool, error) { + localSha, _, _, err := localAgentctlSha256(resolver, platform) if err != nil { return false, err } @@ -84,7 +105,7 @@ func SSHCheckAgentctlCached(ctx context.Context, client *ssh.Client, resolver *A } // runSSHCommand executes a single command on the remote and returns its -// stdout, stderr, and any error. It is the workhorse for arch detection, +// stdout, stderr, and any error. It is the workhorse for platform detection, // remote mkdir, git clone, sha256 checks, and the like. func runSSHCommand(ctx context.Context, client *ssh.Client, cmd string) (stdout, stderr string, err error) { return runSSHCommandStdin(ctx, client, cmd, nil) @@ -122,7 +143,7 @@ func runSSHCommandStdin(ctx context.Context, client *ssh.Client, cmd string, std } } -// detectRemoteInfo runs a tiny probe to learn about the host. amd64-only gate +// detectRemoteInfo runs a tiny probe to learn about the host. The support gate // happens at the caller — this function only reports. func detectRemoteInfo(ctx context.Context, client *ssh.Client) (*SSHRemoteInfo, error) { info := &SSHRemoteInfo{} @@ -134,6 +155,13 @@ func detectRemoteInfo(ctx context.Context, client *ssh.Client) (*SSHRemoteInfo, return nil, fmt.Errorf("ssh: uname -m: %w", err) } info.Arch = strings.TrimSpace(out) + out, _, err = runSSHCommand(ctx, client, "uname -s") + if err != nil { + return nil, fmt.Errorf("ssh: uname -s: %w", err) + } + info.OS = strings.TrimSpace(out) + platform, _ := normalizeSSHRemotePlatform(info.OS, info.Arch) + info.Platform = platform if out, _, err := runSSHCommand(ctx, client, "git --version"); err == nil { info.GitVer = strings.TrimSpace(out) @@ -141,16 +169,54 @@ func detectRemoteInfo(ctx context.Context, client *ssh.Client) (*SSHRemoteInfo, return info, nil } -// requireSupportedArch returns nil when the remote arch is supported, or a -// user-facing error otherwise. v1 = linux/amd64 only (mirrors Docker today). -func requireSupportedArch(arch string) error { - if arch == sshSupportedArch { +func normalizeSSHRemotePlatform(osName, arch string) (SSHRemotePlatform, bool) { + goos := normalizeSSHRemoteOS(osName) + goarch := normalizeSSHRemoteArch(arch) + platform := SSHRemotePlatform{GOOS: goos, GOARCH: goarch, UnameOS: osName, UnameArch: arch} + if err := requireSupportedRemotePlatform(platform); err != nil { + return platform, false + } + return platform, true +} + +func normalizeSSHRemoteOS(osName string) string { + switch strings.ToLower(strings.TrimSpace(osName)) { + case "linux": + return sshRemoteGOOSLinux + case "darwin": + return sshRemoteGOOSDarwin + default: + return "" + } +} + +func normalizeSSHRemoteArch(arch string) string { + switch strings.ToLower(strings.TrimSpace(arch)) { + case "x86_64", "amd64": + return sshRemoteGOARCHAMD64 + case "arm64", "aarch64": + return sshRemoteGOARCHARM64 + default: + return "" + } +} + +func requireSupportedRemotePlatform(platform SSHRemotePlatform) error { + switch platform.String() { + case sshRemoteGOOSLinux + "/" + sshRemoteGOARCHAMD64, + sshRemoteGOOSDarwin + "/" + sshRemoteGOARCHARM64, + sshRemoteGOOSDarwin + "/" + sshRemoteGOARCHAMD64: return nil + default: + reported := platform.String() + if reported == "unknown" && (platform.UnameOS != "" || platform.UnameArch != "") { + reported = fmt.Sprintf("%s/%s", platform.UnameOS, platform.UnameArch) + } + return fmt.Errorf( + "unsupported remote platform %q — SSH executor supports linux/amd64, darwin/arm64, and darwin/amd64", + reported, + ) } - return fmt.Errorf( - "unsupported remote architecture %q — SSH executor v1 supports linux/amd64 only (arm64 lands when agentctl-linux-arm64 is added to the build)", - arch, - ) } // expandRemoteHome rewrites a leading ~/ to the home directory reported by the @@ -176,8 +242,8 @@ func expandRemoteHome(ctx context.Context, client *ssh.Client, path string) (str // localAgentctlSha256 returns the hex sha256 of the local agentctl binary // resolved via AgentctlResolver. Used to decide whether to re-upload. -func localAgentctlSha256(resolver *AgentctlResolver) (string, []byte, string, error) { - path, err := resolver.ResolveLinuxBinary() +func localAgentctlSha256(resolver *AgentctlResolver, platform SSHRemotePlatform) (string, []byte, string, error) { + path, err := resolver.ResolveRemoteBinary(platform) if err != nil { return "", nil, "", err } @@ -191,8 +257,8 @@ func localAgentctlSha256(resolver *AgentctlResolver) (string, []byte, string, er // ensureAgentctlOnHost uploads the agentctl binary if the remote's cached sha256 // differs from the local binary's sha256. Returns the absolute remote path. -func ensureAgentctlOnHost(ctx context.Context, client *ssh.Client, resolver *AgentctlResolver, log *logger.Logger) (string, error) { - localSha, localData, localPath, err := localAgentctlSha256(resolver) +func ensureAgentctlOnHost(ctx context.Context, client *ssh.Client, resolver *AgentctlResolver, platform SSHRemotePlatform, log *logger.Logger) (string, error) { + localSha, localData, localPath, err := localAgentctlSha256(resolver, platform) if err != nil { return "", err } @@ -299,6 +365,15 @@ func shellQuote(s string) string { // "agent isn't on PATH" diagnosis. const defaultLoginShell = "bash" +// SSHDefaultShellForPlatform returns the login shell Kandev should prefer +// when an SSH profile has no explicit shell saved. +func SSHDefaultShellForPlatform(platform SSHRemotePlatform) string { + if platform.GOOS == sshRemoteGOOSDarwin { + return "zsh" + } + return defaultLoginShell +} + // WrapLoginShell wraps cmd in `${shell} -lc ''` so commands run under // a login shell that has sourced the user's profile (~/.profile, // ~/.bash_profile, etc.). This is the canonical fix for "I have nvm diff --git a/apps/backend/internal/launcher/bundle.go b/apps/backend/internal/launcher/bundle.go index 9eaad894ab..64ff8ba08a 100644 --- a/apps/backend/internal/launcher/bundle.go +++ b/apps/backend/internal/launcher/bundle.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "runtime" ) type runtimeBundle struct { @@ -13,6 +12,17 @@ type runtimeBundle struct { Source string } +type agentctlRemoteHelper struct { + Name string + Label string +} + +var requiredAgentctlRemoteHelpers = []agentctlRemoteHelper{ + {Name: "agentctl-linux-amd64", Label: "agentctl linux/amd64 helper"}, + {Name: "agentctl-darwin-arm64", Label: "agentctl darwin/arm64 helper"}, + {Name: "agentctl-darwin-amd64", Label: "agentctl darwin/amd64 helper"}, +} + func resolveRuntimeBundle() (runtimeBundle, error) { dir := os.Getenv("KANDEV_BUNDLE_DIR") if dir == "" { @@ -30,17 +40,15 @@ func validateRuntimeBundle(dir, source string) (runtimeBundle, error) { if !exists(agentctl) { return runtimeBundle{}, fmt.Errorf("agentctl binary not found in bundle at %s", agentctl) } - agentctlLinuxAMD64 := filepath.Join(dir, "bin", "agentctl-linux-amd64") - if requiresAgentctlLinuxAMD64(runtime.GOOS, runtime.GOARCH) && !exists(agentctlLinuxAMD64) { - return runtimeBundle{}, fmt.Errorf("agentctl linux/amd64 helper not found in bundle at %s", agentctlLinuxAMD64) + 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) + } } return runtimeBundle{Dir: dir, Launcher: launcher, Source: source}, nil } -func requiresAgentctlLinuxAMD64(goos, goarch string) bool { - return goos != "linux" || goarch != "amd64" -} - func executableName(name string) string { if os.PathSeparator == '\\' { return name + ".exe" diff --git a/apps/backend/internal/launcher/bundle_test.go b/apps/backend/internal/launcher/bundle_test.go index a6a257bfa9..6bbea6d1d1 100644 --- a/apps/backend/internal/launcher/bundle_test.go +++ b/apps/backend/internal/launcher/bundle_test.go @@ -3,7 +3,7 @@ package launcher import ( "os" "path/filepath" - "runtime" + "strings" "testing" ) @@ -11,9 +11,7 @@ func TestValidateRuntimeBundleAcceptsSingleBinaryLayout(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "bin", "kandev")) writeFile(t, filepath.Join(dir, "bin", "agentctl")) - if requiresAgentctlLinuxAMD64(runtime.GOOS, runtime.GOARCH) { - writeFile(t, filepath.Join(dir, "bin", "agentctl-linux-amd64")) - } + writeRemoteAgentctlHelpers(t, dir) bundle, err := validateRuntimeBundle(dir, "test") if err != nil { @@ -33,15 +31,46 @@ func TestValidateRuntimeBundleRejectsMissingLauncher(t *testing.T) { } } -func TestRequiresAgentctlLinuxAMD64(t *testing.T) { - if requiresAgentctlLinuxAMD64("linux", "amd64") { - t.Fatal("linux/amd64 should use the native agentctl binary") +func TestValidateRuntimeBundleRejectsMissingRemoteHelper(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "bin", "kandev")) + writeFile(t, filepath.Join(dir, "bin", "agentctl")) + writeFile(t, filepath.Join(dir, "bin", "agentctl-linux-amd64")) + writeFile(t, filepath.Join(dir, "bin", "agentctl-darwin-amd64")) + + _, err := validateRuntimeBundle(dir, "test") + if err == nil { + t.Fatal("expected error") } - if !requiresAgentctlLinuxAMD64("linux", "arm64") { - t.Fatal("linux/arm64 should require the linux/amd64 helper") + if got, want := err.Error(), "agentctl darwin/arm64 helper not found"; !strings.Contains(got, want) { + t.Fatalf("error = %q, want substring %q", got, want) } - if !requiresAgentctlLinuxAMD64("darwin", "arm64") { - t.Fatal("non-linux hosts should require the linux/amd64 helper") +} + +func TestRequiredAgentctlRemoteHelpers(t *testing.T) { + got := make([]string, 0, len(requiredAgentctlRemoteHelpers)) + for _, helper := range requiredAgentctlRemoteHelpers { + got = append(got, helper.Name) + } + want := []string{ + "agentctl-linux-amd64", + "agentctl-darwin-arm64", + "agentctl-darwin-amd64", + } + if len(got) != len(want) { + t.Fatalf("helpers = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("helpers = %v, want %v", got, want) + } + } +} + +func writeRemoteAgentctlHelpers(t *testing.T, dir string) { + t.Helper() + for _, helper := range requiredAgentctlRemoteHelpers { + writeFile(t, filepath.Join(dir, "bin", helper.Name)) } } diff --git a/apps/backend/internal/ssh/handlers.go b/apps/backend/internal/ssh/handlers.go index 372994b6f8..ce9e572905 100644 --- a/apps/backend/internal/ssh/handlers.go +++ b/apps/backend/internal/ssh/handlers.go @@ -139,7 +139,9 @@ type TestResult struct { Success bool `json:"success"` Fingerprint string `json:"fingerprint,omitempty"` UnameAll string `json:"uname_all,omitempty"` + OS string `json:"os,omitempty"` Arch string `json:"arch,omitempty"` + Platform string `json:"platform,omitempty"` GitVersion string `json:"git_version,omitempty"` AgentctlAction string `json:"agentctl_action,omitempty"` // "cached" | "needs_upload" | "skipped" Steps []TestStep `json:"steps"` @@ -215,7 +217,7 @@ type AgentReadinessResponse struct { // ProbeAgentsRequest is the optional body of POST /api/v1/ssh/executors/:id/probe-agents. // Shell, when set, names the login shell the probe runs under (e.g. "bash", -// "zsh"). Empty falls back to bash — see lifecycle.WrapLoginShell. +// "zsh"). Empty uses the platform default selected by lifecycle. type ProbeAgentsRequest struct { Shell string `json:"shell,omitempty"` } @@ -262,10 +264,12 @@ func (h *Handler) probeAgents(ctx context.Context, executorID, shell string) (*A } defer func() { _ = client.Close() }() - rows := h.probeAgentsOnClient(ctx, client, shell) + platform := h.probeRemotePlatform(ctx, client, "probe-agents") + effectiveShell := readinessShellForRequest(shell, platform) + rows := h.probeAgentsOnClient(ctx, client, effectiveShell) return &AgentReadinessResponse{ Host: target.Host, - Shell: shell, + Shell: effectiveShell, DurationMs: time.Since(started).Milliseconds(), Rows: rows, }, http.StatusOK, nil @@ -314,9 +318,10 @@ var candidateShells = []string{"bash", "zsh", "sh", "fish", "dash"} // ProbeShellsResponse is the body of POST /api/v1/ssh/executors/:id/probe-shells. type ProbeShellsResponse struct { - Host string `json:"host"` - DurationMs int64 `json:"duration_ms"` - Available []string `json:"available"` + Host string `json:"host"` + DefaultShell string `json:"default_shell"` + DurationMs int64 `json:"duration_ms"` + Available []string `json:"available"` } // httpProbeShells SSHs to the host and reports which of a small set of @@ -354,6 +359,7 @@ func (h *Handler) probeShells(ctx context.Context, executorID string) (*ProbeShe } defer func() { _ = client.Close() }() + platform := h.probeRemotePlatform(ctx, client, "probe-shells") available := make([]string, 0, len(candidateShells)) for _, shell := range candidateShells { // Probe without login-shell wrapping — we're checking whether the @@ -371,12 +377,33 @@ func (h *Handler) probeShells(ctx context.Context, executorID string) (*ProbeShe } } return &ProbeShellsResponse{ - Host: target.Host, - DurationMs: time.Since(started).Milliseconds(), - Available: available, + Host: target.Host, + DefaultShell: readinessShellForRequest("", platform), + DurationMs: time.Since(started).Milliseconds(), + Available: available, }, http.StatusOK, nil } +func (h *Handler) probeRemotePlatform( + ctx context.Context, + client *ssh.Client, + operation string, +) lifecycle.SSHRemotePlatform { + info, err := lifecycle.SSHProbeRemote(ctx, client) + if err != nil { + h.logger.Warn(operation+": remote platform probe failed", zap.Error(err)) + return lifecycle.SSHRemotePlatform{} + } + return info.Platform +} + +func readinessShellForRequest(shell string, platform lifecycle.SSHRemotePlatform) string { + if trimmed := strings.TrimSpace(shell); trimmed != "" { + return trimmed + } + return lifecycle.SSHDefaultShellForPlatform(platform) +} + // resolveSSHTarget looks up the executor by id and projects its config // into an SSHTarget. Shared across probe entry points. func (h *Handler) resolveSSHTarget(ctx context.Context, executorID string) (*lifecycle.SSHTarget, int, error) { @@ -483,10 +510,11 @@ func (h *Handler) runTest(ctx context.Context, req TestRequest) *TestResult { infoCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - if err := h.testProbeAndArch(infoCtx, client, result); err != nil { + platform, err := h.testProbeAndPlatform(infoCtx, client, result) + if err != nil { return finalize() } - h.testAgentctlCache(infoCtx, client, result) + h.testAgentctlCache(infoCtx, client, result, platform) result.Success = true return finalize() @@ -536,25 +564,27 @@ func (h *Handler) testHandshake( return client, nil } -func (h *Handler) testProbeAndArch(ctx context.Context, client *ssh.Client, result *TestResult) error { +func (h *Handler) testProbeAndPlatform(ctx context.Context, client *ssh.Client, result *TestResult) (lifecycle.SSHRemotePlatform, error) { info, err := lifecycle.SSHProbeRemote(ctx, client) if err != nil { result.Steps = append(result.Steps, TestStep{Name: "Probe remote", Success: false, Error: err.Error()}) result.Error = err.Error() - return err + return lifecycle.SSHRemotePlatform{}, err } result.UnameAll = info.UnameAll + result.OS = info.OS result.Arch = info.Arch + result.Platform = info.Platform.String() result.GitVersion = info.GitVer result.Steps = append(result.Steps, TestStep{Name: "Probe remote", Success: true, Output: info.UnameAll}) - if err := lifecycle.SSHRequireSupportedArch(info.Arch); err != nil { - result.Steps = append(result.Steps, TestStep{Name: "Verify arch", Success: false, Error: err.Error()}) + if err := lifecycle.SSHRequireSupportedRemotePlatform(info.Platform); err != nil { + result.Steps = append(result.Steps, TestStep{Name: "Verify platform", Success: false, Error: err.Error()}) result.Error = err.Error() - return err + return lifecycle.SSHRemotePlatform{}, err } - result.Steps = append(result.Steps, TestStep{Name: "Verify arch", Success: true, Output: info.Arch}) - return nil + result.Steps = append(result.Steps, TestStep{Name: "Verify platform", Success: true, Output: info.Platform.String()}) + return info.Platform, nil } // populateSessionMetadata copies SSH-specific keys from an ExecutorRunning row @@ -594,9 +624,14 @@ func intFromMetadata(md map[string]interface{}, key string) int { } } -func (h *Handler) testAgentctlCache(ctx context.Context, client *ssh.Client, result *TestResult) { +func (h *Handler) testAgentctlCache( + ctx context.Context, + client *ssh.Client, + result *TestResult, + platform lifecycle.SSHRemotePlatform, +) { upStepStart := time.Now() - cached, err := lifecycle.SSHCheckAgentctlCached(ctx, client, h.resolver) + cached, err := lifecycle.SSHCheckAgentctlCached(ctx, client, h.resolver, platform) upStep := TestStep{Name: "Verify agentctl cache", DurationMs: time.Since(upStepStart).Milliseconds()} if err != nil { upStep.Success = false diff --git a/apps/backend/internal/ssh/handlers_test.go b/apps/backend/internal/ssh/handlers_test.go new file mode 100644 index 0000000000..e31dba8df3 --- /dev/null +++ b/apps/backend/internal/ssh/handlers_test.go @@ -0,0 +1,45 @@ +package ssh + +import ( + "testing" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" +) + +func TestReadinessShellForRequest(t *testing.T) { + cases := []struct { + name string + shell string + platform lifecycle.SSHRemotePlatform + want string + }{ + { + name: "explicit shell wins", + shell: " fish ", + platform: lifecycle.SSHRemotePlatform{GOOS: "darwin", GOARCH: "arm64"}, + want: "fish", + }, + { + name: "darwin defaults to zsh", + platform: lifecycle.SSHRemotePlatform{GOOS: "darwin", GOARCH: "arm64"}, + want: "zsh", + }, + { + name: "linux defaults to bash", + platform: lifecycle.SSHRemotePlatform{GOOS: "linux", GOARCH: "amd64"}, + want: "bash", + }, + { + name: "unknown defaults to bash", + want: "bash", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := readinessShellForRequest(tc.shell, tc.platform); got != tc.want { + t.Fatalf("readinessShellForRequest() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/apps/backend/internal/system/metrics/collector_darwin_nocgo.go b/apps/backend/internal/system/metrics/collector_darwin_nocgo.go new file mode 100644 index 0000000000..a9eb23a08a --- /dev/null +++ b/apps/backend/internal/system/metrics/collector_darwin_nocgo.go @@ -0,0 +1,21 @@ +//go:build darwin && !cgo + +package metrics + +import "errors" + +func readCPUTimes(_ *Collector) (cpuTimes, error) { + return cpuTimes{}, errors.New("cpu metrics unavailable on darwin without cgo") +} + +func (c *Collector) memoryPercent() (float64, error) { + return 0, errors.New("memory metrics unavailable on darwin without cgo") +} + +func (c *Collector) cpuTempValue() (float64, error) { + return 0, errors.New("cpu temperature unavailable on darwin without cgo") +} + +func (c *Collector) ioLoadValue() (float64, error) { + return 0, errors.New("load average unavailable on darwin without cgo") +} diff --git a/apps/cli/README_internal.md b/apps/cli/README_internal.md index ad1e8028cd..c1ac77db42 100644 --- a/apps/cli/README_internal.md +++ b/apps/cli/README_internal.md @@ -42,15 +42,15 @@ The GitHub release bundle and the npm runtime package are **different shapes** b ``` # GitHub release bundle (used by Homebrew + manual installs) kandev/ -└── bin/{kandev,agentctl,agentctl-linux-amd64} +└── bin/{kandev,agentctl,agentctl-linux-amd64,agentctl-darwin-arm64,agentctl-darwin-amd64} # npm runtime package (@kdlbs/runtime-{platform}) @kdlbs/runtime-{platform}/ -└── bin/{kandev,agentctl,agentctl-linux-amd64} +└── bin/{kandev,agentctl,agentctl-linux-amd64,agentctl-darwin-arm64,agentctl-darwin-amd64} # Tauri desktop resource directory apps/desktop/src-tauri/resources/kandev/ -└── bin/{kandev[.exe],agentctl[.exe],agentctl-linux-amd64} +└── bin/{kandev[.exe],agentctl[.exe],agentctl-linux-amd64,agentctl-darwin-arm64,agentctl-darwin-amd64} ``` For npm installs, the main `kandev` package provides only a tiny Node bin shim that execs `bin/kandev` from the platform runtime package. diff --git a/apps/cli/src/release-config.test.ts b/apps/cli/src/release-config.test.ts index da36963103..71d910b2fc 100644 --- a/apps/cli/src/release-config.test.ts +++ b/apps/cli/src/release-config.test.ts @@ -352,7 +352,9 @@ describe("release desktop artifacts", () => { expect(workflow).toContain("$LASTEXITCODE -ne 0"); expect(workflow).toContain("Sign macOS desktop runtime binaries"); expect(workflow).toContain("resources/kandev/bin"); - expect(workflow).toContain("for binary in kandev agentctl; do"); + expect(workflow).toContain( + "for binary in kandev agentctl agentctl-darwin-arm64 agentctl-darwin-amd64; do", + ); expect(workflow).toContain( 'codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY"', ); diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index f3922b26b9..2fb77cf6a9 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -19,6 +19,8 @@ Release builds prepare `src-tauri/resources/kandev/` with: bin/kandev[.exe] bin/agentctl[.exe] bin/agentctl-linux-amd64 +bin/agentctl-darwin-arm64 +bin/agentctl-darwin-amd64 ``` Use `scripts/release/prepare-desktop-runtime.sh` and `scripts/release/verify-desktop-runtime.sh`; do not commit runtime binaries. The tracked `.gitignore` files only keep the resource directory present for Tauri config validation. diff --git a/apps/desktop/e2e/desktop-launch-smoke.mjs b/apps/desktop/e2e/desktop-launch-smoke.mjs index 3bdcbe5882..3d394dcec8 100644 --- a/apps/desktop/e2e/desktop-launch-smoke.mjs +++ b/apps/desktop/e2e/desktop-launch-smoke.mjs @@ -74,7 +74,11 @@ async function runSmoke() { async function writeFakeRuntime(runtimeDir, stateDir) { const fakeRuntime = join(runtimeDir, "bin", process.platform === "win32" ? "kandev.cmd" : "kandev"); const agentctl = join(runtimeDir, "bin", process.platform === "win32" ? "agentctl.cmd" : "agentctl"); - const linuxHelper = join(runtimeDir, "bin", "agentctl-linux-amd64"); + const remoteHelpers = [ + ["agentctl-linux-amd64", "linux/amd64"], + ["agentctl-darwin-arm64", "darwin/arm64"], + ["agentctl-darwin-amd64", "darwin/amd64"], + ]; if (process.platform === "win32") { await writeFile( @@ -92,9 +96,12 @@ async function writeFakeRuntime(runtimeDir, stateDir) { await chmod(agentctl, 0o755); } - await writeFile(linuxHelper, "#!/usr/bin/env bash\necho fake agentctl linux helper\n"); - if (process.platform !== "win32") { - await chmod(linuxHelper, 0o755); + for (const [name, platform] of remoteHelpers) { + const helper = join(runtimeDir, "bin", name); + await writeFile(helper, `#!/usr/bin/env bash\necho fake agentctl ${platform} helper\n`); + if (process.platform !== "win32") { + await chmod(helper, 0o755); + } } } diff --git a/apps/desktop/src-tauri/src/backend.rs b/apps/desktop/src-tauri/src/backend.rs index bebb525f0d..dfa8985ff4 100644 --- a/apps/desktop/src-tauri/src/backend.rs +++ b/apps/desktop/src-tauri/src/backend.rs @@ -25,6 +25,11 @@ const DESKTOP_HEALTH_TOKEN_ENV: &str = "KANDEV_DESKTOP_HEALTH_TOKEN"; const DESKTOP_HEALTH_TOKEN_HEADER: &str = "x-kandev-desktop-health-token"; const STARTUP_OUTPUT_LIMIT: usize = 12 * 1024; const HEALTH_READY_SETTLE: Duration = Duration::from_millis(100); +const REMOTE_AGENTCTL_HELPERS: [(&str, &str); 3] = [ + ("agentctl-linux-amd64", "agentctl linux/amd64 helper"), + ("agentctl-darwin-arm64", "agentctl darwin/arm64 helper"), + ("agentctl-darwin-amd64", "agentctl darwin/amd64 helper"), +]; #[derive(Clone)] pub struct BackendState { @@ -240,7 +245,9 @@ pub fn validate_runtime_dir(runtime_dir: &Path) -> Result<(), String> { let bin_dir = runtime_dir.join("bin"); require_runtime_file(&bin_dir.join(executable_name("kandev")), "Kandev launcher binary")?; require_runtime_file(&bin_dir.join(executable_name("agentctl")), "agentctl binary")?; - require_runtime_file(&bin_dir.join("agentctl-linux-amd64"), "agentctl linux/amd64 helper")?; + for &(name, label) in REMOTE_AGENTCTL_HELPERS.iter() { + require_runtime_file(&bin_dir.join(name), label)?; + } Ok(()) } @@ -713,6 +720,22 @@ mod tests { assert!(err.contains("agentctl linux/amd64 helper is missing"), "{err}"); } + #[test] + fn missing_darwin_helper_returns_readable_error() { + let dir = temp_root("missing-darwin-helper"); + let bin = dir.join("bin"); + fs::create_dir_all(&bin).expect("create bin"); + fs::write(bin.join(executable_name("kandev")), b"stub").expect("write launcher"); + fs::write(bin.join(executable_name("agentctl")), b"stub").expect("write agentctl"); + fs::write(bin.join("agentctl-linux-amd64"), b"stub").expect("write linux helper"); + fs::write(bin.join("agentctl-darwin-amd64"), b"stub").expect("write darwin amd64 helper"); + + let err = + build_backend_command(&dir, 48123, BTreeMap::new(), None).expect_err("missing helper"); + + assert!(err.contains("agentctl darwin/arm64 helper is missing"), "{err}"); + } + #[test] fn pick_loopback_port_returns_valid_port() { let port = pick_loopback_port().expect("loopback port"); @@ -905,7 +928,9 @@ mod tests { fs::create_dir_all(&bin).expect("create bin"); fs::write(bin.join(executable_name("kandev")), b"stub").expect("write launcher"); fs::write(bin.join(executable_name("agentctl")), b"stub").expect("write agentctl"); - fs::write(bin.join("agentctl-linux-amd64"), b"stub").expect("write linux helper"); + for &(name, label) in REMOTE_AGENTCTL_HELPERS.iter() { + fs::write(bin.join(name), b"stub").unwrap_or_else(|err| panic!("write {label}: {err}")); + } dir } diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index f2cab47711..15a05a019e 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -4,6 +4,7 @@ import { use, useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "@/lib/routing/client-router"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; +import { IconShieldLock } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; import { @@ -508,11 +509,25 @@ function ProfileEditSections({ } function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) { + const router = useRouter(); const { items: secrets } = useSecrets(); const persistence = useProfilePersistence(executor, profile); const form = useProfileFormState(executor, profile); const relatedContainers = useDockerProfileContainers(profile.id, form.isDocker); const spritesTokenMissing = form.isSprites && !form.spritesSecretId; + const headerActions = + executor.type === "ssh" ? ( + + ) : undefined; const handleSave = () => { if (!form.name.trim() || form.mcpPolicyError || spritesTokenMissing) return; @@ -566,6 +581,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E executor={executor} profileName={profile.name} description={getExecutorDescription(executor.type)} + actions={headerActions} />

- Connect to a remote host over SSH and run agentctl there. linux/amd64 hosts only. + Connect to a remote Linux amd64 or macOS host and run agentctl there.

+
+ {actions} + +
diff --git a/apps/web/components/settings/ssh-agent-readiness-card.test.ts b/apps/web/components/settings/ssh-agent-readiness-card.test.ts new file mode 100644 index 0000000000..dc70b8a54d --- /dev/null +++ b/apps/web/components/settings/ssh-agent-readiness-card.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { readinessDisplayShell, readinessProbeBody } from "./ssh-agent-readiness-card"; + +describe("ssh agent readiness shell selection", () => { + it("omits shell override when the profile has no explicit shell", () => { + expect(readinessProbeBody("")).toEqual({}); + expect(readinessProbeBody(" ")).toEqual({}); + }); + + it("trims and sends explicit shell choices", () => { + expect(readinessProbeBody(" zsh ")).toEqual({ shell: "zsh" }); + }); + + it("displays the detected default when no explicit shell is saved", () => { + expect(readinessDisplayShell("", "zsh")).toBe("zsh"); + expect(readinessDisplayShell(" ", "zsh")).toBe("zsh"); + expect(readinessDisplayShell("", "")).toBe("bash"); + }); + + it("displays explicit shell choices over detected defaults", () => { + expect(readinessDisplayShell("bash", "zsh")).toBe("bash"); + }); +}); diff --git a/apps/web/components/settings/ssh-agent-readiness-card.tsx b/apps/web/components/settings/ssh-agent-readiness-card.tsx index c6efd3c114..10c9df0c46 100644 --- a/apps/web/components/settings/ssh-agent-readiness-card.tsx +++ b/apps/web/components/settings/ssh-agent-readiness-card.tsx @@ -25,6 +25,19 @@ export interface SSHAgentReadinessCardProps { const DEFAULT_SHELL = "bash"; +function normalizeShell(shell: string): string { + return shell.trim(); +} + +export function readinessProbeBody(shell: string): { shell?: string } { + const trimmed = normalizeShell(shell); + return trimmed ? { shell: trimmed } : {}; +} + +export function readinessDisplayShell(shell: string, defaultShell: string): string { + return normalizeShell(shell) || defaultShell || DEFAULT_SHELL; +} + // useReadinessState owns the card's fetch + selection state so the component // renders a thin view layer. Pulled out to keep the component body under the // max-lines-per-function lint budget. @@ -43,7 +56,8 @@ function useReadinessState({ const [hasProbed, setHasProbed] = useState(false); const [shells, setShells] = useState(null); const [shellsLoading, setShellsLoading] = useState(false); - const [shell, setShell] = useState(shellProp ?? DEFAULT_SHELL); + const [shell, setShell] = useState(shellProp ?? ""); + const [defaultShell, setDefaultShell] = useState(DEFAULT_SHELL); // Drop stale responses if the user clicks Refresh again before the // previous request lands — same pattern as ssh-sessions-card. const seqRef = useRef(0); @@ -53,8 +67,10 @@ function useReadinessState({ setLoading(true); setError(null); try { - const resp = await probeSSHAgents(executorId, { shell }); + const probeBody = readinessProbeBody(shell); + const resp = await probeSSHAgents(executorId, probeBody); if (seq !== seqRef.current) return; + if (!probeBody.shell && resp.shell) setDefaultShell(resp.shell); setRows(resp.rows); setHasProbed(true); } catch (e) { @@ -71,6 +87,7 @@ function useReadinessState({ setShellsLoading(true); try { const resp = await probeSSHShells(executorId); + setDefaultShell(resp.default_shell || DEFAULT_SHELL); setShells(resp.available); } catch { // On failure the dropdown stays empty; user can type / save the shell @@ -91,6 +108,7 @@ function useReadinessState({ setError(null); setHasProbed(false); setLoading(false); + setDefaultShell(DEFAULT_SHELL); void probeShells(); return () => { seqRef.current = -1; @@ -123,6 +141,7 @@ function useReadinessState({ shells, shellsLoading, shell, + defaultShell, refresh, handleShellChange, }; @@ -150,9 +169,11 @@ export function SSHAgentReadinessCard({ shells, shellsLoading, shell, + defaultShell, refresh, handleShellChange, } = state; + const displayShell = readinessDisplayShell(shell, defaultShell); return ( @@ -178,9 +199,10 @@ export function SSHAgentReadinessCard({ @@ -195,11 +217,13 @@ function ShellSelector({ shell, shells, loading, + defaultShell, onChange, }: { shell: string; shells: string[] | null; loading: boolean; + defaultShell: string; onChange: (s: string) => void | Promise; }) { // While probing, show a placeholder option so the dropdown can't surface @@ -218,7 +242,7 @@ function ShellSelector({ data-testid="ssh-readiness-shell" className="h-7 w-32 text-xs" > - + {options.map((s) => ( diff --git a/apps/web/components/settings/ssh-connection-card.tsx b/apps/web/components/settings/ssh-connection-card.tsx index e656278365..67d4564059 100644 --- a/apps/web/components/settings/ssh-connection-card.tsx +++ b/apps/web/components/settings/ssh-connection-card.tsx @@ -229,7 +229,7 @@ export function SSHConnectionCard(props: SSHConnectionCardProps) { Connection - Run an agent on any Linux box you can reach over SSH. linux/amd64 hosts only. + Run an agent on Linux amd64 or macOS hosts you can reach over SSH. diff --git a/apps/web/e2e/tests/settings/mobile-ssh-profile-connection-link.spec.ts b/apps/web/e2e/tests/settings/mobile-ssh-profile-connection-link.spec.ts new file mode 100644 index 0000000000..27b0752482 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-ssh-profile-connection-link.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("ssh profile connection settings on mobile", () => { + test("opens the connection test page from the SSH profile editor", async ({ + apiClient, + testPage, + }) => { + const executor = await apiClient.createSSHExecutor("Mobile SSH link target", { + ssh_host: "127.0.0.1", + ssh_port: "22", + ssh_user: "kandev", + ssh_identity_source: "agent", + ssh_host_fingerprint: "SHA256:test", + }); + const profile = await apiClient.createExecutorProfile(executor.id, { + name: "Mobile SSH profile with link", + config: {}, + prepare_script: "", + cleanup_script: "", + env_vars: [], + }); + + await testPage.goto(`/settings/executors/${profile.id}`); + + const link = testPage.getByTestId("ssh-connection-settings-link"); + await expect(link).toBeVisible(); + await link.click(); + await expect(testPage).toHaveURL(new RegExp(`/settings/executors/ssh/${executor.id}$`)); + await expect(testPage.getByTestId("ssh-test-button")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/settings/ssh-profile-connection-link.spec.ts b/apps/web/e2e/tests/settings/ssh-profile-connection-link.spec.ts new file mode 100644 index 0000000000..fb668df848 --- /dev/null +++ b/apps/web/e2e/tests/settings/ssh-profile-connection-link.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("ssh profile connection settings", () => { + test("opens the connection test page from the SSH profile editor", async ({ + apiClient, + testPage, + }) => { + const executor = await apiClient.createSSHExecutor("SSH link target", { + ssh_host: "127.0.0.1", + ssh_port: "22", + ssh_user: "kandev", + ssh_identity_source: "agent", + ssh_host_fingerprint: "SHA256:test", + }); + const profile = await apiClient.createExecutorProfile(executor.id, { + name: "SSH profile with link", + config: {}, + prepare_script: "", + cleanup_script: "", + env_vars: [], + }); + + await testPage.goto(`/settings/executors/${profile.id}`); + + await testPage.getByTestId("ssh-connection-settings-link").click(); + await expect(testPage).toHaveURL(new RegExp(`/settings/executors/ssh/${executor.id}$`)); + await expect(testPage.getByTestId("ssh-test-button")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/ssh/error-surfacing.spec.ts b/apps/web/e2e/tests/ssh/error-surfacing.spec.ts index a1c7c2736f..8dee4f23dd 100644 --- a/apps/web/e2e/tests/ssh/error-surfacing.spec.ts +++ b/apps/web/e2e/tests/ssh/error-surfacing.spec.ts @@ -62,17 +62,15 @@ test.describe("ssh error surfacing", () => { expect(handshake?.error).toMatch(/bastion|proxy|connection refused/i); }); - test("unsupported arm64 host surfaces the linux/amd64-only guidance", async ({ + test("supported platform check surfaces normalized platform output", async ({ apiClient, seedData, }) => { - // We can't trivially spin up arm64 sshd, but we can assert the message - // shape via the existing Verify arch step on the running container. - // When the host is the expected x86_64, this step succeeds — that's a - // negative control. The arm64 message itself is unit-tested in - // executor_ssh_connection_test.go (TestRequireSupportedArch). + // We can't trivially spin up every OS/arch SSH target in CI, but we can + // assert the successful platform step shape on the running Linux container. + // Unsupported platform guidance is unit-tested in executor_ssh_connection_test.go. const result = await apiClient.testSSHConnection({ - name: "Q-arch-control", + name: "Q-platform-control", host: seedData.sshTarget.host, port: seedData.sshTarget.port, user: seedData.sshTarget.user, @@ -80,9 +78,9 @@ test.describe("ssh error surfacing", () => { identity_file: seedData.sshTarget.identityFile, }); expect(result.success).toBe(true); - const arch = result.steps.find((s) => s.name === "Verify arch"); - expect(arch?.success).toBe(true); - expect(arch?.output).toBe("x86_64"); + const platform = result.steps.find((s) => s.name === "Verify platform"); + expect(platform?.success).toBe(true); + expect(platform?.output).toBe("linux/amd64"); }); test("missing agent binary on remote — launch fails with install hint, not raw exec error", async ({ diff --git a/apps/web/e2e/tests/ssh/test-endpoint.spec.ts b/apps/web/e2e/tests/ssh/test-endpoint.spec.ts index 6fa7a941ad..95929414dc 100644 --- a/apps/web/e2e/tests/ssh/test-endpoint.spec.ts +++ b/apps/web/e2e/tests/ssh/test-endpoint.spec.ts @@ -4,14 +4,14 @@ import { execInContainer } from "../../helpers/ssh"; /** * HTTP contract for POST /api/v1/ssh/test. The endpoint dials the host with a * permissive host-key callback (records the fingerprint but does not pin it), - * runs uname / arch / git probes, and reports whether agentctl is already + * runs uname / platform / git probes, and reports whether agentctl is already * cached on the remote. Drives the "Test Connection" UI flow. * * Covers e2e-plan.md group F (F1–F6). */ test.describe("ssh test-endpoint contract", () => { // F2 — happy-path step ordering against a reachable sshd container. - test("returns step ordering Resolve target -> SSH handshake -> Probe remote -> Verify arch -> Verify agentctl cache", async ({ + test("returns step ordering Resolve target -> SSH handshake -> Probe remote -> Verify platform -> Verify agentctl cache", async ({ apiClient, seedData, }) => { @@ -26,14 +26,16 @@ test.describe("ssh test-endpoint contract", () => { expect(result.success).toBe(true); expect(result.fingerprint).toBe(seedData.sshTarget.hostFingerprint); + expect(result.os).toBe("Linux"); expect(result.arch).toBe("x86_64"); + expect(result.platform).toBe("linux/amd64"); const stepNames = result.steps.map((s) => s.name); expect(stepNames).toEqual([ "Resolve target", "SSH handshake", "Probe remote", - "Verify arch", + "Verify platform", "Verify agentctl cache", ]); for (const step of result.steps) { @@ -81,7 +83,7 @@ test.describe("ssh test-endpoint contract", () => { expect(stepNames).toContain("Resolve target"); expect(stepNames).toContain("SSH handshake"); expect(stepNames).not.toContain("Probe remote"); - expect(stepNames).not.toContain("Verify arch"); + expect(stepNames).not.toContain("Verify platform"); const handshake = result.steps.find((s) => s.name === "SSH handshake"); expect(handshake?.success).toBe(false); expect(handshake?.error).toMatch(/tcp dial|connection refused|handshake/i); diff --git a/apps/web/e2e/tests/ssh/test-result.spec.ts b/apps/web/e2e/tests/ssh/test-result.spec.ts index 719b121ba0..72cd4b34bd 100644 --- a/apps/web/e2e/tests/ssh/test-result.spec.ts +++ b/apps/web/e2e/tests/ssh/test-result.spec.ts @@ -31,7 +31,7 @@ test.describe("ssh test-result UI", () => { await page.expectStepSuccess("resolve-target"); await page.expectStepSuccess("ssh-handshake"); await page.expectStepSuccess("probe-remote"); - await page.expectStepSuccess("verify-arch"); + await page.expectStepSuccess("verify-platform"); await page.expectStepSuccess("verify-agentctl-cache"); await expect(page.observedFingerprint()).toHaveText(seedData.sshTarget.hostFingerprint); @@ -104,7 +104,7 @@ test.describe("ssh test-result UI", () => { await page.waitForTestResult(); // alpine ships busybox; uname -a includes "Linux". await expect(page.step("probe-remote")).toContainText(/Linux/); - // arch step output is the raw arch string. - await expect(page.step("verify-arch")).toContainText("x86_64"); + // platform step output is the normalized goos/goarch tuple. + await expect(page.step("verify-platform")).toContainText("linux/amd64"); }); }); diff --git a/apps/web/lib/types/http-ssh.ts b/apps/web/lib/types/http-ssh.ts index 1872c1ab36..8ee6bc4d3d 100644 --- a/apps/web/lib/types/http-ssh.ts +++ b/apps/web/lib/types/http-ssh.ts @@ -23,7 +23,9 @@ export interface SSHTestResult { success: boolean; fingerprint?: string; uname_all?: string; + os?: string; arch?: string; + platform?: string; git_version?: string; agentctl_action?: "cached" | "needs_upload" | "skipped"; steps: SSHTestStep[]; @@ -64,6 +66,7 @@ export interface SSHAgentReadinessResponse { export interface SSHProbeShellsResponse { host: string; + default_shell: string; duration_ms: number; available: string[]; } diff --git a/docs/plans/desktop-tauri-app/plan.md b/docs/plans/desktop-tauri-app/plan.md index fd0c5ca071..e2602864ab 100644 --- a/docs/plans/desktop-tauri-app/plan.md +++ b/docs/plans/desktop-tauri-app/plan.md @@ -92,7 +92,9 @@ apps/desktop/src-tauri/resources/kandev/ └── bin/ ├── kandev[.exe] ├── agentctl[.exe] - └── agentctl-linux-amd64 + ├── agentctl-linux-amd64 + ├── agentctl-darwin-arm64 + └── agentctl-darwin-amd64 ``` The helper validates the exact binaries required by `internal/launcher/bundle.go` so the desktop app and existing runtime tarballs cannot drift. diff --git a/docs/plans/desktop-tauri-app/task-03-desktop-runtime-resources.md b/docs/plans/desktop-tauri-app/task-03-desktop-runtime-resources.md index ebaa1141ec..483aa77b72 100644 --- a/docs/plans/desktop-tauri-app/task-03-desktop-runtime-resources.md +++ b/docs/plans/desktop-tauri-app/task-03-desktop-runtime-resources.md @@ -13,7 +13,7 @@ spec: "../../specs/desktop-tauri-app/spec.md" ## Acceptance - A release helper extracts the existing platform runtime bundle into the deterministic Tauri resource layout. -- The helper validates `kandev`, `agentctl`, and `agentctl-linux-amd64` requirements consistently with the native launcher. +- The helper validates `kandev`, `agentctl`, and the remote agentctl helpers consistently with the native launcher. - The Tauri app resolves packaged resources without relying on `KANDEV_BUNDLE_DIR` or Node.js at runtime. ## Verification diff --git a/docs/specs/native-kandev-cli/plan.md b/docs/specs/native-kandev-cli/plan.md index 6bb27e9af8..15c4d9f1fe 100644 --- a/docs/specs/native-kandev-cli/plan.md +++ b/docs/specs/native-kandev-cli/plan.md @@ -215,7 +215,9 @@ Changes: kandev/ ├── bin/kandev ├── bin/agentctl -└── bin/agentctl-linux-amd64 +├── bin/agentctl-linux-amd64 +├── bin/agentctl-darwin-arm64 +└── bin/agentctl-darwin-amd64 ``` - Stop requiring `dist/kandev/cli` for Homebrew; platform bundles contain native `bin/` artifacts with web assets embedded in `bin/kandev`. diff --git a/docs/specs/native-kandev-cli/spec.md b/docs/specs/native-kandev-cli/spec.md index 01fad08d9c..429235b546 100644 --- a/docs/specs/native-kandev-cli/spec.md +++ b/docs/specs/native-kandev-cli/spec.md @@ -70,7 +70,9 @@ Release bundles expose this layout: kandev/ ├── bin/kandev ├── bin/agentctl -└── bin/agentctl-linux-amd64 +├── bin/agentctl-linux-amd64 +├── bin/agentctl-darwin-arm64 +└── bin/agentctl-darwin-amd64 ``` `bin/kandev` is both the public launcher and the hidden backend-mode executable. diff --git a/docs/specs/ssh-executor/e2e-plan.md b/docs/specs/ssh-executor/e2e-plan.md index 2fa96010d7..49a52e5f20 100644 --- a/docs/specs/ssh-executor/e2e-plan.md +++ b/docs/specs/ssh-executor/e2e-plan.md @@ -115,9 +115,10 @@ All locators use `data-testid` attributes added to `ssh-settings.tsx` in the sam These are intentionally out of scope for this PR. Each one names what unblocks it. -- **arm64 remote host (O1, O2)** — needs `agentctl-linux-arm64` in the build pipeline and `qemu-user-static` available in CI to run an arm64 sshd container. Revisit when the arm64 follow-up lands (see spec.md "Future scope: Linux arm64 agentctl"). Until then, the `requireSupportedArch` gate is covered indirectly by H1 succeeding only when the host is amd64. +- **Linux arm64 remote host (O1, O2)** — needs `agentctl-linux-arm64` in the build pipeline and `qemu-user-static` available in CI to run an arm64 sshd container. Revisit when the Linux arm64 follow-up lands. Until then, the unsupported-platform gate is covered by backend unit tests. - **Chained ProxyJump beyond first hop** — v1 spec says single bastion only. N3 just verifies we *fail* on a chained config rather than silently misroute. A future "chained ProxyJump" feature would add positive cases. -- **macOS / Windows remote** — needs `agentctl-darwin-*` / `agentctl-windows-*` binaries. +- **macOS remote E2E** — supported by the runtime, but needs macOS SSH host coverage in CI or a manual runner. +- **Windows remote** — needs `agentctl-windows-*` binaries and a Windows SSH host harness. - **Reverse-forward of local MCP servers to the remote agent** — not in v1 spec; would need its own test plan when that feature lands. ## Phasing (within this PR) diff --git a/docs/specs/ssh-executor/spec.md b/docs/specs/ssh-executor/spec.md index c5eca4d131..a75014b8d7 100644 --- a/docs/specs/ssh-executor/spec.md +++ b/docs/specs/ssh-executor/spec.md @@ -78,14 +78,14 @@ Multiple sessions in the *same* task share the same worktree on disk (same files - On subsequent sessions for the same task on the same host: detect the existing task dir, skip clone, attach a new agentctl to it. - On `StopInstance` of the *last* session in a task: leave the task dir intact (so a later resume keeps history); only remove it on explicit task-level cleanup (out of scope for v1 — see below). -### agentctl binary upload with content-hash cache (amd64 only in v1) +### agentctl binary upload with content-hash cache -- On `CreateInstance`, resolve the local `agentctl-linux-amd64` binary via the existing `AgentctlResolver`. -- Detect remote architecture via `uname -m` over SSH. **If the remote is not `x86_64`, fail with a clear error**: "SSH executor v1 supports linux/amd64 only; this host reports ``". Matches the current Docker executor's reality — the Makefile only produces `agentctl-linux-amd64`. +- On `CreateInstance`, detect the remote platform via `uname -s` and `uname -m`, normalize it to a Go `GOOS/GOARCH` tuple, and resolve the matching agentctl helper via `AgentctlResolver`. +- Supported remote platforms are `linux/amd64`, `darwin/arm64`, and `darwin/amd64`. Unsupported platforms fail with a clear error: `unsupported remote platform "" — SSH executor supports linux/amd64, darwin/arm64, and darwin/amd64`. +- Runtime bundles include `agentctl-linux-amd64`, `agentctl-darwin-arm64`, and `agentctl-darwin-amd64`; development builds produce them with `make -C apps/backend build-agentctl-remote`. - Compute SHA256 locally; check `~/.kandev/bin/agentctl.sha256` on the remote via `sha256sum`. Upload only if missing or mismatched. - Upload via SFTP to `~/.kandev/bin/agentctl` (chmod 755), then write the sha256 sidecar. - Binary is shared across all tasks and sessions on the host. -- **arm64 is a separate follow-up** that needs a new `build-agentctl-linux-arm64` Makefile target and an arch-aware resolver — benefits Docker too. ### Credential push reuses the existing `FileUploader` seam @@ -104,6 +104,7 @@ Multiple sessions in the *same* task share the same worktree on disk (same files - **`SSHConnectionCard`** — the form + "Test connection" gate. Fields: name, `host` (or `host_alias` from `~/.ssh/config`), `port`, `user`, identity source (`agent` | `file`), optional `identity_file`, optional `proxy_jump`. Save disabled until a successful test produced a fingerprint and the "Trust this host" checkbox is ticked. - **`SSHSessionsCard`** — mirrors `SpritesInstancesCard` and `DockerContainersCard`. Table columns: task ID, session ID, host (`user@host`), remote agentctl port, local forward port, uptime, status badge. Data sourced from `GET /api/v1/ssh/executors/{id}/sessions`. Refreshes every 90 seconds. - Per-profile settings: `workdir_root` stored on `ExecutorProfile.Config` (so the same host can serve different profiles into different roots). +- SSH profile pages include a direct "Connection Settings" action back to the host-level SSH connection page so users can find Test connection / re-trust without knowing the dedicated executor URL. ### Edit-with-live-sessions UX @@ -113,7 +114,7 @@ Multiple sessions in the *same* task share the same worktree on disk (same files ### Telemetry & errors - Same step-progress callback used by Sprites (`OnProgress`) reports the per-step status: "Connecting → Detecting remote OS → Uploading agent controller → Preparing task directory → Starting agent controller → Connecting to agent controller". -- Connection errors surface verbatim (host unreachable, auth failed, host key mismatch, arm64 unsupported) — no swallowing into generic "executor failed". +- Connection errors surface verbatim (host unreachable, auth failed, host key mismatch, unsupported platform) — no swallowing into generic "executor failed". ## Scenarios @@ -129,6 +130,8 @@ Multiple sessions in the *same* task share the same worktree on disk (same files - **GIVEN** the user opens the SSH executor's settings page, **WHEN** they scroll to "Active sessions", **THEN** they see each running session on this host (task, session ID, user@host, remote port, local fwd port, uptime, status) — same shape as the Docker containers table and Sprites instances table. +- **GIVEN** a user is editing an SSH executor profile, **WHEN** they need to re-test or re-trust the host, **THEN** the profile page exposes a direct Connection Settings action that opens the SSH connection page with the Test connection button. + - **GIVEN** a user edits the SSH executor's `host` field while two sessions are running, **WHEN** they click Save, **THEN** a confirm modal warns them the running sessions will keep going on the old host and only new sessions will use the new host. On confirm, the executor row updates and the live sessions are unaffected. - **GIVEN** a host is reachable only via a bastion, **WHEN** the user's `~/.ssh/config` has `ProxyJump bastion.example.com` for that host alias, **THEN** kandev parses the config, dials through the bastion, and the rest of the flow is unchanged. @@ -137,8 +140,8 @@ Multiple sessions in the *same* task share the same worktree on disk (same files ## Out of scope (v1) -- **Remote macOS / Windows.** Linux remote only; agentctl is built for Linux. macOS via SSH is a plausible v2. -- **Remote arm64.** Matches the current state of Docker. Lands when a new `build-agentctl-linux-arm64` Makefile target + arch-aware resolver are added (cross-cutting change benefiting Docker too); not part of this spec. +- **Remote Windows.** SSH transport to Windows is out of scope. +- **Remote Linux arm64.** Lands when a new `agentctl-linux-arm64` helper is built, packaged, and covered by CI. - **Password / keyboard-interactive auth.** Keys + agent only. - **Passphrase-protected keys handled inside kandev.** User must load the key into `ssh-agent`. - **Chained ProxyJump.** Single bastion only; multi-hop deferred. @@ -156,7 +159,6 @@ Multiple sessions in the *same* task share the same worktree on disk (same files - **Auto-provision mode**: kandev runs a one-shot install script on the remote (apt/yum/brew) to set up node/docker/etc. before the first task — turns a bare Hetzner box into a ready-to-use kandev host. - **Multi-user-per-host** with per-task Linux user impersonation (`sudo -u worker-N`) for genuine isolation on shared boxes. - **Chained ProxyJump** and `ProxyCommand` support for esoteric setups. -- **macOS remote** once an `agentctl-darwin` binary exists. - **"Push working tree" mode** for users who want to test uncommitted changes against a remote (rsync the worktree instead of cloning). - **Pooled host capacity**: configure 3 SSH hosts as a pool, kandev round-robins / load-balances tasks across them. - **SSH-tunneled MCP servers**: expose a user's local MCP servers to a remote-running agent via reverse forward. diff --git a/scripts/release-desktop.test.sh b/scripts/release-desktop.test.sh index a81324892f..0e0e0bd65f 100644 --- a/scripts/release-desktop.test.sh +++ b/scripts/release-desktop.test.sh @@ -19,6 +19,11 @@ SIGNING_SECRET_ENV=( -u WINDOWS_CERTIFICATE -u WINDOWS_CERTIFICATE_PASSWORD ) +REMOTE_AGENTCTL_HELPERS=( + agentctl-linux-amd64 + agentctl-darwin-arm64 + agentctl-darwin-amd64 +) fail() { echo "FAIL: $*" >&2 @@ -40,16 +45,20 @@ write_runtime() { printf '#!/usr/bin/env bash\nexit 0\n' > "$dir/bin/kandev" printf '#!/usr/bin/env bash\nexit 0\n' > "$dir/bin/agentctl" if [ "$helper" = "with-helper" ]; then - printf '#!/usr/bin/env bash\nexit 0\n' > "$dir/bin/agentctl-linux-amd64" + for remote_helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + printf '#!/usr/bin/env bash\nexit 0\n' > "$dir/bin/$remote_helper" + done fi } chmod_runtime() { local dir="$1" chmod +x "$dir/bin/kandev" "$dir/bin/agentctl" - if [ -f "$dir/bin/agentctl-linux-amd64" ]; then - chmod +x "$dir/bin/agentctl-linux-amd64" - fi + for remote_helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + if [ -f "$dir/bin/$remote_helper" ]; then + chmod +x "$dir/bin/$remote_helper" + fi + done } runtime_dir="$TMP_DIR/runtime" @@ -75,6 +84,16 @@ fi grep -q "Missing agentctl linux/amd64 helper" "$ERR_FILE" || fail "verify-desktop-runtime did not explain missing helper" pass "verify-desktop-runtime requires helper for linux-x64 runtime" +missing_darwin_helper_runtime_dir="$TMP_DIR/missing-darwin-helper-runtime" +write_runtime "$missing_darwin_helper_runtime_dir" +rm "$missing_darwin_helper_runtime_dir/bin/agentctl-darwin-arm64" +chmod_runtime "$missing_darwin_helper_runtime_dir" +if "$ROOT_DIR/scripts/release/verify-desktop-runtime.sh" --platform macos-arm64 "$missing_darwin_helper_runtime_dir" >"$OUT_FILE" 2>"$ERR_FILE"; then + fail "verify-desktop-runtime should require darwin helper" +fi +grep -q "Missing agentctl darwin/arm64 helper" "$ERR_FILE" || fail "verify-desktop-runtime did not explain missing darwin helper" +pass "verify-desktop-runtime requires darwin helper" + nonexec_helper_runtime_dir="$TMP_DIR/nonexec-helper-runtime" write_runtime "$nonexec_helper_runtime_dir" chmod +x "$nonexec_helper_runtime_dir/bin/kandev" "$nonexec_helper_runtime_dir/bin/agentctl" @@ -88,7 +107,9 @@ windows_runtime_dir="$TMP_DIR/windows-runtime" mkdir -p "$windows_runtime_dir/bin" printf 'stub\n' > "$windows_runtime_dir/bin/kandev.exe" printf 'stub\n' > "$windows_runtime_dir/bin/agentctl.exe" -printf 'stub\n' > "$windows_runtime_dir/bin/agentctl-linux-amd64" +for remote_helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + printf 'stub\n' > "$windows_runtime_dir/bin/$remote_helper" +done OS=Windows_NT "$ROOT_DIR/scripts/release/verify-desktop-runtime.sh" --platform windows-x64 "$windows_runtime_dir" >"$OUT_FILE" grep -q "verified for windows-x64" "$OUT_FILE" || fail "verify-desktop-runtime did not accept Windows-host runtime" pass "verify-desktop-runtime accepts Windows-host helper without POSIX mode bits" @@ -99,20 +120,24 @@ linux_output_dir="$TMP_DIR/linux-output" --platform linux-x64 \ --output-dir "$linux_output_dir" >"$OUT_FILE" grep -q "prepared for linux-x64" "$OUT_FILE" || fail "prepare-desktop-runtime did not include platform output" -if [ ! -x "$linux_output_dir/bin/agentctl-linux-amd64" ]; then - fail "prepare-desktop-runtime should copy executable helper for linux-x64" -fi -pass "prepare-desktop-runtime copies helper for linux-x64" +for remote_helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + if [ ! -x "$linux_output_dir/bin/$remote_helper" ]; then + fail "prepare-desktop-runtime should copy executable $remote_helper for linux-x64" + fi +done +pass "prepare-desktop-runtime copies helpers for linux-x64" macos_output_dir="$TMP_DIR/macos-output" "$ROOT_DIR/scripts/release/prepare-desktop-runtime.sh" \ --bundle-dir "$runtime_dir" \ --platform macos-arm64 \ --output-dir "$macos_output_dir" >/dev/null -if [ ! -x "$macos_output_dir/bin/agentctl-linux-amd64" ]; then - fail "prepare-desktop-runtime should copy executable helper for macos-arm64" -fi -pass "prepare-desktop-runtime copies helper for non-linux-x64" +for remote_helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + if [ ! -x "$macos_output_dir/bin/$remote_helper" ]; then + fail "prepare-desktop-runtime should copy executable $remote_helper for macos-arm64" + fi +done +pass "prepare-desktop-runtime copies helpers for non-linux-x64" if "$ROOT_DIR/scripts/release/prepare-desktop-runtime.sh" --bundle-dir "$runtime_dir" --output-dir / >"$OUT_FILE" 2>"$ERR_FILE"; then fail "prepare-desktop-runtime should reject root output directory" diff --git a/scripts/release/package-bundle.sh b/scripts/release/package-bundle.sh index 95050aaf55..05eda746de 100755 --- a/scripts/release/package-bundle.sh +++ b/scripts/release/package-bundle.sh @@ -2,12 +2,17 @@ # Finalize the dist/kandev/ release layout from already-built pieces. # Caller must have run, in this order: # - Vite assets synced into apps/backend/internal/webapp/embedded/generated -# - go build ./cmd/{kandev,agentctl} -o dist/kandev/bin/... +# - go build ./cmd/{kandev,agentctl} plus remote agentctl helpers into dist/kandev/bin/... # After this: dist/kandev/bin is ready to install or tar. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" BUNDLE="$ROOT_DIR/dist/kandev" +REMOTE_AGENTCTL_HELPERS=( + agentctl-linux-amd64 + agentctl-darwin-arm64 + agentctl-darwin-amd64 +) if [ ! -f "$BUNDLE/bin/kandev" ] && [ ! -f "$BUNDLE/bin/kandev.exe" ]; then echo "Missing native launcher in $BUNDLE/bin; build cmd/kandev first" >&2 @@ -19,9 +24,11 @@ if [ ! -f "$BUNDLE/bin/agentctl" ] && [ ! -f "$BUNDLE/bin/agentctl.exe" ]; then exit 1 fi -if [ ! -f "$BUNDLE/bin/agentctl-linux-amd64" ]; then - echo "Missing linux/amd64 agentctl helper in $BUNDLE/bin; build cmd/agentctl for linux/amd64 first" >&2 - exit 1 -fi +for helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + if [ ! -f "$BUNDLE/bin/$helper" ]; then + echo "Missing remote agentctl helper $helper in $BUNDLE/bin; run make -C apps/backend build-agentctl-remote first" >&2 + exit 1 + fi +done echo "Bundle assembled at $BUNDLE" diff --git a/scripts/release/prepare-desktop-runtime.sh b/scripts/release/prepare-desktop-runtime.sh index ac5d6d5ad0..3fa06d133a 100755 --- a/scripts/release/prepare-desktop-runtime.sh +++ b/scripts/release/prepare-desktop-runtime.sh @@ -12,6 +12,8 @@ runtime bundle. The input bundle must contain: bin/kandev[.exe] bin/agentctl[.exe] bin/agentctl-linux-amd64 + bin/agentctl-darwin-arm64 + bin/agentctl-darwin-amd64 Options: --bundle-dir DIR Source runtime bundle. Defaults to dist/kandev. @@ -27,6 +29,11 @@ BUNDLE_DIR="${ROOT_DIR}/dist/kandev" OUTPUT_DIR="${ROOT_DIR}/apps/desktop/src-tauri/resources/kandev" VERIFY_SCRIPT="${ROOT_DIR}/scripts/release/verify-desktop-runtime.sh" PLATFORM="" +REMOTE_AGENTCTL_HELPERS=( + agentctl-linux-amd64 + agentctl-darwin-arm64 + agentctl-darwin-amd64 +) while [ "$#" -gt 0 ]; do case "$1" in @@ -74,7 +81,10 @@ refuse_dangerous_output_dir() { } refuse_dangerous_output_dir -chmod +x "$BUNDLE_DIR/bin/kandev" "$BUNDLE_DIR/bin/agentctl" "$BUNDLE_DIR/bin/agentctl-linux-amd64" 2>/dev/null || true +chmod +x "$BUNDLE_DIR/bin/kandev" "$BUNDLE_DIR/bin/agentctl" 2>/dev/null || true +for helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + chmod +x "$BUNDLE_DIR/bin/$helper" 2>/dev/null || true +done chmod +x "$BUNDLE_DIR/bin/kandev.exe" "$BUNDLE_DIR/bin/agentctl.exe" 2>/dev/null || true VERIFY_ARGS=() if [ -n "$PLATFORM" ]; then @@ -103,8 +113,9 @@ copy_one() { copy_one "Kandev launcher binary" "$BUNDLE_DIR/bin/kandev" "$BUNDLE_DIR/bin/kandev.exe" copy_one "agentctl binary" "$BUNDLE_DIR/bin/agentctl" "$BUNDLE_DIR/bin/agentctl.exe" -cp "$BUNDLE_DIR/bin/agentctl-linux-amd64" "$OUTPUT_DIR/bin/agentctl-linux-amd64" -chmod +x "$OUTPUT_DIR/bin/agentctl-linux-amd64" 2>/dev/null || true +for helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + copy_one "remote agentctl helper $helper" "$BUNDLE_DIR/bin/$helper" +done "$VERIFY_SCRIPT" "${VERIFY_ARGS[@]}" "$OUTPUT_DIR" >/dev/null if [ -n "$PLATFORM" ]; then diff --git a/scripts/release/verify-desktop-runtime.sh b/scripts/release/verify-desktop-runtime.sh index 7ceba7ac09..cd2ee737f1 100755 --- a/scripts/release/verify-desktop-runtime.sh +++ b/scripts/release/verify-desktop-runtime.sh @@ -13,6 +13,8 @@ Validate a desktop runtime directory with this layout: kandev[.exe] agentctl[.exe] agentctl-linux-amd64 + agentctl-darwin-arm64 + agentctl-darwin-amd64 If runtime-dir is omitted, apps/desktop/src-tauri/resources/kandev is checked. EOF @@ -22,6 +24,11 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" RUNTIME_DIR="${ROOT_DIR}/apps/desktop/src-tauri/resources/kandev" RUNTIME_DIR_SET=false PLATFORM="" +REMOTE_AGENTCTL_HELPERS=( + "agentctl-linux-amd64:agentctl linux/amd64 helper" + "agentctl-darwin-arm64:agentctl darwin/arm64 helper" + "agentctl-darwin-amd64:agentctl darwin/amd64 helper" +) while [ "$#" -gt 0 ]; do case "$1" in @@ -61,7 +68,7 @@ is_executable_file() { # MSYS/Git Bash can report Windows executables differently from Unix mode bits. if [ "${OS:-}" = "Windows_NT" ]; then case "$(basename "$path")" in - *.exe|agentctl-linux-amd64) return 0 ;; + *.exe|agentctl-linux-amd64|agentctl-darwin-arm64|agentctl-darwin-amd64) return 0 ;; esac fi return 1 @@ -108,7 +115,11 @@ fi require_one "Kandev launcher binary" "$BIN_DIR/kandev" "$BIN_DIR/kandev.exe" require_one "agentctl binary" "$BIN_DIR/agentctl" "$BIN_DIR/agentctl.exe" -require_executable "agentctl linux/amd64 helper" "$BIN_DIR/agentctl-linux-amd64" +for helper_spec in "${REMOTE_AGENTCTL_HELPERS[@]}"; do + helper="${helper_spec%%:*}" + label="${helper_spec#*:}" + require_executable "$label" "$BIN_DIR/$helper" +done if [ -n "$PLATFORM" ]; then printf 'Desktop runtime verified for %s at %s\n' "$PLATFORM" "$RUNTIME_DIR"