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