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:
@@ -519,6 +519,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd apps/backend
|
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=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=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
|
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/kandev${EXT} dist/kandev/bin/
|
||||||
cp apps/backend/bin/agentctl${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-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-arm64 dist/kandev/bin/
|
||||||
cp apps/backend/bin/agentctl-darwin-amd64 dist/kandev/bin/
|
cp apps/backend/bin/agentctl-darwin-amd64 dist/kandev/bin/
|
||||||
cd dist
|
cd dist
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ RUN mkdir -p /app/apps/backend/bin /data/worktrees
|
|||||||
# bundle/bin/kandev - native Go backend (per-arch)
|
# bundle/bin/kandev - native Go backend (per-arch)
|
||||||
# bundle/bin/agentctl - native agentctl (per-arch)
|
# bundle/bin/agentctl - native agentctl (per-arch)
|
||||||
# bundle/bin/agentctl-linux-amd64 - linux/amd64 agentctl helper
|
# 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-arm64 - darwin/arm64 agentctl helper
|
||||||
# bundle/bin/agentctl-darwin-amd64 - darwin/amd64 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.
|
# manual configuration.
|
||||||
COPY bundle/bin/kandev /app/apps/backend/bin/kandev
|
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-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-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-darwin-amd64 /app/apps/backend/bin/agentctl-darwin-amd64
|
||||||
COPY bundle/bin/agentctl /usr/local/bin/agentctl
|
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 \
|
RUN chmod +x \
|
||||||
/app/apps/backend/bin/kandev \
|
/app/apps/backend/bin/kandev \
|
||||||
/app/apps/backend/bin/agentctl-linux-amd64 \
|
/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-arm64 \
|
||||||
/app/apps/backend/bin/agentctl-darwin-amd64 \
|
/app/apps/backend/bin/agentctl-darwin-amd64 \
|
||||||
/usr/local/bin/agentctl \
|
/usr/local/bin/agentctl \
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ service-bundle: install build
|
|||||||
@mkdir -p "$(SERVICE_BUNDLE_DIR)/bin"
|
@mkdir -p "$(SERVICE_BUNDLE_DIR)/bin"
|
||||||
@cp "$(BACKEND_DIR)/bin/kandev" "$(BACKEND_DIR)/bin/agentctl" \
|
@cp "$(BACKEND_DIR)/bin/kandev" "$(BACKEND_DIR)/bin/agentctl" \
|
||||||
"$(BACKEND_DIR)/bin/agentctl-linux-amd64" \
|
"$(BACKEND_DIR)/bin/agentctl-linux-amd64" \
|
||||||
|
"$(BACKEND_DIR)/bin/agentctl-linux-arm64" \
|
||||||
"$(BACKEND_DIR)/bin/agentctl-darwin-arm64" \
|
"$(BACKEND_DIR)/bin/agentctl-darwin-arm64" \
|
||||||
"$(BACKEND_DIR)/bin/agentctl-darwin-amd64" \
|
"$(BACKEND_DIR)/bin/agentctl-darwin-amd64" \
|
||||||
"$(SERVICE_BUNDLE_DIR)/bin/"
|
"$(SERVICE_BUNDLE_DIR)/bin/"
|
||||||
|
|||||||
+33
-2
@@ -31,18 +31,41 @@ ifeq ($(OS),Windows_NT)
|
|||||||
# cmd has no Unix env-var-prefix syntax; `set X=Y&` chains `set` and the next
|
# cmd has no Unix env-var-prefix syntax; `set X=Y&` chains `set` and the next
|
||||||
# command in the same shell.
|
# command in the same shell.
|
||||||
GO_LINUX_AMD64 = set CGO_ENABLED=0& set GOOS=linux& set GOARCH=amd64&
|
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_ARM64 = set CGO_ENABLED=0& set GOOS=darwin& set GOARCH=arm64&
|
||||||
GO_DARWIN_AMD64 = set CGO_ENABLED=0& set GOOS=darwin& set GOARCH=amd64&
|
GO_DARWIN_AMD64 = set CGO_ENABLED=0& set GOOS=darwin& set GOARCH=amd64&
|
||||||
EXE = .exe
|
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
|
else
|
||||||
RM = rm -f
|
RM = rm -f
|
||||||
RMDIR = rm -rf
|
RMDIR = rm -rf
|
||||||
MKDIR = mkdir -p
|
MKDIR = mkdir -p
|
||||||
CGO_PREFIX = CGO_ENABLED=1
|
CGO_PREFIX = CGO_ENABLED=1
|
||||||
GO_LINUX_AMD64 = CGO_ENABLED=0 GOOS=linux GOARCH=amd64
|
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_ARM64 = CGO_ENABLED=0 GOOS=darwin GOARCH=arm64
|
||||||
GO_DARWIN_AMD64 = CGO_ENABLED=0 GOOS=darwin GOARCH=amd64
|
GO_DARWIN_AMD64 = CGO_ENABLED=0 GOOS=darwin GOARCH=amd64
|
||||||
EXE =
|
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
|
endif
|
||||||
|
|
||||||
# Build info
|
# 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)
|
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
|
## Default target
|
||||||
all: build
|
all: build
|
||||||
@@ -108,7 +131,7 @@ build-agentctl:
|
|||||||
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl$(EXE) ./cmd/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 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 for linux/amd64 (used by Docker/Sprites and Linux SSH hosts)
|
||||||
build-agentctl-linux:
|
build-agentctl-linux:
|
||||||
@@ -116,17 +139,25 @@ build-agentctl-linux:
|
|||||||
-@$(MKDIR) $(BUILD_DIR)
|
-@$(MKDIR) $(BUILD_DIR)
|
||||||
$(GO_LINUX_AMD64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-linux-amd64 ./cmd/agentctl
|
$(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 for darwin/arm64 (used by Apple Silicon SSH hosts)
|
||||||
build-agentctl-darwin-arm64:
|
build-agentctl-darwin-arm64:
|
||||||
@echo "Building agentctl for darwin/arm64..."
|
@echo "Building agentctl for darwin/arm64..."
|
||||||
-@$(MKDIR) $(BUILD_DIR)
|
-@$(MKDIR) $(BUILD_DIR)
|
||||||
$(GO_DARWIN_ARM64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-darwin-arm64 ./cmd/agentctl
|
$(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 for darwin/amd64 (used by Intel Mac SSH hosts)
|
||||||
build-agentctl-darwin-amd64:
|
build-agentctl-darwin-amd64:
|
||||||
@echo "Building agentctl for darwin/amd64..."
|
@echo "Building agentctl for darwin/amd64..."
|
||||||
-@$(MKDIR) $(BUILD_DIR)
|
-@$(MKDIR) $(BUILD_DIR)
|
||||||
$(GO_DARWIN_AMD64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-darwin-amd64 ./cmd/agentctl
|
$(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 for linux/amd64 (used by Docker E2E tests)
|
||||||
build-mock-agent-linux:
|
build-mock-agent-linux:
|
||||||
|
|||||||
@@ -206,6 +206,51 @@ func IsSessionScopedMetadataKey(key string) bool {
|
|||||||
return sessionScopedMetadataKeys[key]
|
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
|
// FilterPersistentMetadata returns a copy of src containing only keys that
|
||||||
// should be carried forward across session resumes. Returns nil if no keys match.
|
// should be carried forward across session resumes. Returns nil if no keys match.
|
||||||
func FilterPersistentMetadata(src map[string]interface{}) map[string]interface{} {
|
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},
|
{"linux amd64", "Linux", "x86_64", "linux", "amd64", true},
|
||||||
{"darwin arm64", "Darwin", "arm64", "darwin", "arm64", true},
|
{"darwin arm64", "Darwin", "arm64", "darwin", "arm64", true},
|
||||||
{"darwin amd64", "Darwin", "x86_64", "darwin", "amd64", 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},
|
{"freebsd amd64 unsupported", "FreeBSD", "x86_64", "", "amd64", false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -162,6 +162,7 @@ func TestNormalizeSSHRemotePlatform(t *testing.T) {
|
|||||||
func TestRequireSupportedRemotePlatform(t *testing.T) {
|
func TestRequireSupportedRemotePlatform(t *testing.T) {
|
||||||
for _, platform := range []SSHRemotePlatform{
|
for _, platform := range []SSHRemotePlatform{
|
||||||
{GOOS: "linux", GOARCH: "amd64", UnameOS: "Linux", UnameArch: "x86_64"},
|
{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: "arm64", UnameOS: "Darwin", UnameArch: "arm64"},
|
||||||
{GOOS: "darwin", GOARCH: "amd64", UnameOS: "Darwin", UnameArch: "x86_64"},
|
{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)
|
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)
|
err := requireSupportedRemotePlatform(unsupported)
|
||||||
if err == nil {
|
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) {
|
if !strings.Contains(err.Error(), want) {
|
||||||
t.Errorf("error %q missing %q", 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 {
|
func requireSupportedRemotePlatform(platform SSHRemotePlatform) error {
|
||||||
switch platform.String() {
|
switch platform.String() {
|
||||||
case sshRemoteGOOSLinux + "/" + sshRemoteGOARCHAMD64,
|
case sshRemoteGOOSLinux + "/" + sshRemoteGOARCHAMD64,
|
||||||
|
sshRemoteGOOSLinux + "/" + sshRemoteGOARCHARM64,
|
||||||
sshRemoteGOOSDarwin + "/" + sshRemoteGOARCHARM64,
|
sshRemoteGOOSDarwin + "/" + sshRemoteGOARCHARM64,
|
||||||
sshRemoteGOOSDarwin + "/" + sshRemoteGOARCHAMD64:
|
sshRemoteGOOSDarwin + "/" + sshRemoteGOARCHAMD64:
|
||||||
return nil
|
return nil
|
||||||
@@ -213,7 +214,7 @@ func requireSupportedRemotePlatform(platform SSHRemotePlatform) error {
|
|||||||
reported = fmt.Sprintf("%s/%s", platform.UnameOS, platform.UnameArch)
|
reported = fmt.Sprintf("%s/%s", platform.UnameOS, platform.UnameArch)
|
||||||
}
|
}
|
||||||
return fmt.Errorf(
|
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,
|
reported,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -561,6 +562,7 @@ func createRemoteAgentInstance(
|
|||||||
RequiresProcessKill: requiresProcessKillFromReq(req),
|
RequiresProcessKill: requiresProcessKillFromReq(req),
|
||||||
StripEnv: stripEnvFromReq(req),
|
StripEnv: stripEnvFromReq(req),
|
||||||
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
|
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
|
||||||
|
Env: sshRemoteAgentEnv(req),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("ssh: marshal create-instance: %w", err)
|
return 0, fmt.Errorf("ssh: marshal create-instance: %w", err)
|
||||||
@@ -614,6 +616,61 @@ func createRemoteAgentInstance(
|
|||||||
return resp.Port, nil
|
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,
|
// sshAgentTypeFromReq returns the agent type ID for the create-instance call,
|
||||||
// or empty when the request didn't carry an agent config.
|
// or empty when the request didn't carry an agent config.
|
||||||
func sshAgentTypeFromReq(req *ExecutorCreateRequest) string {
|
func sshAgentTypeFromReq(req *ExecutorCreateRequest) string {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package launcher
|
package launcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
@@ -15,11 +17,19 @@ type runtimeBundle struct {
|
|||||||
type agentctlRemoteHelper struct {
|
type agentctlRemoteHelper struct {
|
||||||
Name string
|
Name string
|
||||||
Label 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{
|
var requiredAgentctlRemoteHelpers = []agentctlRemoteHelper{
|
||||||
{Name: "agentctl-linux-amd64", Label: "agentctl linux/amd64 helper"},
|
{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"},
|
{Name: "agentctl-darwin-amd64", Label: "agentctl darwin/amd64 helper"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +55,77 @@ func validateRuntimeBundle(dir, source string) (runtimeBundle, error) {
|
|||||||
if !exists(path) {
|
if !exists(path) {
|
||||||
return runtimeBundle{}, fmt.Errorf("%s not found in bundle at %s", helper.Label, 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
|
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 {
|
func executableName(name string) string {
|
||||||
if os.PathSeparator == '\\' {
|
if os.PathSeparator == '\\' {
|
||||||
return name + ".exe"
|
return name + ".exe"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package launcher
|
package launcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -36,6 +37,7 @@ func TestValidateRuntimeBundleRejectsMissingRemoteHelper(t *testing.T) {
|
|||||||
writeFile(t, filepath.Join(dir, "bin", "kandev"))
|
writeFile(t, filepath.Join(dir, "bin", "kandev"))
|
||||||
writeFile(t, filepath.Join(dir, "bin", "agentctl"))
|
writeFile(t, filepath.Join(dir, "bin", "agentctl"))
|
||||||
writeFile(t, filepath.Join(dir, "bin", "agentctl-linux-amd64"))
|
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"))
|
writeFile(t, filepath.Join(dir, "bin", "agentctl-darwin-amd64"))
|
||||||
|
|
||||||
_, err := validateRuntimeBundle(dir, "test")
|
_, 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) {
|
func TestRequiredAgentctlRemoteHelpers(t *testing.T) {
|
||||||
got := make([]string, 0, len(requiredAgentctlRemoteHelpers))
|
got := make([]string, 0, len(requiredAgentctlRemoteHelpers))
|
||||||
for _, helper := range requiredAgentctlRemoteHelpers {
|
for _, helper := range requiredAgentctlRemoteHelpers {
|
||||||
@@ -54,6 +156,7 @@ func TestRequiredAgentctlRemoteHelpers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
want := []string{
|
want := []string{
|
||||||
"agentctl-linux-amd64",
|
"agentctl-linux-amd64",
|
||||||
|
"agentctl-linux-arm64",
|
||||||
"agentctl-darwin-arm64",
|
"agentctl-darwin-arm64",
|
||||||
"agentctl-darwin-amd64",
|
"agentctl-darwin-amd64",
|
||||||
}
|
}
|
||||||
@@ -70,7 +173,14 @@ func TestRequiredAgentctlRemoteHelpers(t *testing.T) {
|
|||||||
func writeRemoteAgentctlHelpers(t *testing.T, dir string) {
|
func writeRemoteAgentctlHelpers(t *testing.T, dir string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, helper := range requiredAgentctlRemoteHelpers {
|
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) {
|
func TestMethodIDToEnvVar(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
methodID string
|
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.
|
// runAgentProcessAsync starts the agent subprocess in a background goroutine.
|
||||||
// On error it marks the session as FAILED. The task is also marked FAILED only
|
// 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
|
// 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)
|
// 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)
|
// 2. Profile remote_credentials with gh_cli_token (extract from local gh CLI)
|
||||||
// 3. Global GITHUB_TOKEN secret (fallback)
|
// 3. Global GITHUB_TOKEN secret (fallback)
|
||||||
// 4. Auto-extract from local gh CLI (final 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)
|
e.applyContainerCredentials(ctx, req, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1071,20 +1071,9 @@ func (s *Service) deleteTaskWithReasonAndDBDelete(
|
|||||||
zap.String("new_owner_task_id", taskEnv.TaskID))
|
zap.String("new_owner_task_id", taskEnv.TaskID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Get active sessions for stopping agents (sync, fast)
|
stopTargets, err := s.deleteTaskStopTargets(ctx, id)
|
||||||
// Must query before delete since DB records will be gone
|
if err != nil {
|
||||||
var stopTargets []taskStopTarget
|
return false, err
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Delete from DB (sync, fast)
|
// 4. Delete from DB (sync, fast)
|
||||||
@@ -1121,6 +1110,24 @@ func (s *Service) deleteTaskWithReasonAndDBDelete(
|
|||||||
return true, nil
|
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,
|
// CleanupTaskResources tears down a task's runtime resources (container,
|
||||||
// sandbox, worktree, executor_running rows, quick-chat dir, task_environment
|
// sandbox, worktree, executor_running rows, quick-chat dir, task_environment
|
||||||
// row) AFTER the task row has been archived or deleted by another path.
|
// 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))
|
zap.Error(err))
|
||||||
} else if exec != nil {
|
} else if exec != nil {
|
||||||
info.ExecutorType = string(exec.Type)
|
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
|
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.
|
// GetWorkspaceInfoForEnvironment returns workspace information for a task environment.
|
||||||
func (s *Service) GetWorkspaceInfoForEnvironment(ctx context.Context, taskEnvironmentID string) (*lifecycle.WorkspaceInfo, error) {
|
func (s *Service) GetWorkspaceInfoForEnvironment(ctx context.Context, taskEnvironmentID string) (*lifecycle.WorkspaceInfo, error) {
|
||||||
if taskEnvironmentID == "" {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Release builds prepare `src-tauri/resources/kandev/` with:
|
|||||||
bin/kandev[.exe]
|
bin/kandev[.exe]
|
||||||
bin/agentctl[.exe]
|
bin/agentctl[.exe]
|
||||||
bin/agentctl-linux-amd64
|
bin/agentctl-linux-amd64
|
||||||
|
bin/agentctl-linux-arm64
|
||||||
bin/agentctl-darwin-arm64
|
bin/agentctl-darwin-arm64
|
||||||
bin/agentctl-darwin-amd64
|
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 agentctl = join(runtimeDir, "bin", process.platform === "win32" ? "agentctl.cmd" : "agentctl");
|
||||||
const remoteHelpers = [
|
const remoteHelpers = [
|
||||||
["agentctl-linux-amd64", "linux/amd64"],
|
["agentctl-linux-amd64", "linux/amd64"],
|
||||||
|
["agentctl-linux-arm64", "linux/arm64"],
|
||||||
["agentctl-darwin-arm64", "darwin/arm64"],
|
["agentctl-darwin-arm64", "darwin/arm64"],
|
||||||
["agentctl-darwin-amd64", "darwin/amd64"],
|
["agentctl-darwin-amd64", "darwin/amd64"],
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+16
-18
@@ -667,12 +667,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.4.0"
|
version = "0.5.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"serde_core",
|
||||||
"serde",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2036,9 +2035,9 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
@@ -2563,9 +2562,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plist"
|
name = "plist"
|
||||||
version = "1.7.1"
|
version = "1.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
|
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
@@ -2692,9 +2691,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.32.0"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -3838,30 +3837,29 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.41"
|
version = "0.3.53"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
|
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
"time-macros",
|
"time-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-core"
|
name = "time-core"
|
||||||
version = "0.1.4"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-macros"
|
name = "time-macros"
|
||||||
version = "0.2.22"
|
version = "0.2.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
|
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ authors = ["Kandev"]
|
|||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
repository = "https://github.com/kdlbs/kandev"
|
repository = "https://github.com/kdlbs/kandev"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.88"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "kandev-desktop"
|
name = "kandev-desktop"
|
||||||
@@ -30,7 +30,7 @@ darling = "=0.21.3"
|
|||||||
getrandom = "0.3"
|
getrandom = "0.3"
|
||||||
idna_adapter = "=1.0.0"
|
idna_adapter = "=1.0.0"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
plist = "=1.7.1"
|
plist = "=1.10.0"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_with = "=3.15.0"
|
serde_with = "=3.15.0"
|
||||||
tauri = { version = "=2.8.4", features = [], optional = true }
|
tauri = { version = "=2.8.4", features = [], optional = true }
|
||||||
|
|||||||
@@ -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 DESKTOP_HEALTH_TOKEN_HEADER: &str = "x-kandev-desktop-health-token";
|
||||||
const STARTUP_OUTPUT_LIMIT: usize = 12 * 1024;
|
const STARTUP_OUTPUT_LIMIT: usize = 12 * 1024;
|
||||||
const HEALTH_READY_SETTLE: Duration = Duration::from_millis(100);
|
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-amd64", "agentctl linux/amd64 helper"),
|
||||||
|
("agentctl-linux-arm64", "agentctl linux/arm64 helper"),
|
||||||
("agentctl-darwin-arm64", "agentctl darwin/arm64 helper"),
|
("agentctl-darwin-arm64", "agentctl darwin/arm64 helper"),
|
||||||
("agentctl-darwin-amd64", "agentctl darwin/amd64 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("kandev")), b"stub").expect("write launcher");
|
||||||
fs::write(bin.join(executable_name("agentctl")), b"stub").expect("write agentctl");
|
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-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");
|
fs::write(bin.join("agentctl-darwin-amd64"), b"stub").expect("write darwin amd64 helper");
|
||||||
|
|
||||||
let err =
|
let err =
|
||||||
|
|||||||
@@ -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
|
### 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`.
|
- 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`.
|
- 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}`.
|
||||||
- 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`.
|
- **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.
|
- 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.
|
- 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.
|
- 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)
|
## Out of scope (v1)
|
||||||
|
|
||||||
- **Remote Windows.** SSH transport to Windows is out of scope.
|
- **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.
|
- **Password / keyboard-interactive auth.** Keys + agent only.
|
||||||
- **Passphrase-protected keys handled inside kandev.** User must load the key into `ssh-agent`.
|
- **Passphrase-protected keys handled inside kandev.** User must load the key into `ssh-agent`.
|
||||||
- **Chained ProxyJump.** Single bastion only; multi-hop deferred.
|
- **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
|
## 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.
|
- **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.
|
- **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.
|
- **Chained ProxyJump** and `ProxyCommand` support for esoteric setups.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ SIGNING_SECRET_ENV=(
|
|||||||
)
|
)
|
||||||
REMOTE_AGENTCTL_HELPERS=(
|
REMOTE_AGENTCTL_HELPERS=(
|
||||||
agentctl-linux-amd64
|
agentctl-linux-amd64
|
||||||
|
agentctl-linux-arm64
|
||||||
agentctl-darwin-arm64
|
agentctl-darwin-arm64
|
||||||
agentctl-darwin-amd64
|
agentctl-darwin-amd64
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|||||||
BUNDLE="$ROOT_DIR/dist/kandev"
|
BUNDLE="$ROOT_DIR/dist/kandev"
|
||||||
REMOTE_AGENTCTL_HELPERS=(
|
REMOTE_AGENTCTL_HELPERS=(
|
||||||
agentctl-linux-amd64
|
agentctl-linux-amd64
|
||||||
|
agentctl-linux-arm64
|
||||||
agentctl-darwin-arm64
|
agentctl-darwin-arm64
|
||||||
agentctl-darwin-amd64
|
agentctl-darwin-amd64
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ runtime bundle. The input bundle must contain:
|
|||||||
bin/kandev[.exe]
|
bin/kandev[.exe]
|
||||||
bin/agentctl[.exe]
|
bin/agentctl[.exe]
|
||||||
bin/agentctl-linux-amd64
|
bin/agentctl-linux-amd64
|
||||||
|
bin/agentctl-linux-arm64
|
||||||
bin/agentctl-darwin-arm64
|
bin/agentctl-darwin-arm64
|
||||||
bin/agentctl-darwin-amd64
|
bin/agentctl-darwin-amd64
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ VERIFY_SCRIPT="${ROOT_DIR}/scripts/release/verify-desktop-runtime.sh"
|
|||||||
PLATFORM=""
|
PLATFORM=""
|
||||||
REMOTE_AGENTCTL_HELPERS=(
|
REMOTE_AGENTCTL_HELPERS=(
|
||||||
agentctl-linux-amd64
|
agentctl-linux-amd64
|
||||||
|
agentctl-linux-arm64
|
||||||
agentctl-darwin-arm64
|
agentctl-darwin-arm64
|
||||||
agentctl-darwin-amd64
|
agentctl-darwin-amd64
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Validate a desktop runtime directory with this layout:
|
|||||||
kandev[.exe]
|
kandev[.exe]
|
||||||
agentctl[.exe]
|
agentctl[.exe]
|
||||||
agentctl-linux-amd64
|
agentctl-linux-amd64
|
||||||
|
agentctl-linux-arm64
|
||||||
agentctl-darwin-arm64
|
agentctl-darwin-arm64
|
||||||
agentctl-darwin-amd64
|
agentctl-darwin-amd64
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ RUNTIME_DIR_SET=false
|
|||||||
PLATFORM=""
|
PLATFORM=""
|
||||||
REMOTE_AGENTCTL_HELPERS=(
|
REMOTE_AGENTCTL_HELPERS=(
|
||||||
"agentctl-linux-amd64:agentctl linux/amd64 helper"
|
"agentctl-linux-amd64:agentctl linux/amd64 helper"
|
||||||
|
"agentctl-linux-arm64:agentctl linux/arm64 helper"
|
||||||
"agentctl-darwin-arm64:agentctl darwin/arm64 helper"
|
"agentctl-darwin-arm64:agentctl darwin/arm64 helper"
|
||||||
"agentctl-darwin-amd64:agentctl darwin/amd64 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.
|
# MSYS/Git Bash can report Windows executables differently from Unix mode bits.
|
||||||
if [ "${OS:-}" = "Windows_NT" ]; then
|
if [ "${OS:-}" = "Windows_NT" ]; then
|
||||||
case "$(basename "$path")" in
|
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
|
esac
|
||||||
fi
|
fi
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
Reference in New Issue
Block a user