diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f472f369d1..c99dbbc3ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -519,6 +519,9 @@ jobs: run: | cd apps/backend GOOS=linux GOARCH=amd64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-linux-amd64 ./cmd/agentctl + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 CC= go build -ldflags "${LDFLAGS}" -o bin/agentctl-linux-arm64 ./cmd/agentctl + # darwin/arm64 is ad-hoc-signed by the Go linker at link time, which is + # what Apple Silicon (AMFI) requires; bundle validation enforces it. 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 @@ -530,6 +533,7 @@ 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-linux-arm64 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 diff --git a/Dockerfile b/Dockerfile index a1fa4a0edd..579180e45f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,6 +89,7 @@ RUN mkdir -p /app/apps/backend/bin /data/worktrees # bundle/bin/kandev - native Go backend (per-arch) # bundle/bin/agentctl - native agentctl (per-arch) # bundle/bin/agentctl-linux-amd64 - linux/amd64 agentctl helper +# bundle/bin/agentctl-linux-arm64 - linux/arm64 agentctl helper # bundle/bin/agentctl-darwin-arm64 - darwin/arm64 agentctl helper # bundle/bin/agentctl-darwin-amd64 - darwin/amd64 agentctl helper # @@ -98,6 +99,7 @@ RUN mkdir -p /app/apps/backend/bin /data/worktrees # 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-linux-arm64 /app/apps/backend/bin/agentctl-linux-arm64 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 @@ -108,6 +110,7 @@ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x \ /app/apps/backend/bin/kandev \ /app/apps/backend/bin/agentctl-linux-amd64 \ + /app/apps/backend/bin/agentctl-linux-arm64 \ /app/apps/backend/bin/agentctl-darwin-arm64 \ /app/apps/backend/bin/agentctl-darwin-amd64 \ /usr/local/bin/agentctl \ diff --git a/Makefile b/Makefile index 5489cde90a..21ab85673c 100644 --- a/Makefile +++ b/Makefile @@ -266,6 +266,7 @@ service-bundle: install build @mkdir -p "$(SERVICE_BUNDLE_DIR)/bin" @cp "$(BACKEND_DIR)/bin/kandev" "$(BACKEND_DIR)/bin/agentctl" \ "$(BACKEND_DIR)/bin/agentctl-linux-amd64" \ + "$(BACKEND_DIR)/bin/agentctl-linux-arm64" \ "$(BACKEND_DIR)/bin/agentctl-darwin-arm64" \ "$(BACKEND_DIR)/bin/agentctl-darwin-amd64" \ "$(SERVICE_BUNDLE_DIR)/bin/" diff --git a/apps/backend/Makefile b/apps/backend/Makefile index e4ba2248e7..b96966eaf3 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -31,18 +31,41 @@ ifeq ($(OS),Windows_NT) # 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_LINUX_ARM64 = set CGO_ENABLED=0& set GOOS=linux& set GOARCH=arm64& 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 + # Windows recipes run under cmd.exe, which has no POSIX `if command -v ...; then` + # syntax. Neither codesign nor rcodesign ships on Windows anyway, so ad-hoc + # signing is a no-op here: darwin/arm64 stays Go-signed at link time, and + # darwin/amd64 runs only on Intel Macs (which don't enforce signing at exec). + define adhoc_sign_darwin + @echo (skipping darwin ad-hoc sign on Windows: darwin/arm64 is Go-signed, darwin/amd64 runs on Intel) + endef else RM = rm -f RMDIR = rm -rf MKDIR = mkdir -p CGO_PREFIX = CGO_ENABLED=1 GO_LINUX_AMD64 = CGO_ENABLED=0 GOOS=linux GOARCH=amd64 + GO_LINUX_ARM64 = CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GO_DARWIN_ARM64 = CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 GO_DARWIN_AMD64 = CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 EXE = + # Ad-hoc sign a darwin Mach-O so Apple Silicon (AMFI) will run it; ad-hoc needs + # no Apple identity. Go already ad-hoc-signs darwin/arm64 at link time, so this + # is mainly to (a) sign darwin/amd64 too and (b) re-sign uniformly. Prefer + # codesign on macOS, fall back to rcodesign on Linux/CI. If neither is present, + # warn but don't fail (darwin/arm64 stays Go-signed; darwin/amd64 runs on Intel). + define adhoc_sign_darwin + @if command -v codesign >/dev/null 2>&1; then \ + echo " codesign --sign - $(1)"; codesign --force --sign - "$(1)"; \ + elif command -v rcodesign >/dev/null 2>&1; then \ + echo " rcodesign sign (ad-hoc) $(1)"; rcodesign sign "$(1)"; \ + else \ + echo " WARNING: no codesign/rcodesign found; $(1) left as-is (darwin/arm64 is Go-signed; darwin/amd64 runs only on Intel Macs)" >&2; \ + fi + endef endif # Build info @@ -52,7 +75,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-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 +.PHONY: all build build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 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 @@ -108,7 +131,7 @@ build-agentctl: $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl$(EXE) ./cmd/agentctl ## 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-remote: build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 ## Build agentctl for linux/amd64 (used by Docker/Sprites and Linux SSH hosts) build-agentctl-linux: @@ -116,17 +139,25 @@ build-agentctl-linux: -@$(MKDIR) $(BUILD_DIR) $(GO_LINUX_AMD64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-linux-amd64 ./cmd/agentctl +## Build agentctl for linux/arm64 (used by ARM Linux SSH hosts, e.g. Graviton) +build-agentctl-linux-arm64: + @echo "Building agentctl for linux/arm64..." + -@$(MKDIR) $(BUILD_DIR) + $(GO_LINUX_ARM64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-linux-arm64 ./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 + $(call adhoc_sign_darwin,$(BUILD_DIR)/agentctl-darwin-arm64) ## 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 + $(call adhoc_sign_darwin,$(BUILD_DIR)/agentctl-darwin-amd64) ## Build mock-agent for linux/amd64 (used by Docker E2E tests) build-mock-agent-linux: diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index e63ef3b47d..2ad1c22116 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -206,6 +206,51 @@ func IsSessionScopedMetadataKey(key string) bool { return sessionScopedMetadataKeys[key] } +// sshWorkspaceFallbackKeys are the STABLE SSH executor-config keys projected +// into workspace metadata as a fallback for terminal / workspace-restore when +// no live ExecutorRunning record exists. This is deliberately a connection + +// per-profile allowlist and MUST NOT include the session-scoped runtime keys +// (remote session dir, agentctl port/PID/URL, local forward port) — projecting +// a stale one would make the lifecycle manager try to reattach to a dead remote +// agentctl instance instead of creating a fresh one. It mirrors +// trustedExecutorConfigKeys (the connection-routing set targetFromMetadata +// reads) plus the two per-profile keys the terminal path needs (workdir root, +// login shell). Notably it includes ssh_host_alias so alias-only executors +// (host read from ~/.ssh/config) survive restore. +var sshWorkspaceFallbackKeys = map[string]bool{ + MetadataKeySSHHost: true, + MetadataKeySSHHostAlias: true, + MetadataKeySSHPort: true, + MetadataKeySSHUser: true, + MetadataKeySSHHostFingerprint: true, + MetadataKeySSHIdentitySource: true, + MetadataKeySSHIdentityFile: true, + MetadataKeySSHProxyJump: true, + MetadataKeySSHWorkdirRoot: true, + MetadataKeySSHShell: true, +} + +// FilterSSHWorkspaceFallbackConfig returns the subset of a stored SSH executor +// config that is safe to project into workspace metadata as a fallback for the +// terminal / workspace-restore path. Only stable connection + per-profile keys +// are copied (see sshWorkspaceFallbackKeys); session-scoped runtime keys are +// intentionally dropped. Returns nil when nothing matches. +func FilterSSHWorkspaceFallbackConfig(config map[string]string) map[string]interface{} { + if len(config) == 0 { + return nil + } + filtered := make(map[string]interface{}) + for k, v := range config { + if sshWorkspaceFallbackKeys[k] { + filtered[k] = v + } + } + if len(filtered) == 0 { + return nil + } + return filtered +} + // FilterPersistentMetadata returns a copy of src containing only keys that // should be carried forward across session resumes. Returns nil if no keys match. func FilterPersistentMetadata(src map[string]interface{}) map[string]interface{} { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go index c8683b165b..3b428868b7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go @@ -142,7 +142,7 @@ func TestNormalizeSSHRemotePlatform(t *testing.T) { {"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}, + {"linux arm64", "Linux", "aarch64", "linux", "arm64", true}, {"freebsd amd64 unsupported", "FreeBSD", "x86_64", "", "amd64", false}, } for _, tc := range cases { @@ -162,6 +162,7 @@ func TestNormalizeSSHRemotePlatform(t *testing.T) { func TestRequireSupportedRemotePlatform(t *testing.T) { for _, platform := range []SSHRemotePlatform{ {GOOS: "linux", GOARCH: "amd64", UnameOS: "Linux", UnameArch: "x86_64"}, + {GOOS: "linux", GOARCH: "arm64", UnameOS: "Linux", UnameArch: "aarch64"}, {GOOS: "darwin", GOARCH: "arm64", UnameOS: "Darwin", UnameArch: "arm64"}, {GOOS: "darwin", GOARCH: "amd64", UnameOS: "Darwin", UnameArch: "x86_64"}, } { @@ -169,12 +170,12 @@ func TestRequireSupportedRemotePlatform(t *testing.T) { t.Errorf("%s should be supported, got %v", platform.String(), err) } } - unsupported := SSHRemotePlatform{GOOS: "linux", GOARCH: "arm64", UnameOS: "Linux", UnameArch: "aarch64"} + unsupported := SSHRemotePlatform{GOOS: "", GOARCH: "amd64", UnameOS: "FreeBSD", UnameArch: "x86_64"} err := requireSupportedRemotePlatform(unsupported) if err == nil { - t.Fatal("linux/arm64 should not be supported yet") + t.Fatal("freebsd/amd64 should not be supported") } - for _, want := range []string{"linux/arm64", "linux/amd64", "darwin/arm64", "darwin/amd64"} { + for _, want := range []string{"unsupported remote platform", "linux/{amd64,arm64}", "darwin/{amd64,arm64}", "FreeBSD"} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q missing %q", err.Error(), want) } @@ -371,3 +372,66 @@ func TestParseLiteralProxyJump(t *testing.T) { }) } } + +func TestSSHRemoteAgentEnv(t *testing.T) { + // Fixture values — named so it's clear these are arbitrary test inputs, + // not real credentials or host config. + const ( + tokenFromReq = "claude-token-from-req" + tokenFromEnv = "claude-token-from-controlplane" + openAIKey = "openai-key-from-req" + anthropicFromEnv = "anthropic-key-from-controlplane" + nonCredentialHome = "/home/agent" + nonCredentialPath = "/usr/bin" + ) + + // req.Env credential keys are forwarded; non-credential keys (HOME/PATH) are not. + req := &ExecutorCreateRequest{Env: map[string]string{ + "CLAUDE_CODE_OAUTH_TOKEN": tokenFromReq, + "HOME": nonCredentialHome, + "PATH": nonCredentialPath, + "OPENAI_API_KEY": openAIKey, + }} + got := sshRemoteAgentEnv(req) + if got["CLAUDE_CODE_OAUTH_TOKEN"] != tokenFromReq { + t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN = %q, want %q", got["CLAUDE_CODE_OAUTH_TOKEN"], tokenFromReq) + } + if got["OPENAI_API_KEY"] != openAIKey { + t.Fatalf("OPENAI_API_KEY = %q, want %q", got["OPENAI_API_KEY"], openAIKey) + } + if _, ok := got["HOME"]; ok { + t.Error("HOME must NOT be forwarded to the remote agent") + } + if _, ok := got["PATH"]; ok { + t.Error("PATH must NOT be forwarded to the remote agent") + } + + // Credentials present ONLY in the control-plane process env must NOT be + // forwarded (that would leak the kandev host's own credentials to any SSH + // target). Only keys explicitly resolved into req.Env are sent. + t.Setenv("ANTHROPIC_API_KEY", anthropicFromEnv) + t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", tokenFromEnv) + got = sshRemoteAgentEnv(&ExecutorCreateRequest{Env: map[string]string{}}) + if _, ok := got["ANTHROPIC_API_KEY"]; ok { + t.Error("ANTHROPIC_API_KEY from control-plane env must NOT be forwarded when absent from req.Env") + } + if got != nil { + t.Fatalf("expected nil when req.Env has no credential keys, got %v", got) + } + + // req.Env is the sole source; the control-plane env is ignored even when set. + got = sshRemoteAgentEnv(&ExecutorCreateRequest{Env: map[string]string{"CLAUDE_CODE_OAUTH_TOKEN": tokenFromReq}}) + if got["CLAUDE_CODE_OAUTH_TOKEN"] != tokenFromReq { + t.Fatalf("req.Env should be the source, got %q", got["CLAUDE_CODE_OAUTH_TOKEN"]) + } +} + +func TestSSHRemoteAgentEnvEmpty(t *testing.T) { + // nil req and empty req.Env both yield nil (no control-plane fallback). + if got := sshRemoteAgentEnv(nil); got != nil { + t.Fatalf("expected nil for nil req, got %v", got) + } + if got := sshRemoteAgentEnv(&ExecutorCreateRequest{}); got != nil { + t.Fatalf("expected nil for no credentials, got %v", got) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go index 3a2479e702..0425dab67c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go @@ -204,6 +204,7 @@ func normalizeSSHRemoteArch(arch string) string { func requireSupportedRemotePlatform(platform SSHRemotePlatform) error { switch platform.String() { case sshRemoteGOOSLinux + "/" + sshRemoteGOARCHAMD64, + sshRemoteGOOSLinux + "/" + sshRemoteGOARCHARM64, sshRemoteGOOSDarwin + "/" + sshRemoteGOARCHARM64, sshRemoteGOOSDarwin + "/" + sshRemoteGOARCHAMD64: return nil @@ -213,7 +214,7 @@ func requireSupportedRemotePlatform(platform SSHRemotePlatform) error { 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", + "unsupported remote platform %q — SSH executor supports linux/{amd64,arm64} and darwin/{amd64,arm64}", reported, ) } @@ -561,6 +562,7 @@ func createRemoteAgentInstance( RequiresProcessKill: requiresProcessKillFromReq(req), StripEnv: stripEnvFromReq(req), BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), + Env: sshRemoteAgentEnv(req), }) if err != nil { return 0, fmt.Errorf("ssh: marshal create-instance: %w", err) @@ -614,6 +616,61 @@ func createRemoteAgentInstance( return resp.Port, nil } +// sshRemoteAgentCredentialEnvKeys are the agent-authentication environment +// variables forwarded to the remote agent instance. Unlike containerized +// executors (Docker/Sprites) the SSH executor's CreateInstanceRequest never +// carried Env, so env-authenticated agents — notably claude-acp, which reads +// CLAUDE_CODE_OAUTH_TOKEN, not a credentials file — failed with "Authentication +// required" on every SSH remote. We forward ONLY this credential allowlist (not +// the control plane's HOME/PATH/etc., which would break a different remote). +// Credential env var names. Named constants keep each string single-sourced +// (several also appear in the Docker/Sprites credential paths) so goconst stays +// satisfied and the allowlist reads as intent rather than magic strings. +const ( + envKeyClaudeCodeOAuthToken = "CLAUDE_CODE_OAUTH_TOKEN" + envKeyAnthropicAPIKey = "ANTHROPIC_API_KEY" + envKeyOpenAIAPIKey = "OPENAI_API_KEY" + envKeyGeminiAPIKey = "GEMINI_API_KEY" + envKeyGoogleAPIKey = "GOOGLE_API_KEY" + envKeyGitHubToken = "GITHUB_TOKEN" + envKeyGHToken = "GH_TOKEN" +) + +var sshRemoteAgentCredentialEnvKeys = []string{ + envKeyClaudeCodeOAuthToken, + envKeyAnthropicAPIKey, + envKeyOpenAIAPIKey, + envKeyGeminiAPIKey, + envKeyGoogleAPIKey, + envKeyGitHubToken, + envKeyGHToken, +} + +// sshRemoteAgentEnv builds the env map sent to the remote agent instance. Each +// credential key is taken ONLY from the resolved request env — credentials the +// orchestrator explicitly resolved for this executor/session (profile env vars, +// profile remote_auth_secrets, or the GITHUB_TOKEN resolution chain, see the +// orchestrator's applyContainerCredentials). It deliberately does NOT fall back +// to the control plane's own process environment: that would forward whatever the kandev host +// happens to have exported (OPENAI_API_KEY, GITHUB_TOKEN, …) to any SSH host the +// executor connects to, bypassing per-executor credential scoping. Empty values +// are skipped so we never clobber a remote-side value with a blank. +func sshRemoteAgentEnv(req *ExecutorCreateRequest) map[string]string { + if req == nil || req.Env == nil { + return nil + } + env := make(map[string]string) + for _, key := range sshRemoteAgentCredentialEnvKeys { + if val := req.Env[key]; val != "" { + env[key] = val + } + } + if len(env) == 0 { + return nil + } + return env +} + // sshAgentTypeFromReq returns the agent type ID for the create-instance call, // or empty when the request didn't carry an agent config. func sshAgentTypeFromReq(req *ExecutorCreateRequest) string { diff --git a/apps/backend/internal/launcher/bundle.go b/apps/backend/internal/launcher/bundle.go index 64ff8ba08a..3dd1078cbe 100644 --- a/apps/backend/internal/launcher/bundle.go +++ b/apps/backend/internal/launcher/bundle.go @@ -1,7 +1,9 @@ package launcher import ( + "encoding/binary" "fmt" + "io" "os" "path/filepath" ) @@ -15,11 +17,19 @@ type runtimeBundle struct { type agentctlRemoteHelper struct { Name string Label string + // MustBeSigned requires the Mach-O helper to carry an ad-hoc (or real) code + // signature. Apple Silicon (AMFI) SIGKILLs unsigned darwin/arm64 binaries on + // launch, so an unsigned arm64 helper would fail on every Mac it's uploaded + // to — catch that at bundle-validation time, not at remote-launch time. + // darwin/amd64 is intentionally exempt: Intel macOS does not enforce code + // signing at exec, and Go's linker does not ad-hoc-sign amd64 cross-builds. + MustBeSigned bool } var requiredAgentctlRemoteHelpers = []agentctlRemoteHelper{ {Name: "agentctl-linux-amd64", Label: "agentctl linux/amd64 helper"}, - {Name: "agentctl-darwin-arm64", Label: "agentctl darwin/arm64 helper"}, + {Name: "agentctl-linux-arm64", Label: "agentctl linux/arm64 helper"}, + {Name: "agentctl-darwin-arm64", Label: "agentctl darwin/arm64 helper", MustBeSigned: true}, {Name: "agentctl-darwin-amd64", Label: "agentctl darwin/amd64 helper"}, } @@ -45,10 +55,77 @@ func validateRuntimeBundle(dir, source string) (runtimeBundle, error) { if !exists(path) { return runtimeBundle{}, fmt.Errorf("%s not found in bundle at %s", helper.Label, path) } + if helper.MustBeSigned { + signed, ok := machoHasCodeSignature(path) + if !ok { + // Fail closed: a helper we can't parse as a thin arm64 Mach-O + // (read error, truncation, unexpected/fat layout) can't be + // verified as signed, and an unverifiable darwin/arm64 helper + // would still be SIGKILLed by Apple Silicon at launch. + return runtimeBundle{}, fmt.Errorf( + "%s at %s is not a parsable thin darwin/arm64 Mach-O; cannot verify its "+ + "code signature (rebuild it via 'make -C apps/backend build-agentctl-remote')", + helper.Label, path) + } + if !signed { + return runtimeBundle{}, fmt.Errorf( + "%s at %s is not code-signed; Apple Silicon will refuse to run it "+ + "(build it via 'make -C apps/backend build-agentctl-remote', which ad-hoc-signs darwin helpers)", + helper.Label, path) + } + } } return runtimeBundle{Dir: dir, Launcher: launcher, Source: source}, nil } +// machoHasCodeSignature reports whether a thin 64-bit Mach-O carries an +// LC_CODE_SIGNATURE load command. The second return is false when the file +// can't be parsed as a thin Mach-O (e.g. unexpected format) so callers can +// treat "undeterminable" as non-fatal rather than failing on a parse quirk. +func machoHasCodeSignature(path string) (signed bool, ok bool) { + const ( + machoMagic64LE = 0xfeedfacf // MH_MAGIC_64, written little-endian on disk + lcCodeSignature = 0x1d // LC_CODE_SIGNATURE + machoHeaderSize64 = 32 + loadCommandHdrSize = 8 + ) + const maxScanBytes = 64 * 1024 // more than enough for any real Mach-O load-command table + f, err := os.Open(path) + if err != nil { + return false, false + } + defer func() { _ = f.Close() }() + data := make([]byte, maxScanBytes) + n, err := io.ReadFull(f, data) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return false, false + } + data = data[:n] + if len(data) < machoHeaderSize64 { + return false, false + } + if binary.LittleEndian.Uint32(data[0:4]) != machoMagic64LE { + return false, false // not a thin LE 64-bit Mach-O (fat/other) — don't judge + } + ncmds := binary.LittleEndian.Uint32(data[16:20]) + off := machoHeaderSize64 + for i := uint32(0); i < ncmds; i++ { + if off+loadCommandHdrSize > len(data) { + return false, false // truncated load-command table + } + cmd := binary.LittleEndian.Uint32(data[off : off+4]) + cmdSize := binary.LittleEndian.Uint32(data[off+4 : off+8]) + if cmd == lcCodeSignature { + return true, true + } + if cmdSize < loadCommandHdrSize { + return false, false // malformed + } + off += int(cmdSize) + } + return false, true +} + func executableName(name string) string { if os.PathSeparator == '\\' { return name + ".exe" diff --git a/apps/backend/internal/launcher/bundle_test.go b/apps/backend/internal/launcher/bundle_test.go index 6bbea6d1d1..26273593ae 100644 --- a/apps/backend/internal/launcher/bundle_test.go +++ b/apps/backend/internal/launcher/bundle_test.go @@ -1,6 +1,7 @@ package launcher import ( + "encoding/binary" "os" "path/filepath" "strings" @@ -36,6 +37,7 @@ func TestValidateRuntimeBundleRejectsMissingRemoteHelper(t *testing.T) { 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-linux-arm64")) writeFile(t, filepath.Join(dir, "bin", "agentctl-darwin-amd64")) _, err := validateRuntimeBundle(dir, "test") @@ -47,6 +49,106 @@ func TestValidateRuntimeBundleRejectsMissingRemoteHelper(t *testing.T) { } } +func TestValidateRuntimeBundleRejectsUnsignedDarwinArm64(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-linux-arm64")) + writeFile(t, filepath.Join(dir, "bin", "agentctl-darwin-amd64")) + // A real-shaped but unsigned Mach-O must be rejected for darwin/arm64. + writeMachO(t, filepath.Join(dir, "bin", "agentctl-darwin-arm64"), false) + + _, err := validateRuntimeBundle(dir, "test") + if err == nil { + t.Fatal("expected error for unsigned darwin/arm64 helper") + } + if got, want := err.Error(), "not code-signed"; !strings.Contains(got, want) { + t.Fatalf("error = %q, want substring %q", got, want) + } +} + +func TestValidateRuntimeBundleRejectsUnparsableDarwinArm64(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-linux-arm64")) + writeFile(t, filepath.Join(dir, "bin", "agentctl-darwin-amd64")) + // A non-Mach-O stub (e.g. a truncated/corrupt upload) can't be verified as + // signed and must fail closed rather than silently pass. + writeFile(t, filepath.Join(dir, "bin", "agentctl-darwin-arm64")) + + _, err := validateRuntimeBundle(dir, "test") + if err == nil { + t.Fatal("expected error for unparsable darwin/arm64 helper") + } + if got, want := err.Error(), "not a parsable"; !strings.Contains(got, want) { + t.Fatalf("error = %q, want substring %q", got, want) + } +} + +func TestValidateRuntimeBundleAcceptsSignedDarwinArm64(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-linux-arm64")) + writeFile(t, filepath.Join(dir, "bin", "agentctl-darwin-amd64")) + writeMachO(t, filepath.Join(dir, "bin", "agentctl-darwin-arm64"), true) + + if _, err := validateRuntimeBundle(dir, "test"); err != nil { + t.Fatalf("unexpected error for signed darwin/arm64 helper: %v", err) + } +} + +func TestMachoHasCodeSignature(t *testing.T) { + dir := t.TempDir() + signed := filepath.Join(dir, "signed") + unsigned := filepath.Join(dir, "unsigned") + writeMachO(t, signed, true) + writeMachO(t, unsigned, false) + + if got, ok := machoHasCodeSignature(signed); !ok || !got { + t.Fatalf("signed: got=%v ok=%v, want signed=true ok=true", got, ok) + } + if got, ok := machoHasCodeSignature(unsigned); !ok || got { + t.Fatalf("unsigned: got=%v ok=%v, want false true", got, ok) + } + // A non-Mach-O (e.g. an ELF or stub) must be reported as undeterminable. + if _, ok := machoHasCodeSignature(filepath.Join(dir, "missing")); ok { + t.Fatal("missing file: ok=true, want false") + } +} + +// writeMachO writes a minimal thin LE 64-bit Mach-O with a single load command +// that is either LC_CODE_SIGNATURE (signed) or LC_UUID (unsigned). +func writeMachO(t *testing.T, path string, signed bool) { + t.Helper() + const ( + magic64LE = 0xfeedfacf + lcCodeSignature = 0x1d + lcUUID = 0x1b + hdrSize = 32 + lcSize = 16 + ) + buf := make([]byte, hdrSize+lcSize) + binary.LittleEndian.PutUint32(buf[0:4], magic64LE) + binary.LittleEndian.PutUint32(buf[16:20], 1) // ncmds = 1 + cmd := uint32(lcUUID) + if signed { + cmd = lcCodeSignature + } + binary.LittleEndian.PutUint32(buf[hdrSize:hdrSize+4], cmd) + binary.LittleEndian.PutUint32(buf[hdrSize+4:hdrSize+8], lcSize) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buf, 0o755); err != nil { + t.Fatal(err) + } +} + func TestRequiredAgentctlRemoteHelpers(t *testing.T) { got := make([]string, 0, len(requiredAgentctlRemoteHelpers)) for _, helper := range requiredAgentctlRemoteHelpers { @@ -54,6 +156,7 @@ func TestRequiredAgentctlRemoteHelpers(t *testing.T) { } want := []string{ "agentctl-linux-amd64", + "agentctl-linux-arm64", "agentctl-darwin-arm64", "agentctl-darwin-amd64", } @@ -70,7 +173,14 @@ func TestRequiredAgentctlRemoteHelpers(t *testing.T) { func writeRemoteAgentctlHelpers(t *testing.T, dir string) { t.Helper() for _, helper := range requiredAgentctlRemoteHelpers { - writeFile(t, filepath.Join(dir, "bin", helper.Name)) + path := filepath.Join(dir, "bin", helper.Name) + // MustBeSigned helpers now fail closed on an unparsable artifact, so a + // plain stub no longer passes validation — write a signed Mach-O. + if helper.MustBeSigned { + writeMachO(t, path, true) + continue + } + writeFile(t, path) } } diff --git a/apps/backend/internal/orchestrator/executor/executor_credentials_test.go b/apps/backend/internal/orchestrator/executor/executor_credentials_test.go index 2dcd8a1d37..6ce7c21ad0 100644 --- a/apps/backend/internal/orchestrator/executor/executor_credentials_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_credentials_test.go @@ -31,6 +31,30 @@ func TestIsContainerizedExecutor(t *testing.T) { } } +func TestExecutorNeedsResolvedCredentials(t *testing.T) { + tests := []struct { + executorType string + want bool + }{ + {"local_docker", true}, + {"remote_docker", true}, + {"sprites", true}, + {"ssh", true}, // SSH remotes run agentctl off-host; credentials must reach req.Env + {"local", false}, + {"worktree", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.executorType, func(t *testing.T) { + got := executorNeedsResolvedCredentials(tt.executorType) + if got != tt.want { + t.Errorf("executorNeedsResolvedCredentials(%q) = %v, want %v", tt.executorType, got, tt.want) + } + }) + } +} + func TestMethodIDToEnvVar(t *testing.T) { tests := []struct { methodID string diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index bbb0a54403..4daac8adaf 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -45,6 +45,16 @@ func isContainerizedExecutor(executorType string) bool { } } +// executorNeedsResolvedCredentials reports whether an executor runs the agent +// off the control-plane host and therefore needs credentials resolved into +// req.Env (rather than inherited from the kandev process environment). This is +// every containerized executor plus SSH, whose remote agentctl only receives +// the credential keys we forward in req.Env. +func executorNeedsResolvedCredentials(executorType string) bool { + return isContainerizedExecutor(executorType) || + models.ExecutorType(executorType) == models.ExecutorTypeSSH +} + // runAgentProcessAsync starts the agent subprocess in a background goroutine. // On error it marks the session as FAILED. The task is also marked FAILED only // when escalateTaskOnFailure is true; resume callers pass false so a transient @@ -777,12 +787,17 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s } } - // For containerized executors, resolve credentials in this order: + // For remote executors (containerized *and* SSH), resolve credentials into + // req.Env in this order: // 1. Profile remote_auth_secrets (e.g., gh_cli_env method with secret) // 2. Profile remote_credentials with gh_cli_token (extract from local gh CLI) // 3. Global GITHUB_TOKEN secret (fallback) // 4. Auto-extract from local gh CLI (final fallback) - if isContainerizedExecutor(execConfig.ExecutorType) { + // SSH is included so env-authenticated agents (e.g. claude-acp reading + // CLAUDE_CODE_OAUTH_TOKEN) and remote git get their credentials from the + // configured profile/secret store rather than from a blanket forward of the + // control-plane process env — the SSH executor only forwards req.Env keys. + if executorNeedsResolvedCredentials(execConfig.ExecutorType) { e.applyContainerCredentials(ctx, req, metadata) } diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 056e820c92..da6ae8e162 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -1071,20 +1071,9 @@ func (s *Service) deleteTaskWithReasonAndDBDelete( zap.String("new_owner_task_id", taskEnv.TaskID)) } - // 3. Get active sessions for stopping agents (sync, fast) - // Must query before delete since DB records will be gone - var stopTargets []taskStopTarget - if s.executionStopper != nil { - activeSessions, err := s.sessions.ListActiveTaskSessionsByTaskID(ctx, id) - if err != nil { - s.logger.Warn("failed to list active sessions for delete", - zap.String("task_id", id), - zap.Error(err)) - } - stopTargets, err = s.buildStopTargets(ctx, id, activeSessions) - if err != nil { - return false, fmt.Errorf("list runtime cleanup inventory: %w", err) - } + stopTargets, err := s.deleteTaskStopTargets(ctx, id) + if err != nil { + return false, err } // 4. Delete from DB (sync, fast) @@ -1121,6 +1110,24 @@ func (s *Service) deleteTaskWithReasonAndDBDelete( return true, nil } +func (s *Service) deleteTaskStopTargets(ctx context.Context, id string) ([]taskStopTarget, error) { + // Must query before delete since DB records will be gone. + if s.executionStopper == nil { + return nil, nil + } + activeSessions, err := s.sessions.ListActiveTaskSessionsByTaskID(ctx, id) + if err != nil { + s.logger.Warn("failed to list active sessions for delete", + zap.String("task_id", id), + zap.Error(err)) + } + stopTargets, err := s.buildStopTargets(ctx, id, activeSessions) + if err != nil { + return nil, fmt.Errorf("list runtime cleanup inventory: %w", err) + } + return stopTargets, nil +} + // CleanupTaskResources tears down a task's runtime resources (container, // sandbox, worktree, executor_running rows, quick-chat dir, task_environment // row) AFTER the task row has been archived or deleted by another path. diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 7442ee80b2..58d575205e 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -476,6 +476,21 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio zap.Error(err)) } else if exec != nil { info.ExecutorType = string(exec.Type) + // Project the executor record's connection config (e.g. ssh_host, + // ssh_host_fingerprint, ssh_user) into the workspace metadata as a + // fallback. The agent-launch path gets these via the orchestrator's + // executor-config merge, but the workspace-restore / terminal path + // only carries them forward from a live ExecutorRunning record. When + // no running record exists — terminal-state sessions (completed / + // failed / cancelled), post-restart, or after agentctl cleanup — the + // SSH executor would otherwise fail with "host (or host_alias) is + // required in executor config" when opening a terminal or restoring + // the workspace. Existing values (from the running record) win. + // Scoped to SSH: this fallback only makes sense for the SSH executor + // and the projected keys are SSH connection/profile keys. + if exec.Type == models.ExecutorTypeSSH { + mergeExecutorConfigMetadata(info, exec.Config) + } } } @@ -584,6 +599,33 @@ func ensureWorkspaceMetadata(info *lifecycle.WorkspaceInfo) map[string]interface return info.Metadata } +// mergeExecutorConfigMetadata projects an SSH executor record's stable +// connection/profile config keys (e.g. ssh_host, ssh_host_alias, +// ssh_host_fingerprint, ssh_user) into the workspace metadata WITHOUT +// overwriting values already present — a live ExecutorRunning record carries +// authoritative per-session values (remote dirs/ports) and must win. This is +// the fallback that lets terminal-state SSH sessions open a terminal / restore +// the workspace when no running record exists. +// +// It filters through lifecycle.FilterSSHWorkspaceFallbackConfig rather than the +// general persistent-metadata allowlist so that (a) alias-only executors +// (ssh_host_alias, no ssh_host) are preserved, and (b) session-scoped runtime +// keys (remote agentctl port/PID/session dir) can never be projected — those +// would make a restore reattach to a stale/dead remote instance. +func mergeExecutorConfigMetadata(info *lifecycle.WorkspaceInfo, config map[string]string) { + filtered := lifecycle.FilterSSHWorkspaceFallbackConfig(config) + if filtered == nil { + return + } + dst := ensureWorkspaceMetadata(info) + for k, v := range filtered { + if _, exists := dst[k]; exists { + continue + } + dst[k] = v + } +} + // GetWorkspaceInfoForEnvironment returns workspace information for a task environment. func (s *Service) GetWorkspaceInfoForEnvironment(ctx context.Context, taskEnvironmentID string) (*lifecycle.WorkspaceInfo, error) { if taskEnvironmentID == "" { diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index a98edaceee..21c0d790f1 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -780,3 +780,148 @@ func TestAbandonOpenTurns_IterationCap(t *testing.T) { } } } + +func TestMergeExecutorConfigMetadata(t *testing.T) { + // Fixture values — arbitrary test inputs, not real host config. + const ( + execHost = "ssh.example.com" + execUser = "agent" + execFP = "SHA256:test-fingerprint" + liveHost = "live-host-from-running-record" + staleHost = "stale-host-from-executor" + droppedKey = "not_a_persistent_key" + ) + + // SSH connection keys from the executor record must be projected when absent. + info := &lifecycle.WorkspaceInfo{} + mergeExecutorConfigMetadata(info, map[string]string{ + lifecycle.MetadataKeySSHHost: execHost, + lifecycle.MetadataKeySSHUser: execUser, + lifecycle.MetadataKeySSHHostFingerprint: execFP, + droppedKey: "dropme", + }) + if got := info.Metadata[lifecycle.MetadataKeySSHHost]; got != execHost { + t.Fatalf("ssh_host = %v, want %q", got, execHost) + } + if got := info.Metadata[lifecycle.MetadataKeySSHHostFingerprint]; got != execFP { + t.Fatalf("ssh_host_fingerprint = %v, want %q", got, execFP) + } + if _, ok := info.Metadata[droppedKey]; ok { + t.Fatal("non-persistent key should be filtered out") + } + + // Existing (live running-record) values must win over executor config. + info2 := &lifecycle.WorkspaceInfo{Metadata: map[string]interface{}{ + lifecycle.MetadataKeySSHHost: liveHost, + }} + mergeExecutorConfigMetadata(info2, map[string]string{ + lifecycle.MetadataKeySSHHost: staleHost, + lifecycle.MetadataKeySSHUser: execUser, + }) + if got := info2.Metadata[lifecycle.MetadataKeySSHHost]; got != liveHost { + t.Fatalf("ssh_host = %v, want %q (existing must win)", got, liveHost) + } + if got := info2.Metadata[lifecycle.MetadataKeySSHUser]; got != execUser { + t.Fatalf("ssh_user = %v, want %q (absent key should be filled)", got, execUser) + } + + // Empty config is a no-op. + info3 := &lifecycle.WorkspaceInfo{} + mergeExecutorConfigMetadata(info3, nil) + if len(info3.Metadata) != 0 { + t.Fatalf("empty config should not create metadata, got %v", info3.Metadata) + } + + // Alias-only executors (host read from ~/.ssh/config) must have their alias + // projected — otherwise targetFromMetadata still throws "host (or host_alias) + // is required". Regression guard for the FilterPersistentMetadata gap. + const execAlias = "my-remote-box" + info4 := &lifecycle.WorkspaceInfo{} + mergeExecutorConfigMetadata(info4, map[string]string{ + lifecycle.MetadataKeySSHHostAlias: execAlias, + lifecycle.MetadataKeySSHShell: "zsh", + }) + if got := info4.Metadata[lifecycle.MetadataKeySSHHostAlias]; got != execAlias { + t.Fatalf("ssh_host_alias = %v, want %q (alias-only executor must survive)", got, execAlias) + } + if got := info4.Metadata[lifecycle.MetadataKeySSHShell]; got != "zsh" { + t.Fatalf("ssh_shell = %v, want zsh (per-profile key should project)", got) + } + + // Session-scoped runtime keys must NEVER be projected from executor config — + // projecting a stale one would make restore reattach to a dead remote + // agentctl instance instead of creating a fresh one. + info5 := &lifecycle.WorkspaceInfo{} + mergeExecutorConfigMetadata(info5, map[string]string{ + lifecycle.MetadataKeySSHHost: execHost, + lifecycle.MetadataKeySSHRemoteAgentctlPort: "40123", + lifecycle.MetadataKeySSHRemoteAgentctlPID: "9999", + lifecycle.MetadataKeySSHRemoteSessionDir: "/home/agent/.kandev/sessions/old", + }) + for _, k := range []string{ + lifecycle.MetadataKeySSHRemoteAgentctlPort, + lifecycle.MetadataKeySSHRemoteAgentctlPID, + lifecycle.MetadataKeySSHRemoteSessionDir, + } { + if _, ok := info5.Metadata[k]; ok { + t.Errorf("session-scoped key %q must NOT be projected from executor config", k) + } + } + if got := info5.Metadata[lifecycle.MetadataKeySSHHost]; got != execHost { + t.Fatalf("ssh_host = %v, want %q (connection key should still project)", got, execHost) + } +} + +// TestGetWorkspaceInfoForSession_SSHConfigProjectedWhenNoRunningRecord exercises +// the end-to-end fallback: a completed SSH session with no ExecutorRunning +// record must still surface the executor's ssh_host so a terminal / restore +// does not fail with "host (or host_alias) is required in executor config". +func TestGetWorkspaceInfoForSession_SSHConfigProjectedWhenNoRunningRecord(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + const execHost = "ssh.example.com" + if err := repo.CreateExecutor(ctx, &models.Executor{ + ID: "ssh-exec-1", + Name: "my-ssh-host", + Type: models.ExecutorTypeSSH, + Config: map[string]string{ + lifecycle.MetadataKeySSHHost: execHost, + lifecycle.MetadataKeySSHUser: "agent", + lifecycle.MetadataKeySSHHostFingerprint: "SHA256:test-fingerprint", + }, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to create ssh executor: %v", err) + } + + // Completed session pointing at the SSH executor, with NO ExecutorRunning record. + session := &models.TaskSession{ + ID: "session-ssh-1", + TaskID: "task-123", + TaskEnvironmentID: "env-123", + AgentProfileID: "profile-1", + ExecutorID: "ssh-exec-1", + State: models.TaskSessionStateCompleted, + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to create session: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-ssh-1") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err) + } + if info.ExecutorType != string(models.ExecutorTypeSSH) { + t.Fatalf("ExecutorType = %q, want ssh", info.ExecutorType) + } + if got, _ := info.Metadata[lifecycle.MetadataKeySSHHost].(string); got != execHost { + t.Fatalf("ssh_host = %v, want %q (executor config must be projected as fallback)", info.Metadata[lifecycle.MetadataKeySSHHost], execHost) + } +} diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index 2fb77cf6a9..6512414c4e 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -19,6 +19,7 @@ Release builds prepare `src-tauri/resources/kandev/` with: bin/kandev[.exe] bin/agentctl[.exe] bin/agentctl-linux-amd64 +bin/agentctl-linux-arm64 bin/agentctl-darwin-arm64 bin/agentctl-darwin-amd64 ``` diff --git a/apps/desktop/e2e/desktop-launch-smoke.mjs b/apps/desktop/e2e/desktop-launch-smoke.mjs index 3d394dcec8..7c5a3ef20e 100644 --- a/apps/desktop/e2e/desktop-launch-smoke.mjs +++ b/apps/desktop/e2e/desktop-launch-smoke.mjs @@ -76,6 +76,7 @@ async function writeFakeRuntime(runtimeDir, stateDir) { const agentctl = join(runtimeDir, "bin", process.platform === "win32" ? "agentctl.cmd" : "agentctl"); const remoteHelpers = [ ["agentctl-linux-amd64", "linux/amd64"], + ["agentctl-linux-arm64", "linux/arm64"], ["agentctl-darwin-arm64", "darwin/arm64"], ["agentctl-darwin-amd64", "darwin/amd64"], ]; diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 0b9f6e0ffe..035df7cbad 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -667,12 +667,11 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -2036,9 +2035,9 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -2563,9 +2562,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.7.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -2692,9 +2691,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.32.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -3838,30 +3837,29 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e59763d3a9..26c0ead100 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -6,7 +6,7 @@ authors = ["Kandev"] license = "AGPL-3.0-only" repository = "https://github.com/kdlbs/kandev" edition = "2021" -rust-version = "1.85" +rust-version = "1.88" [[bin]] name = "kandev-desktop" @@ -30,7 +30,7 @@ darling = "=0.21.3" getrandom = "0.3" idna_adapter = "=1.0.0" libc = "0.2" -plist = "=1.7.1" +plist = "=1.10.0" serde_json = "1" serde_with = "=3.15.0" tauri = { version = "=2.8.4", features = [], optional = true } diff --git a/apps/desktop/src-tauri/src/backend.rs b/apps/desktop/src-tauri/src/backend.rs index dfa8985ff4..b8b7ef2be7 100644 --- a/apps/desktop/src-tauri/src/backend.rs +++ b/apps/desktop/src-tauri/src/backend.rs @@ -25,8 +25,9 @@ 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] = [ +const REMOTE_AGENTCTL_HELPERS: [(&str, &str); 4] = [ ("agentctl-linux-amd64", "agentctl linux/amd64 helper"), + ("agentctl-linux-arm64", "agentctl linux/arm64 helper"), ("agentctl-darwin-arm64", "agentctl darwin/arm64 helper"), ("agentctl-darwin-amd64", "agentctl darwin/amd64 helper"), ]; @@ -728,6 +729,7 @@ mod tests { 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-linux-arm64"), b"stub").expect("write linux arm64 helper"); fs::write(bin.join("agentctl-darwin-amd64"), b"stub").expect("write darwin amd64 helper"); let err = diff --git a/docs/specs/ssh-executor/spec.md b/docs/specs/ssh-executor/spec.md index a75014b8d7..862c3b19d9 100644 --- a/docs/specs/ssh-executor/spec.md +++ b/docs/specs/ssh-executor/spec.md @@ -81,8 +81,9 @@ Multiple sessions in the *same* task share the same worktree on disk (same files ### agentctl binary upload with content-hash cache - On `CreateInstance`, detect the remote platform via `uname -s` and `uname -m`, normalize it to a Go `GOOS/GOARCH` tuple, and resolve the matching agentctl helper via `AgentctlResolver`. -- Supported remote platforms are `linux/amd64`, `darwin/arm64`, and `darwin/amd64`. Unsupported platforms fail with a clear error: `unsupported remote platform "" — SSH executor supports linux/amd64, darwin/arm64, and darwin/amd64`. -- Runtime bundles include `agentctl-linux-amd64`, `agentctl-darwin-arm64`, and `agentctl-darwin-amd64`; development builds produce them with `make -C apps/backend build-agentctl-remote`. +- Supported remote platforms are `linux/amd64`, `linux/arm64`, `darwin/arm64`, and `darwin/amd64`. Unsupported platforms fail with a clear error: `unsupported remote platform "" — SSH executor supports linux/{amd64,arm64} and darwin/{amd64,arm64}`. + - **Validation status:** `linux/amd64` and `darwin/arm64` have been validated end-to-end on real hosts (`darwin/arm64` live over Tailscale against Apple Silicon Macs). `linux/arm64` and `darwin/amd64` are wired through the full build/upload/launch path and share the same cross-compiled helper, but have **not** yet been exercised on real ARM Linux / Intel Mac remotes — treat them as supported-but-unverified until someone confirms a task completes on that hardware. +- Runtime bundles include `agentctl-linux-amd64`, `agentctl-linux-arm64`, `agentctl-darwin-arm64`, and `agentctl-darwin-amd64`; development builds produce them with `make -C apps/backend build-agentctl-remote`. The darwin helpers are ad-hoc-signed (Go signs darwin/arm64 at link time; `make` re-signs both via `codesign`/`rcodesign`); bundle validation rejects an unsigned `agentctl-darwin-arm64` because Apple Silicon refuses to run it. - 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. @@ -141,7 +142,6 @@ Multiple sessions in the *same* task share the same worktree on disk (same files ## Out of scope (v1) - **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. @@ -155,7 +155,7 @@ Multiple sessions in the *same* task share the same worktree on disk (same files ## Future scope -- **Linux arm64 agentctl + executor support** (cross-cutting with Docker). +- **Hardware validation of `linux/arm64` and `darwin/amd64` remotes.** Both are wired through the full build/upload/launch path and share the tested cross-compiled helper, but neither has been exercised on real hardware yet (see the Validation status note in the support matrix). They stay marked supported-but-unverified until a task is confirmed end-to-end on an ARM Linux host and an Intel Mac. - **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. diff --git a/scripts/release-desktop.test.sh b/scripts/release-desktop.test.sh index 0e0e0bd65f..d3e8bc02d6 100644 --- a/scripts/release-desktop.test.sh +++ b/scripts/release-desktop.test.sh @@ -21,6 +21,7 @@ SIGNING_SECRET_ENV=( ) REMOTE_AGENTCTL_HELPERS=( agentctl-linux-amd64 + agentctl-linux-arm64 agentctl-darwin-arm64 agentctl-darwin-amd64 ) diff --git a/scripts/release/package-bundle.sh b/scripts/release/package-bundle.sh index 05eda746de..e52fe3c2ac 100755 --- a/scripts/release/package-bundle.sh +++ b/scripts/release/package-bundle.sh @@ -10,6 +10,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" BUNDLE="$ROOT_DIR/dist/kandev" REMOTE_AGENTCTL_HELPERS=( agentctl-linux-amd64 + agentctl-linux-arm64 agentctl-darwin-arm64 agentctl-darwin-amd64 ) diff --git a/scripts/release/prepare-desktop-runtime.sh b/scripts/release/prepare-desktop-runtime.sh index 3fa06d133a..4bd9770aaa 100755 --- a/scripts/release/prepare-desktop-runtime.sh +++ b/scripts/release/prepare-desktop-runtime.sh @@ -12,6 +12,7 @@ runtime bundle. The input bundle must contain: bin/kandev[.exe] bin/agentctl[.exe] bin/agentctl-linux-amd64 + bin/agentctl-linux-arm64 bin/agentctl-darwin-arm64 bin/agentctl-darwin-amd64 @@ -31,6 +32,7 @@ VERIFY_SCRIPT="${ROOT_DIR}/scripts/release/verify-desktop-runtime.sh" PLATFORM="" REMOTE_AGENTCTL_HELPERS=( agentctl-linux-amd64 + agentctl-linux-arm64 agentctl-darwin-arm64 agentctl-darwin-amd64 ) diff --git a/scripts/release/verify-desktop-runtime.sh b/scripts/release/verify-desktop-runtime.sh index cd2ee737f1..60132e8cab 100755 --- a/scripts/release/verify-desktop-runtime.sh +++ b/scripts/release/verify-desktop-runtime.sh @@ -13,6 +13,7 @@ Validate a desktop runtime directory with this layout: kandev[.exe] agentctl[.exe] agentctl-linux-amd64 + agentctl-linux-arm64 agentctl-darwin-arm64 agentctl-darwin-amd64 @@ -26,6 +27,7 @@ RUNTIME_DIR_SET=false PLATFORM="" REMOTE_AGENTCTL_HELPERS=( "agentctl-linux-amd64:agentctl linux/amd64 helper" + "agentctl-linux-arm64:agentctl linux/arm64 helper" "agentctl-darwin-arm64:agentctl darwin/arm64 helper" "agentctl-darwin-amd64:agentctl darwin/amd64 helper" ) @@ -68,7 +70,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|agentctl-darwin-arm64|agentctl-darwin-amd64) return 0 ;; + *.exe|agentctl-linux-amd64|agentctl-linux-arm64|agentctl-darwin-arm64|agentctl-darwin-amd64) return 0 ;; esac fi return 1