Files
kandev/apps/backend/internal/scriptengine/providers.go
T
João Salavisa 75f1732421 feat: add GitHub token injection for remote executors and Docker session resume (#654)
* feat: add GitHub token injection for remote executors and Docker session resume

- Add global GitHub access token configuration (backend + frontend)
- Inject GitHub token into Docker and Sprites executors via env vars and Git credential helper
- Transition Docker executor from host mounting to repository cloning
- Implement Docker session resume (reconnect to existing containers after backend restart)
- Add shell validation using exec.LookPath in agentctl
- Add scriptengine providers for agent install and agentctl placeholders

* fix: format github-status.tsx with prettier

* fix: resolve lint issues (goconst, errcheck, file-length)

* fix: address code review findings

- Fix resolveGHCLIToken to check both GITHUB_TOKEN and GH_TOKEN guards
- Fix credential helper to use \\ fallback
- Change /bin/bash to /bin/sh in credential helper for Alpine compat
- Strip embedded token from .git/config after clone in prepare scripts
- Extract buildLaunchAgentRequest helpers to stay under 80-line limit
- Add tests for GitHubAuthProvider, injectTokenIntoURL, reconnect validation
- Add GH_TOKEN-only test case for resolveGHCLIToken

* refactor(executor): extract GitHub credential resolution to separate file

Move injectGitHubToken, injectGitHubTokenFromCLI, resolveRemoteCredentials,
resolveAuthSecrets, methodIDToEnvVar, resolveGHCLIToken, and detectGHToken
out of executor_execute.go (was 803 logical lines, over the 800-line limit
after merging main) into a new executor_credentials.go file. Also adds the
nil-guard on secretStore in resolveAuthSecrets that was flagged by review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 22:15:50 +01:00

207 lines
6.4 KiB
Go

package scriptengine
import (
"fmt"
"strings"
)
// RepositoryProvider returns git-related placeholders from metadata and environment.
// Parameters:
// - metadata: executor create request metadata (contains "repository_path", "base_branch", etc.)
// - env: environment variables (contains "GITHUB_TOKEN", etc.)
// - repoURLResolver: resolves a local repo path to its remote URL (e.g., `git remote get-url origin`)
// - tokenInjector: injects auth token into a clone URL
func RepositoryProvider(
metadata map[string]any,
env map[string]string,
repoURLResolver func(string) (string, error),
tokenInjector func(string, map[string]string) string,
) PlaceholderProvider {
return func() map[string]string {
vars := make(map[string]string)
repoPath := getMetaString(metadata, "repository_path")
if repoPath != "" {
vars["repository.path"] = repoPath
vars["repository.name"] = repoNameFromPath(repoPath)
}
branch := getMetaString(metadata, "base_branch")
if branch == "" {
branch = getMetaString(metadata, "repository_branch")
}
vars["repository.branch"] = branch
setupScript := getMetaString(metadata, "repository_setup_script")
vars["repository.setup_script"] = setupScript
// Clone URL: prefer explicit metadata, fall back to resolving from local repo
cloneURL := getMetaString(metadata, "repository_clone_url")
if cloneURL == "" && repoPath != "" && repoURLResolver != nil {
if remoteURL, err := repoURLResolver(repoPath); err == nil && remoteURL != "" {
vars["repository.ssh_url"] = remoteURL
cloneURL = remoteURL
}
}
if cloneURL != "" {
vars["repository.clone_url"] = injectToken(cloneURL, env, tokenInjector)
}
return vars
}
}
// AgentctlProvider returns kandev agentctl-related placeholders.
func AgentctlProvider(agentctlPort int, workspacePath string) PlaceholderProvider {
return func() map[string]string {
portStr := fmt.Sprintf("%d", agentctlPort)
return map[string]string{
"kandev.agentctl.port": portStr,
"kandev.agentctl.install": "chmod +x /usr/local/bin/agentctl",
"kandev.agentctl.start": fmt.Sprintf(
"nohup agentctl --port %s --workdir %s > /tmp/agentctl.log 2>&1 &\nsleep 1",
portStr, workspacePath,
),
}
}
}
// WorkspaceProvider returns workspace path placeholder.
func WorkspaceProvider(workspacePath string) PlaceholderProvider {
return func() map[string]string {
return map[string]string{
"workspace.path": workspacePath,
}
}
}
// WorktreeProvider returns placeholders that describe the selected worktree context.
func WorktreeProvider(basePath, path, id, branch, baseBranch string) PlaceholderProvider {
return func() map[string]string {
return map[string]string{
"worktree.base_path": basePath,
"worktree.path": path,
"worktree.id": id,
"worktree.branch": branch,
"worktree.base_branch": baseBranch,
}
}
}
// GitIdentityProvider returns placeholders for git identity setup in remote executors.
func GitIdentityProvider(metadata map[string]any) PlaceholderProvider {
return func() map[string]string {
name := getMetaString(metadata, "git_user_name")
email := getMetaString(metadata, "git_user_email")
vars := map[string]string{
"git.user_name": name,
"git.user_email": email,
"git.identity_setup": "",
}
if name == "" || email == "" {
return vars
}
lines := []string{
fmt.Sprintf("git config --global user.name '%s'", shellSingleQuote(name)),
fmt.Sprintf("git config --global user.email '%s'", shellSingleQuote(email)),
}
vars["git.identity_setup"] = strings.Join(lines, "\n")
return vars
}
}
// injectToken applies token injection to a URL if an injector is provided.
func injectToken(url string, env map[string]string, injector func(string, map[string]string) string) string {
if injector != nil {
return injector(url, env)
}
return url
}
// getMetaString extracts a string value from a metadata map.
func getMetaString(metadata map[string]any, key string) string {
if metadata == nil {
return ""
}
if v, ok := metadata[key].(string); ok {
return v
}
return ""
}
// repoNameFromPath extracts the repository name from a file path.
func repoNameFromPath(repoPath string) string {
if repoPath == "" {
return ""
}
// Find last path component
for i := len(repoPath) - 1; i >= 0; i-- {
if repoPath[i] == '/' {
return repoPath[i+1:]
}
}
return repoPath
}
// AgentInstallProvider returns a placeholder with combined install scripts for multiple agents.
func AgentInstallProvider(installScripts []string) PlaceholderProvider {
return func() map[string]string {
seen := map[string]bool{}
var lines []string
for _, s := range installScripts {
s = strings.TrimSpace(s)
if s != "" && !seen[s] {
seen[s] = true
lines = append(lines, s)
}
}
return map[string]string{
"kandev.agents.install": strings.Join(lines, "\n"),
}
}
}
func shellSingleQuote(value string) string {
return strings.ReplaceAll(value, "'", `'"'"'`)
}
// GitHubAuthProvider returns placeholders for GitHub authentication setup.
// It configures both the gh CLI and git credential helper to use the GitHub token.
func GitHubAuthProvider(env map[string]string) PlaceholderProvider {
return func() map[string]string {
vars := map[string]string{
"github.auth_setup": "",
}
// Check for GitHub token in env (either GH_TOKEN or GITHUB_TOKEN)
token := env["GH_TOKEN"]
if token == "" {
token = env["GITHUB_TOKEN"]
}
if token == "" {
// No token - output fallback commands that may work if gh is pre-authenticated
vars["github.auth_setup"] = `# No GitHub token configured - trying fallback methods
gh auth setup-git 2>/dev/null || true
gh config set git_protocol https --host github.com 2>/dev/null || true`
return vars
}
// Build auth setup script that configures both gh CLI and git
// Use /bin/sh (not /bin/bash) for Alpine compatibility
// Use ${GH_TOKEN:-${GITHUB_TOKEN}} to support either env var being set
lines := []string{
"# GitHub token authentication",
"# Configure git credential helper for GitHub HTTPS authentication",
`git config --global credential.https://github.com.helper '!/bin/sh -c "echo username=x-access-token; echo password=${GH_TOKEN:-${GITHUB_TOKEN}}"'`,
"# Configure gh CLI to use HTTPS protocol",
"gh config set git_protocol https --host github.com 2>/dev/null || true",
"# Register gh as git credential helper (backup method)",
"gh auth setup-git 2>/dev/null || true",
}
vars["github.auth_setup"] = strings.Join(lines, "\n")
return vars
}
}