Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -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
Expand All @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
35 changes: 33 additions & 2 deletions apps/backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -108,25 +131,33 @@ 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:
@echo "Building agentctl for linux/amd64..."
-@$(MKDIR) $(BUILD_DIR)
$(GO_LINUX_AMD64) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-linux-amd64 ./cmd/agentctl

## Build agentctl for 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:
Expand Down
45 changes: 45 additions & 0 deletions apps/backend/internal/agent/runtime/lifecycle/executor_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{} {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -162,19 +162,20 @@ 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"},
} {
if err := requireSupportedRemotePlatform(platform); err != nil {
t.Errorf("%s should be supported, got %v", platform.String(), err)
}
}
unsupported := SSHRemotePlatform{GOOS: "linux", GOARCH: "arm64", UnameOS: "Linux", UnameArch: "aarch64"}
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)
}
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
}
Expand Down Expand Up @@ -560,6 +561,7 @@ func createRemoteAgentInstance(
McpMode: req.McpMode,
RequiresProcessKill: requiresProcessKillFromReq(req),
BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches),
Env: sshRemoteAgentEnv(req),
})
if err != nil {
return 0, fmt.Errorf("ssh: marshal create-instance: %w", err)
Expand Down Expand Up @@ -613,6 +615,49 @@ 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).
var sshRemoteAgentCredentialEnvKeys = []string{
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GITHUB_TOKEN",
"GH_TOKEN",
}

// 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
Comment thread
iamcobolt marked this conversation as resolved.
Outdated
// 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 {
Expand Down
Loading
Loading