fix(ssh-executor): forward agent creds, fix terminal host config, restore linux/arm64 (#1559)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Carl Sue
2026-07-09 09:20:07 +01:00
committed by GitHub
parent 4f80aafd68
commit 9722a39639
24 changed files with 684 additions and 51 deletions
+4
View File
@@ -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
+3
View File
@@ -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 \
+1
View File
@@ -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/"
+33 -2
View File
@@ -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:
@@ -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{} {
@@ -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)
}
}
@@ -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 {
+78 -1
View File
@@ -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"
+111 -1
View File
@@ -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)
}
}
@@ -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
@@ -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)
}
@@ -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)
stopTargets, err := s.deleteTaskStopTargets(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)
}
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.
@@ -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 == "" {
@@ -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)
}
}
+1
View File
@@ -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
```
@@ -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"],
];
+16 -18
View File
@@ -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",
+2 -2
View File
@@ -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 }
+3 -1
View File
@@ -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 =
+4 -4
View File
@@ -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 "<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 "<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.
+1
View File
@@ -21,6 +21,7 @@ SIGNING_SECRET_ENV=(
)
REMOTE_AGENTCTL_HELPERS=(
agentctl-linux-amd64
agentctl-linux-arm64
agentctl-darwin-arm64
agentctl-darwin-amd64
)
+1
View File
@@ -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
)
@@ -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
)
+3 -1
View File
@@ -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