feat: support remote macos ssh hosts (#1534)

This commit is contained in:
Carlos Florêncio
2026-06-29 19:06:33 +01:00
committed by GitHub
parent bdbde9d12e
commit f3b26dbc5a
43 changed files with 1018 additions and 241 deletions
+10 -8
View File
@@ -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
+15 -8
View File
@@ -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 && \
+14 -25
View File
@@ -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:
+29 -11
View File
@@ -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..."
@@ -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_<OS>_<ARCH>_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
}
@@ -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)
}
}
@@ -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 <workdir>/tasks/<taskDir> and <taskDir>/.kandev/sessions/<sid>.
@@ -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.
@@ -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)
}
err := requireSupportedArch("aarch64")
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("aarch64 should not be supported")
t.Fatal("expected missing host_fingerprint error")
}
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)
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)
}
}
}
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("linux/arm64 should not be supported yet")
}
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)
}
}
}
@@ -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)
@@ -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
@@ -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 {
return nil
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 '<cmd>'` 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
+16 -8
View File
@@ -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"
+40 -11
View File
@@ -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))
}
}
+55 -20
View File
@@ -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
@@ -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)
}
})
}
}
@@ -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")
}
+3 -3
View File
@@ -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.
+3 -1
View File
@@ -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"',
);
+2
View File
@@ -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.
+11 -4
View File
@@ -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);
}
}
}
+27 -2
View File
@@ -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
}
@@ -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" ? (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/settings/executors/ssh/${executor.id}`)}
className="w-full cursor-pointer sm:w-auto"
data-testid="ssh-connection-settings-link"
>
<IconShieldLock className="mr-1.5 h-4 w-4" />
Connection Settings
</Button>
) : 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}
/>
<ProfileEditSections
executor={executor}
@@ -606,5 +622,6 @@ function getExecutorDescription(type: ExecutorType): string {
if (type === "local_docker") return "Runs Docker containers on this machine.";
if (type === "remote_docker") return "Connects to a remote Docker host.";
if (type === "sprites") return "Runs agents in Sprites.dev cloud sandboxes.";
if (type === "ssh") return "Runs agents on a trusted Linux amd64 or macOS host over SSH.";
return "Custom executor.";
}
@@ -93,7 +93,7 @@ function SSHCreateHeader() {
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
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.
</p>
</div>
<Button
@@ -1,5 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { useRouter } from "@/lib/routing/client-router";
import { IconTrash } from "@tabler/icons-react";
@@ -61,10 +62,12 @@ export function ProfileHeader({
executor,
profileName,
description,
actions,
}: {
executor: Executor;
profileName: string;
description: string;
actions?: ReactNode;
}) {
const router = useRouter();
return (
@@ -80,14 +83,17 @@ export function ProfileHeader({
</div>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push(EXECUTORS_ROUTE)}
className="cursor-pointer"
>
Back to Executors
</Button>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
{actions}
<Button
variant="outline"
size="sm"
onClick={() => router.push(EXECUTORS_ROUTE)}
className="w-full cursor-pointer sm:w-auto"
>
Back to Executors
</Button>
</div>
</div>
<Separator />
</>
@@ -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");
});
});
@@ -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<string[] | null>(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 (
<Card data-testid="ssh-agent-readiness-card">
@@ -178,9 +199,10 @@ export function SSHAgentReadinessCard({
</Button>
</div>
<ShellSelector
shell={shell}
shell={displayShell}
shells={shells}
loading={shellsLoading}
defaultShell={defaultShell}
onChange={handleShellChange}
/>
</CardHeader>
@@ -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<void>;
}) {
// 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"
>
<SelectValue placeholder="bash" />
<SelectValue placeholder={defaultShell || DEFAULT_SHELL} />
</SelectTrigger>
<SelectContent>
{options.map((s) => (
@@ -229,7 +229,7 @@ export function SSHConnectionCard(props: SSHConnectionCardProps) {
Connection
</CardTitle>
<CardDescription>
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.
</CardDescription>
</div>
<ConnectionBadge fingerprint={c.form.host_fingerprint} />
@@ -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();
});
});
@@ -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();
});
});
+8 -10
View File
@@ -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 ({
+6 -4
View File
@@ -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);
+3 -3
View File
@@ -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");
});
});
+3
View File
@@ -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[];
}
+3 -1
View File
@@ -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.
@@ -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
+3 -1
View File
@@ -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`.
+3 -1
View File
@@ -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.
+3 -2
View File
@@ -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)
+10 -8
View File
@@ -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 `<arch>`". 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 "<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.
+38 -13
View File
@@ -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"
+12 -5
View File
@@ -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"
+14 -3
View File
@@ -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
+13 -2
View File
@@ -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"