fix(backend): validate managed Git credential identity before session launch (#2787)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-19 02:50:20 -04:00
committed by GitHub
parent 22472460a6
commit 9b99b7d1fd
16 changed files with 1539 additions and 115 deletions
@@ -257,64 +257,12 @@ func repositoryHTTPSIdentity(repository *taskmodels.Repository) (string, string,
if repository == nil {
return "", "", fmt.Errorf("repository is required")
}
remoteURL := repositoryHTTPSCloneURL(repository)
parsed, err := parseRepositoryHTTPSCloneURL(remoteURL)
identity, err := gitcredentials.ResolveRepositoryIdentity(gitcredentials.RepositoryIdentityInput{
RepositoryID: repository.ID, Provider: repository.Provider, ProviderHost: repository.ProviderHost,
ProviderOwner: repository.ProviderOwner, ProviderName: repository.ProviderName, RemoteURL: repository.RemoteURL,
})
if err != nil {
return "", "", fmt.Errorf("repository HTTPS clone URL is unavailable")
return "", "", err
}
providerHost := repositoryProviderOrigin(repository)
if err := repoclone.ValidateHTTPSCloneOrigin(remoteURL, providerHost); err != nil {
return "", "", fmt.Errorf("repository provider origin: %w", err)
}
return strings.ToLower(parsed.Host), parsed.Path, nil
}
// repositoryHTTPSCloneURL mirrors the executor's credentialIdentityCloneURL:
// an HTTPS remote wins, then the persisted provider identity whose host carries
// the real HTTPS origin, and only then a rewrite of a non-HTTPS remote. The two
// must agree, because one issues the lease scope and the other authorizes it.
func repositoryHTTPSCloneURL(repository *taskmodels.Repository) string {
remoteURL := strings.TrimSpace(repository.RemoteURL)
if strings.HasPrefix(strings.ToLower(remoteURL), "https://") {
return remoteURL
}
if derived := providerHTTPSCloneURL(repository); derived != "" {
return derived
}
if converted := repoclone.CanonicalHTTPSCloneURL(remoteURL); converted != "" {
return converted
}
return remoteURL
}
func providerHTTPSCloneURL(repository *taskmodels.Repository) string {
if !strings.EqualFold(repository.Provider, gitCredentialGitHubProviderID) ||
repository.ProviderOwner == "" || repository.ProviderName == "" {
return ""
}
cloneURL, err := repoclone.CloneURLWithHost(
repository.Provider, repository.ProviderHost,
repository.ProviderOwner, repository.ProviderName, repoclone.ProtocolHTTPS,
)
if err != nil {
return ""
}
return cloneURL
}
func parseRepositoryHTTPSCloneURL(remoteURL string) (*url.URL, error) {
parsed, err := url.Parse(remoteURL)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil ||
parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" {
return nil, fmt.Errorf("invalid HTTPS clone URL")
}
return parsed, nil
}
func repositoryProviderOrigin(repository *taskmodels.Repository) string {
providerHost := strings.TrimSpace(repository.ProviderHost)
if providerHost != "" || (repository.Provider != "" && !strings.EqualFold(repository.Provider, gitCredentialGitHubProviderID)) {
return providerHost
}
return "https://" + gitCredentialGitHubHost
return identity.Host, identity.Path, nil
}
@@ -0,0 +1,170 @@
package gitcredentials
import (
"fmt"
"net/url"
"strings"
"github.com/kandev/kandev/internal/repoclone"
)
const publicGitHubHost = "github.com"
// RepositoryIdentityInput is the non-secret persisted repository metadata used
// to authorize a managed Git credential scope. Provider remains routing
// metadata; ProviderHost alone establishes the authorized HTTPS origin.
type RepositoryIdentityInput struct {
RepositoryID string
Provider string
ProviderHost string
ProviderOwner string
ProviderName string
RemoteURL string
}
// RepositoryIdentity is the canonical HTTPS identity used by issuers and the
// credential broker.
type RepositoryIdentity struct {
Host string
Path string
Owner string
Repository string
}
func ResolveRepositoryIdentity(input RepositoryIdentityInput) (RepositoryIdentity, error) {
remoteURL := strings.TrimSpace(input.RemoteURL)
if remoteURL == "" {
if input.ProviderOwner == "" || input.ProviderName == "" {
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "has no clone URL")
}
origin, err := trustedOrigin(input, "")
if err != nil {
return RepositoryIdentity{}, err
}
remoteURL = origin + "/" + strings.Trim(input.ProviderOwner, "/") + "/" + strings.Trim(input.ProviderName, "/") + ".git"
}
remote, err := parseRemote(remoteURL)
if err != nil {
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "must use a credential-free HTTPS or SSH clone URL")
}
return resolveParsedRepositoryIdentity(input, remote)
}
func resolveParsedRepositoryIdentity(input RepositoryIdentityInput, remote parsedRemote) (RepositoryIdentity, error) {
origin, err := trustedOrigin(input, remote.hostname)
if err != nil {
return RepositoryIdentity{}, err
}
originURL, _ := url.Parse(origin)
if remote.isHTTPS {
if err := repoclone.ValidateHTTPSCloneOrigin(remote.canonicalURL, origin); err != nil {
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "remote host conflicts with its provider origin")
}
} else if !strings.EqualFold(remote.hostname, originURL.Hostname()) {
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "remote host conflicts with its provider origin")
}
canonicalPath, err := canonicalIdentityPath(input, remote.path)
if err != nil {
return RepositoryIdentity{}, err
}
owner, repository, err := identityPath(canonicalPath)
if err != nil {
return RepositoryIdentity{}, repositoryIdentityError(input.RepositoryID, "has an invalid clone path")
}
return RepositoryIdentity{Host: originURL.Host, Path: canonicalPath, Owner: owner, Repository: repository}, nil
}
func canonicalIdentityPath(input RepositoryIdentityInput, remotePath string) (string, error) {
if !strings.EqualFold(strings.TrimSpace(input.Provider), "github") || input.ProviderOwner == "" || input.ProviderName == "" {
return remotePath, nil
}
remoteOwner, remoteRepository, err := identityPath(remotePath)
if err != nil || !strings.EqualFold(remoteOwner, strings.TrimSpace(input.ProviderOwner)) ||
!strings.EqualFold(strings.TrimSuffix(remoteRepository, ".git"), strings.TrimSuffix(strings.TrimSpace(input.ProviderName), ".git")) {
return "", repositoryIdentityError(input.RepositoryID, "remote path conflicts with its provider identity")
}
return "/" + strings.Trim(input.ProviderOwner, "/") + "/" + strings.Trim(input.ProviderName, "/") + ".git", nil
}
type parsedRemote struct {
hostname string
path string
canonicalURL string
isHTTPS bool
}
func parseRemote(raw string) (parsedRemote, error) {
value := strings.TrimSpace(raw)
canonical := repoclone.CanonicalHTTPSCloneURL(value)
if canonical != "" {
if err := validateSSHRemote(value); err != nil {
return parsedRemote{}, err
}
parsed, err := url.Parse(canonical)
if err != nil || parsed.Host == "" || parsed.Path == "" {
return parsedRemote{}, fmt.Errorf("invalid SSH clone URL")
}
return parsedRemote{hostname: parsed.Hostname(), path: parsed.Path, canonicalURL: canonical}, nil
}
parsed, err := url.Parse(value)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || parsed.User != nil ||
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.Path == "" {
return parsedRemote{}, fmt.Errorf("invalid HTTPS clone URL")
}
return parsedRemote{hostname: parsed.Hostname(), path: parsed.Path, canonicalURL: "https://" + strings.ToLower(parsed.Host) + parsed.Path, isHTTPS: true}, nil
}
func validateSSHRemote(value string) error {
if !strings.Contains(value, "://") {
if strings.ContainsAny(value, "?#") {
return fmt.Errorf("invalid SSH clone URL")
}
return nil
}
parsed, err := url.Parse(value)
if err != nil || !strings.EqualFold(parsed.Scheme, "ssh") || parsed.Hostname() == "" ||
parsed.Path == "" || parsed.Path == "/" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
return fmt.Errorf("invalid SSH clone URL")
}
if parsed.User != nil {
if _, hasPassword := parsed.User.Password(); hasPassword {
return fmt.Errorf("invalid SSH clone URL")
}
}
return nil
}
func trustedOrigin(input RepositoryIdentityInput, remoteHost string) (string, error) {
if host := strings.TrimSpace(input.ProviderHost); host != "" {
origin, err := repoclone.HTTPSProviderOrigin(host)
if err != nil {
return "", repositoryIdentityError(input.RepositoryID, "has an invalid provider host")
}
return origin, nil
}
if strings.EqualFold(remoteHost, publicGitHubHost) {
return "https://" + publicGitHubHost, nil
}
if strings.EqualFold(strings.TrimSpace(input.Provider), "github") && remoteHost == "" {
return "https://" + publicGitHubHost, nil
}
return "", repositoryIdentityError(input.RepositoryID, "needs a provider host for managed credentials")
}
func identityPath(path string) (string, string, error) {
trimmed := strings.TrimSuffix(strings.Trim(path, "/"), ".git")
owner, repository, found := strings.Cut(trimmed, "/")
if !found || owner == "" || repository == "" {
return "", "", fmt.Errorf("path needs owner and repository")
}
return owner, repository, nil
}
func repositoryIdentityError(repositoryID, detail string) error {
if strings.TrimSpace(repositoryID) == "" {
return fmt.Errorf("repository identity %s", detail)
}
return fmt.Errorf("repository %q %s; repair its clone URL or provider host", repositoryID, detail)
}
@@ -0,0 +1,123 @@
package gitcredentials
import (
"strings"
"testing"
)
func TestResolveRepositoryIdentityAcceptsGitHubTransportSpellingsWithoutProviderHost(t *testing.T) {
for _, provider := range []string{"", "plugin-provider", "kandev-plugin-tags"} {
for _, remoteURL := range []string{
"git@github.com:acme/widgets.git",
"ssh://git@github.com/acme/widgets.git",
"https://github.com/acme/widgets.git",
} {
t.Run(provider+"/"+remoteURL, func(t *testing.T) {
identity, err := ResolveRepositoryIdentity(RepositoryIdentityInput{
RepositoryID: "repo-1", Provider: provider, RemoteURL: remoteURL,
})
if err != nil {
t.Fatalf("ResolveRepositoryIdentity() error = %v", err)
}
if identity.Host != "github.com" || identity.Path != "/acme/widgets.git" {
t.Fatalf("identity = %#v, want github.com /acme/widgets.git", identity)
}
})
}
}
}
func TestResolveRepositoryIdentityUsesExplicitProviderHostForHTTPS(t *testing.T) {
identity, err := ResolveRepositoryIdentity(RepositoryIdentityInput{
RepositoryID: "repo-1", Provider: "acme-forge", ProviderHost: "https://forge.example:9443",
RemoteURL: "https://forge.example:9443/acme/widgets.git",
})
if err != nil {
t.Fatalf("ResolveRepositoryIdentity() error = %v", err)
}
if identity.Host != "forge.example:9443" || identity.Path != "/acme/widgets.git" {
t.Fatalf("identity = %#v, want forge.example:9443 /acme/widgets.git", identity)
}
}
// A custom host with no ProviderHost metadata must fail closed even when
// Provider names a would-be resolver and owner/name are populated: only an
// explicit provider host - or the well-known public github.com - is a
// trusted origin.
func TestResolveRepositoryIdentityRequiresExplicitProviderHostForNonGitHubProvider(t *testing.T) {
_, err := ResolveRepositoryIdentity(RepositoryIdentityInput{
RepositoryID: "repo-1", Provider: "acme-forge", ProviderOwner: "acme", ProviderName: "widgets",
RemoteURL: "https://forge.example/acme/widgets.git",
})
if err == nil {
t.Fatal("ResolveRepositoryIdentity() error = nil, want rejection of an untrusted custom host")
}
}
// A GitHub row whose persisted owner/name conflict with its remote's actual
// path must be rejected even though the remote itself resolves to a trusted
// github.com origin - populated provider identity fields must not silently
// win over a mismatched remote.
func TestResolveRepositoryIdentityRejectsGitHubOwnerNameConflictWithRemotePath(t *testing.T) {
_, err := ResolveRepositoryIdentity(RepositoryIdentityInput{
RepositoryID: "repo-1", Provider: "github", ProviderOwner: "acme", ProviderName: "widgets",
RemoteURL: "https://github.com/acme/other-repo.git",
})
if err == nil {
t.Fatal("ResolveRepositoryIdentity() error = nil, want rejection of a provider/remote path conflict")
}
}
// Credential-bearing and query/fragment-bearing URLs must be rejected, and
// the rejection must never echo the credential-bearing URL back - only the
// safe repository ID and a generic remediation.
func TestResolveRepositoryIdentityRejectsCredentialBearingAndMalformedURLsWithoutLeakingSecrets(t *testing.T) {
for name, remoteURL := range map[string]string{
"https userinfo": "https://oauth2:sh_supersecret@github.com/acme/widgets.git",
"https query": "https://github.com/acme/widgets.git?token=sh_supersecret",
"https fragment": "https://github.com/acme/widgets.git#sh_supersecret",
"ssh password": "ssh://git:sh_supersecret@github.com/acme/widgets.git",
"ssh query": "ssh://git@github.com/acme/widgets.git?token=sh_supersecret",
"ssh fragment": "ssh://git@github.com/acme/widgets.git#sh_supersecret",
"scp-style query": "git@github.com:acme/widgets.git?token=sh_supersecret",
"scp-style fragment": "git@github.com:acme/widgets.git#sh_supersecret",
"malformed": "not-a-valid-url",
} {
t.Run(name, func(t *testing.T) {
_, err := ResolveRepositoryIdentity(RepositoryIdentityInput{RepositoryID: "repo-1", RemoteURL: remoteURL})
if err == nil {
t.Fatalf("ResolveRepositoryIdentity(%q) error = nil, want rejection", remoteURL)
}
if strings.Contains(err.Error(), "sh_supersecret") {
t.Fatalf("error leaked the credential-bearing URL: %v", err)
}
if strings.Contains(err.Error(), remoteURL) {
t.Fatalf("error leaked the rejected remote: %v", err)
}
})
}
}
func TestResolveRepositoryIdentityUsesExplicitProviderHostForSSH(t *testing.T) {
identity, err := ResolveRepositoryIdentity(RepositoryIdentityInput{
RepositoryID: "repo-1", ProviderHost: "https://ghe.example:8443",
RemoteURL: "ssh://git@ghe.example:2222/acme/widgets.git",
})
if err != nil {
t.Fatalf("ResolveRepositoryIdentity() error = %v", err)
}
if identity.Host != "ghe.example:8443" || identity.Path != "/acme/widgets.git" {
t.Fatalf("identity = %#v, want ghe.example:8443 /acme/widgets.git", identity)
}
}
func TestResolveRepositoryIdentityRejectsUntrustedOrConflictingOrigin(t *testing.T) {
for _, input := range []RepositoryIdentityInput{
{RepositoryID: "repo-1", RemoteURL: "ssh://git@ghe.example/acme/widgets.git"},
{RepositoryID: "repo-1", ProviderHost: "https://github.com", RemoteURL: "https://evil.example/acme/widgets.git"},
} {
if _, err := ResolveRepositoryIdentity(input); err == nil {
t.Fatalf("ResolveRepositoryIdentity(%#v) error = nil, want rejection", input)
}
}
}
@@ -1696,13 +1696,6 @@ func (s *Service) switchSessionForStep(ctx context.Context, taskID string, curre
zap.String("current_session", currentSession.ID),
zap.String("current_profile", currentSession.AgentProfileID),
zap.String("new_profile", newAgentProfileID))
// Signal to the frontend that the task is preparing a new agent.
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil {
s.logger.Warn("failed to set task SCHEDULING during agent switch",
zap.String("task_id", taskID), zap.Error(err))
}
existing, lookupErr := s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID)
if lookupErr != nil {
s.logger.Warn("failed to look up reusable session, falling through to create new",
@@ -1710,6 +1703,35 @@ func (s *Service) switchSessionForStep(ctx context.Context, taskID string, curre
zap.String("agent_profile_id", newAgentProfileID),
zap.Error(lookupErr))
}
targetSession := currentSession
if existing != nil {
targetSession = existing
}
// Validate managed-credential repository bindings before mutating either
// session's ownership. Reusing or replacing the current session only to
// discover at launch that a repository binding is irreparable would leave
// the task stuck on a doomed session with no recovery path back to the one
// that was still working.
dbTask, err := s.repo.GetTask(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("failed to get task for session switch preflight: %w", err)
}
if dbTask == nil {
return nil, fmt.Errorf("task %s not found for session switch preflight", taskID)
}
if err := s.executor.PreflightManagedGitCredentials(
ctx, dbTask.WorkspaceID, taskID, targetSession.ExecutorID, targetSession.ExecutorProfileID,
); err != nil {
return nil, err
}
// Signal to the frontend that the task is preparing a new agent.
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil {
s.logger.Warn("failed to set task SCHEDULING during agent switch",
zap.String("task_id", taskID), zap.Error(err))
}
if existing != nil {
return s.reuseSessionForStep(ctx, taskID, currentSession, existing)
}
@@ -1973,6 +1995,39 @@ func (s *Service) prepareWorkflowStepSession(
return newSession, true, nil
}
func (s *Service) preflightWorkflowStepCredentials(
ctx context.Context,
taskID string,
currentSession *models.TaskSession,
targetStep *wfmodels.WorkflowStep,
) error {
if currentSession == nil || targetStep == nil || s.agentManager.IsPassthroughSession(ctx, currentSession.ID) {
return nil
}
effectiveProfile := s.resolveStepAgentProfile(ctx, targetStep)
if effectiveProfile == "" || effectiveProfile == currentSession.AgentProfileID {
return nil
}
targetSession := currentSession
existing, err := s.findReusableSessionForProfile(ctx, taskID, effectiveProfile, currentSession.ID)
if err != nil {
return fmt.Errorf("find reusable session for credential preflight: %w", err)
}
if existing != nil {
targetSession = existing
}
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
return fmt.Errorf("get task for credential preflight: %w", err)
}
if task == nil {
return fmt.Errorf("task %s not found for credential preflight", taskID)
}
return s.executor.PreflightManagedGitCredentials(
ctx, task.WorkspaceID, taskID, targetSession.ExecutorID, targetSession.ExecutorProfileID,
)
}
// maybySwitchSessionForProfile preserves the legacy processOnEnter failure
// handling while sharing the error-returning step-entry preflight with direct
// workflow-engine dispatch.
@@ -3818,6 +3873,13 @@ func (s *Service) applyEngineTransition(
return false
}
}
if err := s.preflightWorkflowStepCredentials(ctx, taskID, session, targetStep); err != nil {
s.logger.Warn("target profile credential preflight failed, skipping transition",
zap.String("task_id", taskID),
zap.String("step_id", result.ToStepID),
zap.Error(err))
return false
}
terminalTarget := s.workflowStepIsTerminal(ctx, targetStep.ID)
@@ -0,0 +1,280 @@
package orchestrator
import (
"context"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/gitcredentials"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/orchestrator/queue"
"github.com/kandev/kandev/internal/orchestrator/scheduler"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type fakeSwitchSessionCredentialIssuer struct{}
func (fakeSwitchSessionCredentialIssuer) Issue(
context.Context, gitcredentials.Scope,
) (gitcredentials.Lease, error) {
return gitcredentials.Lease{Token: "opaque-lease"}, nil
}
// TestSwitchSessionForStep_RejectsIrreparableManagedCredentialRepository
// covers AC-8/AC-9 at the profile-switch/re-entry seam: a task bound to a
// repository whose managed Git credential identity is irreparable (a custom
// host with no trustworthy provider host metadata) must reject the switch
// before mutating either session's ownership - not stop the still-working
// current session only to hand the task to a session that can never launch.
func TestSwitchSessionForStep_RejectsIrreparableManagedCredentialRepository(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
now := time.Now().UTC()
ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}
if err := repo.CreateWorkspace(ctx, ws); err != nil {
t.Fatalf("create workspace: %v", err)
}
wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now}
if err := repo.CreateWorkflow(ctx, wf); err != nil {
t.Fatalf("create workflow: %v", err)
}
task := &models.Task{
ID: "t1", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: "step2",
Title: "Test", Description: "Test", State: v1.TaskStateInProgress,
CreatedAt: now, UpdatedAt: now,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("create task: %v", err)
}
repository := &models.Repository{
ID: "repo1", WorkspaceID: "ws1", Name: "widgets", SourceType: "local",
Provider: "acme-forge", RemoteURL: "https://forge.example/acme/widgets.git",
}
if err := repo.CreateRepository(ctx, repository); err != nil {
t.Fatalf("create repository: %v", err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{
ID: "taskrepo1", TaskID: "t1", RepositoryID: "repo1",
}); err != nil {
t.Fatalf("create task repository: %v", err)
}
session := &models.TaskSession{
ID: "s1", TaskID: "t1", AgentProfileID: "profile-a",
ExecutorID: "exec-local", ExecutorProfileID: "ep1", AgentExecutionID: "ae1",
State: models.TaskSessionStateRunning, IsPrimary: true,
StartedAt: now, UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("create task session: %v", err)
}
taskRepo := newMockTaskRepo()
taskRepo.tasks["t1"] = &v1.Task{
ID: "t1", WorkspaceID: "ws1", WorkflowID: "wf1",
Title: "Test", Description: "Test", State: v1.TaskStateInProgress,
}
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
log := testLogger()
exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{})
exec.SetGitHubCredentialBroker(fakeSwitchSessionCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve")
sched := scheduler.NewScheduler(queue.NewTaskQueue(100), exec, taskRepo, log, scheduler.SchedulerConfig{})
svc := &Service{
logger: log,
repo: repo,
workflowStepGetter: newMockStepGetter(),
taskRepo: taskRepo,
agentManager: agentMgr,
messageQueue: messagequeue.NewServiceMemory(log),
executor: exec,
scheduler: sched,
}
_, err := svc.switchSessionForStep(ctx, "t1", session, "profile-b")
if err == nil {
t.Fatal("switchSessionForStep() error = nil, want rejection of the irreparable repository binding")
}
if !strings.Contains(err.Error(), "repo1") {
t.Fatalf("error %q does not name the safe repository id repo1", err.Error())
}
// The current session must be untouched: still RUNNING and still primary.
oldSession, getErr := repo.GetTaskSession(ctx, "s1")
if getErr != nil {
t.Fatalf("get old session: %v", getErr)
}
if oldSession.State != models.TaskSessionStateRunning {
t.Errorf("old session state = %s, want RUNNING (must not be touched by a rejected switch)", oldSession.State)
}
if !oldSession.IsPrimary {
t.Error("old session must still be primary after a rejected switch")
}
// No replacement session for profile-b was created.
sessions, listErr := repo.ListTaskSessions(ctx, "t1")
if listErr != nil {
t.Fatalf("list task sessions: %v", listErr)
}
if len(sessions) != 1 {
t.Fatalf("task session count = %d, want 1 (no doomed replacement session)", len(sessions))
}
}
func TestApplyEngineTransitionRejectsTargetProfileBeforePersistingStep(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
now := time.Now().UTC()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create workflow: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "t1", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: "step1",
Title: "Test", Description: "Test", State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("create task: %v", err)
}
if err := repo.CreateRepository(ctx, &models.Repository{
ID: "repo1", WorkspaceID: "ws1", Name: "widgets", SourceType: "local",
Provider: "acme-forge", RemoteURL: "https://forge.example/acme/widgets.git",
}); err != nil {
t.Fatalf("create repository: %v", err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{
ID: "taskrepo1", TaskID: "t1", RepositoryID: "repo1",
}); err != nil {
t.Fatalf("create task repository: %v", err)
}
session := &models.TaskSession{
ID: "s1", TaskID: "t1", AgentProfileID: "profile-a", ExecutorID: "exec-local",
State: models.TaskSessionStateRunning, IsPrimary: true, StartedAt: now, UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("create task session: %v", err)
}
steps := newMockStepGetter()
steps.steps["step1"] = &wfmodels.WorkflowStep{ID: "step1", WorkflowID: "wf1", Position: 1}
steps.steps["step2"] = &wfmodels.WorkflowStep{
ID: "step2", WorkflowID: "wf1", Position: 2, AgentProfileID: "profile-b",
}
taskRepo := newMockTaskRepo()
taskRepo.tasks["t1"] = &v1.Task{
ID: "t1", WorkspaceID: "ws1", WorkflowID: "wf1", Title: "Test", State: v1.TaskStateInProgress,
}
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
log := testLogger()
exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{})
exec.SetGitHubCredentialBroker(fakeSwitchSessionCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve")
svc := &Service{
logger: log, repo: repo, workflowStepGetter: steps, taskRepo: taskRepo, agentManager: agentMgr,
messageQueue: messagequeue.NewServiceMemory(log), executor: exec,
workflowStore: newWorkflowStore(repo, steps, agentMgr, noopPublisher, log),
}
applied := svc.applyEngineTransition(ctx, "t1", session, engine.HandleResult{
Transitioned: true, FromStepID: "step1", ToStepID: "step2",
}, engine.TriggerOnTurnStart, "", false)
if applied {
t.Fatal("applyEngineTransition() = true, want credential admission rejection")
}
storedTask, err := repo.GetTask(ctx, "t1")
if err != nil {
t.Fatalf("get task: %v", err)
}
if storedTask.WorkflowStepID != "step1" {
t.Fatalf("workflow step = %q, want source step1", storedTask.WorkflowStepID)
}
storedSession, err := repo.GetTaskSession(ctx, "s1")
if err != nil {
t.Fatalf("get session: %v", err)
}
if storedSession.State != models.TaskSessionStateRunning || !storedSession.IsPrimary {
t.Fatalf("session after rejection = %#v, want running primary source session", storedSession)
}
}
func TestSwitchSessionForStepUsesReusableSessionExecutorProfileForCredentialAdmission(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
now := time.Now().UTC()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("create workflow: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "t1", WorkspaceID: "ws1", WorkflowID: "wf1", WorkflowStepID: "step1",
Title: "Test", State: v1.TaskStateInProgress, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("create task: %v", err)
}
if err := repo.CreateRepository(ctx, &models.Repository{
ID: "repo1", WorkspaceID: "ws1", Name: "widgets", Provider: "acme-forge",
RemoteURL: "https://forge.example/acme/widgets.git",
}); err != nil {
t.Fatalf("create repository: %v", err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{
ID: "taskrepo1", TaskID: "t1", RepositoryID: "repo1",
}); err != nil {
t.Fatalf("create task repository: %v", err)
}
if err := repo.CreateExecutor(ctx, &models.Executor{
ID: "exec-ssh", Name: "SSH", Type: models.ExecutorTypeSSH, Status: models.ExecutorStatusActive,
}); err != nil {
t.Fatalf("create executor: %v", err)
}
if err := repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{
ID: "ep-token", ExecutorID: "exec-ssh", Name: "Token profile",
Config: map[string]string{"remote_auth_secrets": `{"gh_cli_env":"secret-gh"}`},
}); err != nil {
t.Fatalf("create executor profile: %v", err)
}
current := &models.TaskSession{
ID: "s-current", TaskID: "t1", AgentProfileID: "profile-a", State: models.TaskSessionStateRunning,
IsPrimary: true, StartedAt: now, UpdatedAt: now,
}
target := &models.TaskSession{
ID: "s-target", TaskID: "t1", AgentProfileID: "profile-b", ExecutorID: "exec-ssh",
ExecutorProfileID: "ep-token", State: models.TaskSessionStateCompleted,
StartedAt: now.Add(-time.Minute), UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, current); err != nil {
t.Fatalf("create current session: %v", err)
}
if err := repo.CreateTaskSession(ctx, target); err != nil {
t.Fatalf("create target session: %v", err)
}
taskRepo := newMockTaskRepo()
taskRepo.tasks["t1"] = &v1.Task{ID: "t1", WorkspaceID: "ws1", WorkflowID: "wf1", State: v1.TaskStateInProgress}
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
log := testLogger()
exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{})
exec.SetGitHubCredentialBroker(fakeSwitchSessionCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve")
svc := &Service{
logger: log, repo: repo, taskRepo: taskRepo, agentManager: agentMgr,
messageQueue: messagequeue.NewServiceMemory(log), executor: exec,
}
got, err := svc.switchSessionForStep(ctx, "t1", current, "profile-b")
if err != nil {
t.Fatalf("switchSessionForStep() error = %v, want reusable profile token override", err)
}
if got.ID != target.ID {
t.Fatalf("session = %q, want reusable target %q", got.ID, target.ID)
}
}
@@ -16,7 +16,6 @@ import (
"github.com/kandev/kandev/internal/gitconfigenv"
"github.com/kandev/kandev/internal/gitcredentials"
"github.com/kandev/kandev/internal/githubauth"
"github.com/kandev/kandev/internal/repoclone"
"github.com/kandev/kandev/internal/task/models"
)
@@ -236,12 +235,104 @@ func (e *Executor) resolveManagedGitHubCredentials(
return false, nil
}
// PreflightManagedGitCredentials validates every task repository binding that
// would use a managed Git credential lease, without cloning or issuing one.
// It is a read-only check meant to run before a new session row is persisted
// (PrepareSession) or before a workflow profile switch tears down the
// current session (see switchSessionForStep), so an irreparable repository
// identity - a foreign/ambiguous provider host, a malformed clone URL -
// rejects the operation before anything is mutated, rather than surfacing as
// a doomed session created later at launch time.
func (e *Executor) PreflightManagedGitCredentials(
ctx context.Context,
workspaceID, taskID, executorID, executorProfileID string,
) error {
metadata := make(map[string]interface{})
if executorProfileID != "" {
metadata["executor_profile_id"] = executorProfileID
}
execConfig := e.resolveExecutorConfig(ctx, executorID, workspaceID, metadata)
return e.preflightManagedGitCredentials(ctx, workspaceID, taskID, execConfig)
}
func (e *Executor) preflightManagedGitCredentials(
ctx context.Context,
workspaceID, taskID string,
execConfig executorConfig,
) error {
if e.gitCredentialIssuer == nil {
// No broker configured: the managed-credential feature is inactive, so
// nothing here will ever attempt to resolve a repository's identity.
return nil
}
policy := TaskGitCredentialPolicy{Mode: taskGitCredentialsModeManaged}
if e.githubCredentialPolicyResolver != nil {
resolved, err := e.githubCredentialPolicyResolver.ResolveTaskGitCredentialPolicy(ctx, workspaceID)
if err != nil {
return fmt.Errorf("resolve task Git credential policy: %w", err)
}
policy = resolved
}
if policy.Mode == taskGitCredentialsModeExecutor {
return nil
}
if executorProfileHasGitHubToken(execConfig) {
return nil
}
taskRepos, err := e.repo.ListTaskRepositories(ctx, taskID)
if err != nil {
return fmt.Errorf("list task repositories: %w", err)
}
for _, taskRepo := range taskRepos {
if taskRepo == nil || taskRepo.RepositoryID == "" {
continue
}
repository, err := e.repo.GetRepository(ctx, taskRepo.RepositoryID)
if err != nil {
return fmt.Errorf("load repository %q: %w", taskRepo.RepositoryID, err)
}
if repository == nil || managedGitCredentialProvider(repository, true, nil) == "" {
continue
}
if _, _, _, _, err := gitCredentialCloneIdentity(repository, repository.ID); err != nil {
return err
}
}
return nil
}
func executorProfileHasGitHubToken(execConfig executorConfig) bool {
if !executorNeedsResolvedCredentials(execConfig.ExecutorType) {
return false
}
authSecretsJSON, _ := execConfig.Metadata[profileKeyRemoteAuthSecrets].(string)
if authSecretsJSON == "" {
return false
}
var authSecrets map[string]string
if err := json.Unmarshal([]byte(authSecretsJSON), &authSecrets); err != nil {
return false
}
return strings.TrimSpace(authSecrets["gh_cli_env"]) != ""
}
func (e *Executor) issueGitCredentialScopes(
ctx context.Context,
req *LaunchAgentRequest,
infos []*repoInfo,
githubManaged bool,
) ([]githubCredentialScope, map[string]struct{}, error) {
// Validate every managed repository before issuing the first lease. This avoids
// leaving a partial lease set when a later task binding is invalid.
for _, info := range infos {
if info == nil || info.Repository == nil || managedGitCredentialProvider(info.Repository, githubManaged, req.Env) == "" {
continue
}
if _, _, _, _, err := gitCredentialCloneIdentity(info.Repository, info.RepositoryID); err != nil {
return nil, nil, err
}
}
scopes := make([]githubCredentialScope, 0, len(infos)*2)
helpers := make(map[string]struct{}, len(infos))
for _, info := range infos {
@@ -572,58 +663,18 @@ func managedGitCredentialProvider(repository *models.Repository, githubManaged b
return providerID
}
// credentialIdentityCloneURL returns the HTTPS clone URL a managed credential
// scope is derived from. A repository cloned over SSH stores an SSH remote URL;
// that is a transport choice, not a different repository, so it does not fail
// the launch. The persisted provider identity wins, because its host carries
// the real HTTPS origin including any non-default port that an SSH URL cannot
// express; rewriting the remote in place is the fallback when there is none.
func credentialIdentityCloneURL(repository *models.Repository) string {
cloneURL := repositoryCloneURL(repository)
if strings.HasPrefix(strings.ToLower(cloneURL), httpsScheme+"://") {
return cloneURL
}
if derived := providerHTTPSCloneURL(repository); derived != "" {
return derived
}
if converted := repoclone.CanonicalHTTPSCloneURL(cloneURL); converted != "" {
return converted
}
return cloneURL
}
func gitCredentialCloneIdentity(repository *models.Repository, repositoryID string) (string, string, string, string, error) {
cloneURL := credentialIdentityCloneURL(repository)
if cloneURL == "" {
return "", "", "", "", fmt.Errorf("repository %q has no HTTPS clone URL for managed credentials", repositoryID)
if repository == nil {
return "", "", "", "", fmt.Errorf("repository %q is required", repositoryID)
}
parsed, err := url.Parse(cloneURL)
if err != nil || parsed.Scheme != httpsScheme || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" ||
parsed.Fragment != "" || parsed.Path == "" {
return "", "", "", "", fmt.Errorf("repository %q must use an HTTPS clone URL for managed credentials", repositoryID)
identity, err := gitcredentials.ResolveRepositoryIdentity(gitcredentials.RepositoryIdentityInput{
RepositoryID: repositoryID, Provider: repository.Provider, ProviderHost: repository.ProviderHost,
ProviderOwner: repository.ProviderOwner, ProviderName: repository.ProviderName, RemoteURL: repository.RemoteURL,
})
if err != nil {
return "", "", "", "", err
}
providerHost := strings.TrimSpace(repository.ProviderHost)
providerID := strings.ToLower(strings.TrimSpace(repository.Provider))
if providerHost == "" && (providerID == "" || providerID == gitHubProviderID) {
providerHost = "https://" + defaultGitHubHost
}
if err := repoclone.ValidateHTTPSCloneOrigin(cloneURL, providerHost); err != nil {
return "", "", "", "", fmt.Errorf("repository %q provider origin: %w", repositoryID, err)
}
owner, repo, err := credentialHelperPath(parsed.Path)
if err != nil || repositoryID == "" {
return "", "", "", "", fmt.Errorf("repository %q has an invalid HTTPS clone path", repositoryID)
}
return parsed.Host, parsed.Path, owner, repo, nil
}
func credentialHelperPath(path string) (string, string, error) {
trimmed := strings.TrimSuffix(strings.Trim(path, "/"), ".git")
owner, repo, found := strings.Cut(trimmed, "/")
if !found || owner == "" || repo == "" {
return "", "", errors.New("repository path needs owner and name")
}
return owner, repo, nil
return identity.Host, identity.Path, identity.Owner, identity.Repository, nil
}
func validateGitHubCredentialBrokerURL(raw, executorType string) error {
@@ -0,0 +1,259 @@
package executor
import (
"context"
"errors"
"os/exec"
"strings"
"testing"
"github.com/kandev/kandev/internal/gitcredentials"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
func newPreflightTestExecutor(t *testing.T, repo *mockRepository) *Executor {
t.Helper()
issuer := &fakeGitHubCredentialLeaseIssuer{lease: gitcredentials.Lease{Token: "opaque-lease"}}
exec := newTestExecutor(t, &mockAgentManager{}, repo)
exec.SetGitHubCredentialBroker(issuer, "https://kandev.example/api/v1/github/credentials/resolve")
return exec
}
func seedPreflightTaskRepository(repo *mockRepository, taskID, repositoryID string, repository *models.Repository) {
repo.taskRepositories[taskID+"/"+repositoryID] = &models.TaskRepository{
ID: taskID + "/" + repositoryID, TaskID: taskID, RepositoryID: repositoryID,
}
repo.repositories[repositoryID] = repository
}
// TestPreflightManagedGitCredentialsAcceptsValidLegacyAndPluginGitHubRows
// covers AC-1/AC-2: legacy rows with either GitHub HTTPS/SSH spellings and an
// empty or plugin-specific provider must pass the preflight cleanly.
func TestPreflightManagedGitCredentialsAcceptsValidLegacyAndPluginGitHubRows(t *testing.T) {
for name, remoteURL := range map[string]string{
"empty provider, ssh remote": "git@github.com:acme/widgets.git",
"empty provider, https remote": "https://github.com/acme/widgets.git",
} {
t.Run(name, func(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-1", &models.Repository{
ID: "repo-1", SourceType: sourceTypeLocal, RemoteURL: remoteURL,
})
exec := newPreflightTestExecutor(t, repo)
if err := exec.PreflightManagedGitCredentials(context.Background(), "workspace-1", "task-1", "", ""); err != nil {
t.Fatalf("PreflightManagedGitCredentials() error = %v", err)
}
})
}
t.Run("plugin provider, ssh remote", func(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-1", &models.Repository{
ID: "repo-1", SourceType: sourceTypeLocal, Provider: "kandev-plugin-tags",
RemoteURL: "ssh://git@github.com/acme/widgets.git",
})
exec := newPreflightTestExecutor(t, repo)
if err := exec.PreflightManagedGitCredentials(context.Background(), "workspace-1", "task-1", "", ""); err != nil {
t.Fatalf("PreflightManagedGitCredentials() error = %v", err)
}
})
}
// TestPreflightManagedGitCredentialsRejectsIrreparableIdentity covers AC-9: a
// repository whose remote host conflicts with an untrusted/custom provider
// (no explicit provider host) must fail the preflight with a safe,
// repository-scoped error, before any session row is ever persisted.
func TestPreflightManagedGitCredentialsRejectsIrreparableIdentity(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-1", &models.Repository{
ID: "repo-1", SourceType: sourceTypeLocal, Provider: "acme-forge",
RemoteURL: "https://forge.example/acme/widgets.git",
})
exec := newPreflightTestExecutor(t, repo)
err := exec.PreflightManagedGitCredentials(context.Background(), "workspace-1", "task-1", "", "")
if err == nil {
t.Fatal("PreflightManagedGitCredentials() error = nil, want rejection of an untrusted custom host")
}
if !strings.Contains(err.Error(), "repo-1") {
t.Fatalf("error %q does not name the safe repository id repo-1", err.Error())
}
}
func TestPreflightManagedGitCredentialsDoesNotAuthorizeFromLocalCheckoutOrigin(t *testing.T) {
localPath := t.TempDir()
if output, err := exec.Command("git", "init", localPath).CombinedOutput(); err != nil {
t.Fatalf("git init error = %v: %s", err, output)
}
if output, err := exec.Command(
"git", "-C", localPath, "remote", "add", "origin", "https://github.com/acme/widgets.git",
).CombinedOutput(); err != nil {
t.Fatalf("git remote add error = %v: %s", err, output)
}
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-1", &models.Repository{
ID: "repo-1", SourceType: sourceTypeLocal, Provider: "github", LocalPath: localPath,
})
exec := newPreflightTestExecutor(t, repo)
err := exec.PreflightManagedGitCredentials(context.Background(), "workspace-1", "task-1", "", "")
if err == nil {
t.Fatal("PreflightManagedGitCredentials() error = nil, want incomplete persisted identity rejection")
}
}
// TestPreflightManagedGitCredentialsValidatesEveryBindingInOrder covers the
// multi-repository requirement: a valid primary binding alongside an
// irreparable secondary binding must still fail, so a multi-repo task cannot
// slip one bad repository past the check.
func TestPreflightManagedGitCredentialsValidatesEveryBindingInOrder(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-primary", &models.Repository{
ID: "repo-primary", SourceType: sourceTypeLocal, RemoteURL: "https://github.com/acme/widgets.git",
})
seedPreflightTaskRepository(repo, "task-1", "repo-secondary", &models.Repository{
ID: "repo-secondary", SourceType: sourceTypeLocal, Provider: "acme-forge",
RemoteURL: "git@forge.example:acme/widgets2.git",
})
exec := newPreflightTestExecutor(t, repo)
err := exec.PreflightManagedGitCredentials(context.Background(), "workspace-1", "task-1", "", "")
if err == nil {
t.Fatal("PreflightManagedGitCredentials() error = nil, want rejection for the irreparable secondary binding")
}
if !strings.Contains(err.Error(), "repo-secondary") {
t.Fatalf("error %q does not name the failing secondary repository id", err.Error())
}
}
// TestPreflightManagedGitCredentialsSkipsWhenExecutorModePolicy covers the
// workspace policy applicability rule: when the resolved policy routes Git
// credentials through the executor rather than the managed broker, no
// repository identity is validated at all - matching
// configureGitCredentialBrokerForRepositories' own unconditional skip.
func TestPreflightManagedGitCredentialsSkipsWhenExecutorModePolicy(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-1", &models.Repository{
ID: "repo-1", SourceType: sourceTypeLocal, Provider: "acme-forge",
RemoteURL: "https://forge.example/acme/widgets.git",
})
exec := newPreflightTestExecutor(t, repo)
exec.SetTaskGitCredentialPolicyResolver(fakeTaskGitCredentialPolicyResolver{
policy: TaskGitCredentialPolicy{Mode: taskGitCredentialsModeExecutor},
})
if err := exec.PreflightManagedGitCredentials(context.Background(), "workspace-1", "task-1", "", ""); err != nil {
t.Fatalf("PreflightManagedGitCredentials() error = %v, want skip under executor-mode policy", err)
}
}
func TestPrepareSessionSkipsManagedIdentityForRemoteProfileGitHubToken(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-1", &models.Repository{
ID: "repo-1", SourceType: sourceTypeLocal, Provider: "acme-forge",
RemoteURL: "https://forge.example/acme/widgets.git",
})
repo.executors["exec-ssh"] = &models.Executor{ID: "exec-ssh", Type: models.ExecutorTypeSSH}
repo.executorProfiles["profile-token"] = &models.ExecutorProfile{
ID: "profile-token", ExecutorID: "exec-ssh",
Config: map[string]string{profileKeyRemoteAuthSecrets: `{"gh_cli_env":"secret-gh"}`},
}
exec := newPreflightTestExecutor(t, repo)
task := &v1.Task{ID: "task-1", WorkspaceID: "workspace-1"}
if _, err := exec.PrepareSession(
context.Background(), task, "agent-profile", "exec-ssh", "profile-token", "",
); err != nil {
t.Fatalf("PrepareSession() error = %v, want profile token override", err)
}
if len(repo.createTaskSessionCalls) != 1 {
t.Fatalf("CreateTaskSession calls = %d, want 1", len(repo.createTaskSessionCalls))
}
}
// TestPreflightManagedGitCredentialsNoopWithoutBroker covers the feature-off
// case: with no credential broker configured, the preflight must not fail a
// launch that never would have attempted managed credential issuance anyway.
func TestPreflightManagedGitCredentialsNoopWithoutBroker(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-1", &models.Repository{ID: "repo-1", SourceType: sourceTypeLocal})
exec := newTestExecutor(t, &mockAgentManager{}, repo)
if err := exec.PreflightManagedGitCredentials(context.Background(), "workspace-1", "task-1", "", ""); err != nil {
t.Fatalf("PreflightManagedGitCredentials() error = %v, want nil with no broker configured", err)
}
}
// TestPreflightManagedGitCredentialsIsNonMutating asserts the preflight never
// touches the repository store: it must not create a session, issue a
// lease, or write anything, even when it rejects a binding.
func TestPreflightManagedGitCredentialsIsNonMutating(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-1", "repo-1", &models.Repository{
ID: "repo-1", SourceType: sourceTypeLocal, Provider: "acme-forge",
RemoteURL: "https://forge.example/acme/widgets.git",
})
exec := newPreflightTestExecutor(t, repo)
issuer := exec.gitCredentialIssuer.(*fakeGitHubCredentialLeaseIssuer)
if err := exec.PreflightManagedGitCredentials(context.Background(), "workspace-1", "task-1", "", ""); err == nil {
t.Fatal("expected rejection")
}
if issuer.calls != 0 {
t.Fatalf("Issue calls = %d, want 0 (preflight must never issue a lease)", issuer.calls)
}
if len(repo.createTaskSessionCalls) != 0 {
t.Fatalf("CreateTaskSession calls = %d, want 0 (preflight must never persist a session)", len(repo.createTaskSessionCalls))
}
}
// TestPrepareSessionRejectsIrreparableManagedCredentialRepositoryBeforePersisting
// covers AC-9 end to end at the PrepareSession seam: a task bound to a
// repository with an irreparable managed-credential identity must fail
// before a session row exists at all - not create one that only fails later
// when LaunchPreparedSession attempts credential issuance.
func TestPrepareSessionRejectsIrreparableManagedCredentialRepositoryBeforePersisting(t *testing.T) {
repo := newMockRepository()
repo.taskRepositories["task-repo-1"] = &models.TaskRepository{
ID: "task-repo-1", TaskID: "task-123", RepositoryID: "repo-123",
}
repo.repositories["repo-123"] = &models.Repository{
ID: "repo-123", SourceType: sourceTypeLocal, Provider: "acme-forge",
RemoteURL: "https://forge.example/acme/widgets.git",
}
exec := newPreflightTestExecutor(t, repo)
task := &v1.Task{ID: "task-123", WorkspaceID: "workspace-123"}
_, err := exec.PrepareSession(context.Background(), task, "profile-123", "", "", "")
if err == nil {
t.Fatal("PrepareSession() error = nil, want rejection of the irreparable repository binding")
}
if !strings.Contains(err.Error(), "repo-123") {
t.Fatalf("error %q does not name the safe repository id repo-123", err.Error())
}
if len(repo.createTaskSessionCalls) != 0 {
t.Fatalf("CreateTaskSession calls = %d, want 0 (no session row should be persisted)", len(repo.createTaskSessionCalls))
}
}
func TestPrepareSessionReturnsCredentialPolicyErrorBeforePersisting(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-123", "repo-123", &models.Repository{
ID: "repo-123", Provider: "github", RemoteURL: "https://github.com/acme/widgets.git",
})
exec := newPreflightTestExecutor(t, repo)
exec.SetTaskGitCredentialPolicyResolver(fakeTaskGitCredentialPolicyResolver{err: errors.New("policy unavailable")})
task := &v1.Task{ID: "task-123", WorkspaceID: "workspace-123"}
_, err := exec.PrepareSession(context.Background(), task, "profile-123", "", "", "")
if err == nil || !strings.Contains(err.Error(), "policy unavailable") {
t.Fatalf("PrepareSession() error = %v, want credential policy error", err)
}
if len(repo.createTaskSessionCalls) != 0 {
t.Fatalf("CreateTaskSession calls = %d, want 0", len(repo.createTaskSessionCalls))
}
}
@@ -850,6 +850,16 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
session.ExecutorID = execConfig.ExecutorID
}
// Validate every managed-credential repository binding before persisting
// the session row. Doing this after the row exists would leave a
// zero-message session behind once launch fails at credential issuance.
if err := e.preflightManagedGitCredentials(ctx, task.WorkspaceID, task.ID, execConfig); err != nil {
e.logger.Error("managed Git credential preflight failed",
zap.String("task_id", task.ID),
zap.Error(err))
return "", err
}
var createErr error
if atomicCreator, ok := e.repo.(initialRuntimeSeedTaskSessionCreator); ok {
createErr = atomicCreator.CreateTaskSessionWithInitialRuntimeSeed(ctx, session)
@@ -58,6 +58,11 @@ func (e *Executor) EnsureSessionForAgentWithCreation(
if agentProfileID == "" {
return nil, false, ErrNoAgentProfileID
}
if err := e.PreflightManagedGitCredentials(
ctx, task.WorkspaceID, task.ID, executorID, executorProfileID,
); err != nil {
return nil, false, err
}
existing, err := e.repo.GetTaskSessionByTaskAndAgent(ctx, task.ID, agentInstanceID)
if err != nil {
@@ -77,6 +77,57 @@ func TestEnsureSessionForAgent_CreatesWhenMissing(t *testing.T) {
}
}
func TestEnsureSessionForAgentRejectsManagedIdentityBeforeCreatingSession(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-office", "repo-1", &models.Repository{
ID: "repo-1", Provider: "acme-forge", RemoteURL: "https://forge.example/acme/widgets.git",
})
exec := newPreflightTestExecutor(t, repo)
got, err := exec.EnsureSessionForAgent(
context.Background(), officeTestTask(), "agent-1", "profile-1", "", "",
)
if err == nil {
t.Fatal("EnsureSessionForAgent() error = nil, want managed identity rejection")
}
if got != nil {
t.Fatalf("session = %#v, want nil", got)
}
if len(repo.createTaskSessionCalls) != 0 {
t.Fatalf("CreateTaskSession calls = %d, want 0", len(repo.createTaskSessionCalls))
}
}
func TestEnsureSessionForAgentRejectsManagedIdentityBeforeRebindingIdleSession(t *testing.T) {
repo := newMockRepository()
seedPreflightTaskRepository(repo, "task-office", "repo-1", &models.Repository{
ID: "repo-1", Provider: "acme-forge", RemoteURL: "https://forge.example/acme/widgets.git",
})
existing := &models.TaskSession{
ID: "sess-existing", TaskID: "task-office", AgentProfileID: "agent-1",
ExecutionProfileID: "profile-old", State: models.TaskSessionStateIdle, StartedAt: time.Now().UTC(),
}
repo.sessions[existing.ID] = existing
exec := newPreflightTestExecutor(t, repo)
got, err := exec.EnsureSessionForAgent(
context.Background(), officeTestTask(), "agent-1", "profile-new", "", "",
)
if err == nil {
t.Fatal("EnsureSessionForAgent() error = nil, want managed identity rejection")
}
if got != nil {
t.Fatalf("session = %#v, want nil", got)
}
stored := repo.sessions[existing.ID]
if stored.State != models.TaskSessionStateIdle {
t.Fatalf("session state = %q, want IDLE", stored.State)
}
if stored.ExecutionProfileID != "profile-old" {
t.Fatalf("execution profile = %q, want profile-old", stored.ExecutionProfileID)
}
}
func TestEnsureSessionForAgent_DoesNotMarkExistingTaskSessionAsOrigin(t *testing.T) {
repo := newMockRepository()
repo.sessions["sess-existing"] = &models.TaskSession{
@@ -0,0 +1,45 @@
package sqlite
import (
"context"
"path/filepath"
"testing"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/task/models"
)
func TestBaseMigrationsDoNotInferProviderHostFromPartialRepositoryIdentity(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "repository-provider-identity.db")
dbConn, err := db.OpenSQLite(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
repo, err := NewWithDB(sqlxDB, sqlxDB, nil)
if err != nil {
t.Fatalf("new repository: %v", err)
}
seedWorkspace(t, repo, "ws-provider-identity")
repository := &models.Repository{
ID: "repo-plugin", WorkspaceID: "ws-provider-identity", Name: "widgets", SourceType: "local",
Provider: "kandev-plugin-tags", RemoteURL: "https://github.com/acme/widgets.git",
}
if err := repo.CreateRepository(context.Background(), repository); err != nil {
t.Fatalf("create repository: %v", err)
}
if err := repo.runMigrations(); err != nil {
t.Fatalf("run migrations: %v", err)
}
stored, err := repo.GetRepository(context.Background(), repository.ID)
if err != nil {
t.Fatalf("get repository: %v", err)
}
if stored.ProviderHost != "" {
t.Fatalf("provider_host = %q, want no inferred persistent origin", stored.ProviderHost)
}
}
@@ -0,0 +1,140 @@
---
spec: docs/specs/integrations/github-authentication.md
created: 2026-08-18
status: complete
---
# Fix Plan: Make Managed Git Credential Admission Atomic
## Overview
PR #2787 adds a shared repository identity resolver and checks managed Git credential identity
before selected session changes. The direction fits the provider-neutral broker architecture, but
four gaps can still authorize different identities or mutate task state before a later rejection.
The repair keeps one provider-neutral resolver. It makes persisted repository metadata the only
authorization source, validates secret-bearing SSH syntax before normalization, and runs one
read-only admission gate before all normal, Office, and workflow session mutations. It also removes
the startup migration that infers persistent provider identity from an incomplete legacy tuple.
## Confirmed Root Causes
### SSH syntax is normalized before it is validated
`gitcredentials.parseRemote` first calls `repoclone.CanonicalHTTPSCloneURL`. That conversion drops
an `ssh://` password, query, or fragment before the resolver checks the canonical result. SCP-style
input can also retain a query or fragment in the path. The resolver therefore accepts remote text
that violates its credential-free input contract.
### Issuance and redemption use different identity sources
Executor preflight calls `gitCredentialCloneIdentity`, which can inspect `repository.LocalPath` and
read the checkout's Git origin. Broker authorization uses persisted `RemoteURL`, `ProviderHost`,
`Owner`, and `Name`. A repository with only a usable local origin can pass preflight and then fail
when the broker issues a credential.
### Admission is incomplete and occurs after some state changes
`PreflightManagedGitCredentials` ignores a task-policy resolution error and cannot determine that
an explicit executor-profile token overrides the managed broker. Office session creation and reuse
bypass the normal `PrepareSession` gate. A workflow transition persists the destination step before
the later profile-switch preflight runs. These paths can reject the launch after a session or the
workflow state has changed.
### The migration infers a semantic identity from partial metadata
`repository_credential_origin_migration.go` writes `provider_host` when it sees only a provider and
remote URL. It does not have the full verified provider tuple, and nullable legacy fields can also
abort its scan into Go strings. The accepted repository-origin decision requires existing rows to
remain hostless when the database cannot infer their origin safely. Runtime resolution already
recognizes public `github.com` remotes without this write.
## Repair Architecture
### Shared repository identity
- Validate the original HTTPS, `ssh://`, and SCP-style syntax before any canonical conversion.
- Reject URL userinfo passwords, queries, and fragments with bounded errors that do not echo input.
- Remove the unused `RepositoryIdentity.CanonicalURL` field.
- Make executor preflight pass persisted repository identity fields only. Do not inspect a local
checkout during credential authorization.
- Keep `internal/gitcredentials` provider-neutral. GitHub-specific policy stays in its callers.
### Read-only credential admission
- Make policy-resolution errors fail closed.
- Resolve the selected executor profile before managed identity validation.
- Treat a configured executor-profile `GITHUB_TOKEN` or `GH_TOKEN`, including a secret reference,
as the effective unmanaged source. Admission must not reveal the secret value.
- Put policy, profile-source, and repository checks behind one read-only executor admission method.
- Call that method before normal session preparation changes state and before Office code creates,
rebinds, or resumes a session.
### Workflow transition boundary
- Resolve the destination agent and effective executor profile without changing state.
- Run credential admission before `processOnExit` and before the workflow engine applies the
transition.
- Keep post-transition session switching as the execution step. A failed admission must leave the
source step, current session, and prompt routing unchanged.
### Persistence
- Remove the new repository credential-origin migration and its registration from base migrations.
- Add a regression test that runs startup migrations for incomplete legacy repository metadata and
verifies that `provider_host` remains empty.
- Do not add a replacement backfill. Repository discovery or import remains responsible for
persisting verified provider-origin identity.
## Tests
- Reject `ssh://` passwords, queries, fragments, and SCP-style query or fragment input without
leaking the supplied remote in the error.
- Reject a provider-backed repository that has only a local checkout origin in both executor
preflight and broker authorization.
- Return task-policy resolver errors before session mutation.
- Allow an invalid managed identity when an explicit executor-profile GitHub token is the selected
source.
- Prove that fresh and reused Office sessions remain unchanged after failed admission.
- Prove that a failed destination-profile admission leaves the workflow on its source step and
does not route the destination prompt.
- Prove that startup migration does not infer `provider_host` from partial legacy metadata.
## Documentation
- Update the durable GitHub authentication specification with the atomic admission, persisted
identity, SSH validation, and no-backfill rules.
- No public documentation or frontend copy changes are required. The repair makes failure behavior
match the existing task Git access contract.
## Implementation Tasks
- [x] [task-01-harden-repository-identity](task-01-harden-repository-identity.md) - done
- [x] [task-02-centralize-session-admission](task-02-centralize-session-admission.md) - done
- [x] [task-03-remove-unsafe-origin-backfill](task-03-remove-unsafe-origin-backfill.md) - done
Execution is sequential in the primary conversation. Task 02 depends on the corrected identity
contract from Task 01. Task 03 then restores the persistence boundary and locks it with a startup
regression test.
## Risks And Out Of Scope
- Existing database rows that already have a verified `provider_host` remain unchanged.
- This repair does not discover provider identity from arbitrary local Git configuration.
- This repair does not change the workspace policy model, broker lease scope, or plugin API.
- Profile-token admission checks only whether the selected profile configures the override. It must
not resolve or log the token during the read-only gate.
- Workflow admission must use the same profile that the later switch will use. A duplicated profile
selection rule would recreate the time-of-check/time-of-use gap.
## Recorded Results
- The resolver now rejects secret-bearing SSH forms before canonicalization and returns only safe,
repository-scoped errors.
- Executor issuance and broker redemption use the same persisted repository fields.
- Policy, remote profile token, normal session, Office session, and workflow transition admission
now complete before the related state mutation.
- The repository credential-origin backfill was removed. Startup migration keeps partial legacy
provider identity unchanged.
- Targeted verification passed 25 resolver tests, 11 identity/admission tests, 41 executor
lifecycle tests, 13 race-enabled workflow transition tests, and 598 SQLite repository tests.
@@ -0,0 +1,80 @@
---
id: "01-harden-repository-identity"
title: "Harden repository credential identity"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/integrations/github-authentication.md"
---
# Task 01: Harden Repository Credential Identity
## Acceptance
- The shared resolver rejects HTTPS or SSH credentials, queries, and fragments before
canonicalization can discard them.
- Rejection errors do not include the supplied remote or a contained secret.
- Executor preflight and broker authorization derive identity from the same persisted repository
fields and never use a local checkout origin as authorization data.
- The identity type contains only fields consumed by authorization.
- The package remains provider-neutral and contains no plugin-specific contract.
## TDD Sequence
1. Add table-driven resolver cases for an `ssh://` password, query, and fragment and for SCP-style
query and fragment input. Assert that each error omits the input and marker secret.
2. Run the focused identity test and record RED cases that the current canonicalization accepts.
3. Add an executor/broker parity case for a GitHub provider row with a valid local origin but no
persisted remote or owner/name identity. Record the current preflight/broker disagreement.
4. Validate original remote syntax before conversion, remove the unused canonical URL result, and
make executor preflight use the persisted remote.
5. Run the focused packages and record GREEN results.
## Verification
```bash
cd apps/backend && go test ./internal/gitcredentials -run 'TestResolveRepositoryIdentity' -count=1
cd apps/backend && go test ./internal/orchestrator/executor ./internal/backendapp -run 'Test.*GitCredential.*(Identity|Preflight|Authorization)' -count=1
```
## Files Likely Touched
- `apps/backend/internal/gitcredentials/identity.go`
- `apps/backend/internal/gitcredentials/identity_test.go`
- `apps/backend/internal/orchestrator/executor/executor_credentials.go`
- `apps/backend/internal/orchestrator/executor/executor_credentials_preflight_test.go`
- `apps/backend/internal/backendapp/git_credentials.go`
- focused broker authorization tests under `apps/backend/internal/backendapp/`
- this task file and `plan.md`
## Dependencies
None.
## Parallelism
Sequential. Task 02 consumes the corrected persisted-identity contract.
## Inputs
- The managed identity rules in the GitHub authentication specification.
- `gitcredentials.ResolveRepositoryIdentity`.
- `executor.gitCredentialCloneIdentity`.
- The broker authorizer configured in `backendapp.ConfigureGitCredentialBroker`.
## Output Contract
Report the exact RED failures, the accepted remote forms, the common persisted identity source,
the GREEN commands, files changed, and remaining risks. Update this task and the parent plan with
the recorded results.
## Recorded Results
- RED: five SSH password/query/fragment cases passed unsafe input through canonicalization.
- RED: executor preflight accepted a valid local checkout origin when persisted authorization
identity was incomplete.
- GREEN: `go test ./internal/gitcredentials -run TestResolveRepositoryIdentity -count=1` passed 25
tests.
- GREEN: the focused executor/backend application identity and admission command passed 11 tests.
- `RepositoryIdentity.CanonicalURL` was removed because no authorization caller used it.
@@ -0,0 +1,92 @@
---
id: "02-centralize-session-admission"
title: "Centralize session credential admission"
status: done
wave: 2
depends_on: ["01-harden-repository-identity"]
plan: "plan.md"
spec: "../../specs/integrations/github-authentication.md"
---
# Task 02: Centralize Session Credential Admission
## Acceptance
- A task-policy resolver error stops admission and reaches the caller.
- Admission resolves the effective executor profile and skips broker identity validation when that
profile configures `GITHUB_TOKEN` or `GH_TOKEN`.
- Normal and Office paths run admission before any session create, rebind, resume, or state change.
- Workflow target-profile admission runs before exit hooks and the persisted step transition.
- A failed workflow admission preserves the source step and current session and does not deliver
the destination prompt.
- The admission operation is read-only and does not reveal an executor-profile secret.
## TDD Sequence
1. Add a policy-error case that records session storage calls and proves the current preflight
ignores the error. Add a remote-profile `gh_cli_env` secret-reference case.
2. Add fresh-session and idle-session Office cases. Record RED results for creation, profile
rebind, or state change before a managed identity failure.
3. Extend the workflow profile-preflight test to assert source-step persistence, current-session
ownership, exit-hook behavior, and destination prompt routing. Record the current partial
transition as RED.
4. Introduce one read-only admission boundary that selects policy, profile override, and persisted
repository identity. Call it before normal and Office mutations.
5. Resolve the destination profile and effective executor profile before workflow exit processing.
Run admission, then apply the transition and switch the session only after success.
6. Run the focused executor and orchestrator tests and record GREEN results.
## Verification
```bash
cd apps/backend && go test ./internal/orchestrator/executor -run 'Test.*(PreflightManagedGitCredentials|PrepareSession|EnsureSessionForAgent)' -count=1
cd apps/backend && go test ./internal/orchestrator -run 'Test.*Workflow.*(Profile|Credential|Preflight|Transition)' -count=1
```
## Files Likely Touched
- `apps/backend/internal/orchestrator/executor/executor_credentials.go`
- `apps/backend/internal/orchestrator/executor/executor_execute.go`
- `apps/backend/internal/orchestrator/executor/executor_office.go`
- `apps/backend/internal/orchestrator/executor/executor_credentials_preflight_test.go`
- focused Office executor tests under `apps/backend/internal/orchestrator/executor/`
- `apps/backend/internal/orchestrator/event_handlers_workflow.go`
- `apps/backend/internal/orchestrator/event_handlers_workflow_profile_preflight_test.go`
- this task file and `plan.md`
## Dependencies
- Task 01 must establish the common persisted repository identity contract.
## Parallelism
Sequential. The session and workflow paths must use the same admission operation and effective
profile selection.
## Inputs
- The atomic admission scenarios in the GitHub authentication specification.
- `executor.PreflightManagedGitCredentials` and executor profile environment metadata.
- `PrepareSession` and `EnsureSessionForAgentWithCreation`.
- The workflow transition and `switchSessionForStep` paths.
## Output Contract
Report each RED mutation, the final admission call sites, how profile overrides are detected
without secret access, the GREEN commands, files changed, and remaining risks. Update this task and
the parent plan with the recorded results.
## Recorded Results
- RED: a policy resolver error returned success and persisted a session.
- RED: a configured remote-profile GitHub token still failed managed repository identity
admission.
- RED: fresh and idle Office paths created, rebound, or resumed a session before rejection.
- RED: workflow rejection occurred after the destination step was stored and changed the source
session state.
- RED: a reusable destination session with a token profile was checked with the source session's
executor profile.
- GREEN: the focused executor lifecycle command passed 41 tests.
- GREEN: the race-enabled workflow transition command passed 13 tests.
- Admission detects the selected profile's non-secret `gh_cli_env` binding metadata. It does not
reveal the referenced token.
@@ -0,0 +1,79 @@
---
id: "03-remove-unsafe-origin-backfill"
title: "Remove unsafe origin backfill"
status: done
wave: 3
depends_on: ["02-centralize-session-admission"]
plan: "plan.md"
spec: "../../specs/integrations/github-authentication.md"
---
# Task 03: Remove Unsafe Origin Backfill
## Acceptance
- Startup migrations do not infer or persist `provider_host` from only a provider and remote URL.
- Incomplete legacy rows, including rows with nullable identity fields, do not stop startup schema
replay.
- Runtime identity resolution can still infer public `github.com` in memory from a safe persisted
remote.
- No replacement migration or duplicate provider-specific inference path is added.
## TDD Sequence
1. Add a startup migration regression with incomplete legacy provider metadata and nullable fields.
Assert that startup succeeds and `provider_host` remains empty.
2. Run the focused SQLite repository test and record RED because the PR migration writes the host
or fails while it scans a nullable value.
3. Remove the credential-origin migration registration, implementation, and migration-specific
tests. Keep the startup regression at the base migration boundary.
4. Run the SQLite repository package test and record GREEN.
5. Confirm that the specification and accepted provider-origin decision describe the same
persistence boundary.
## Verification
```bash
cd apps/backend && go test ./internal/task/repository/sqlite -run 'Test.*Migration.*ProviderHost' -count=1
cd apps/backend && go test ./internal/task/repository/sqlite -count=1
```
## Files Likely Touched
- `apps/backend/internal/task/repository/sqlite/base_migrations.go`
- `apps/backend/internal/task/repository/sqlite/repository_credential_origin_migration.go`
- `apps/backend/internal/task/repository/sqlite/repository_credential_origin_migration_test.go`
- `apps/backend/internal/task/repository/sqlite/repository_credential_origin_migration_postgres_test.go`
- a focused base migration regression test under
`apps/backend/internal/task/repository/sqlite/`
- this task file and `plan.md`
## Dependencies
- Task 02 must make runtime admission safe without relying on a persistent backfill.
## Parallelism
Sequential. This final task removes the temporary persistence mechanism after the runtime contract
is complete.
## Inputs
- ADR `docs/decisions/2026-07-20-repository-provider-origin-identity.md`.
- The runtime public-host inference in `gitcredentials.ResolveRepositoryIdentity`.
- `baseMigrations` and startup migration replay tests.
## Output Contract
Report the RED persistence change or scan failure, deleted migration files, retained startup
regression, GREEN commands, files changed, and remaining risks. Update this task and the parent plan
with the recorded results.
## Recorded Results
- RED: startup migration changed an empty plugin repository `provider_host` to public GitHub from
only its provider label and remote URL.
- The new credential-origin migration, registration, and provider-specific tests were removed.
- A base migration regression now preserves the empty provider host for partial legacy identity.
- GREEN: the focused regression passed once.
- GREEN: `go test ./internal/task/repository/sqlite -count=1` passed 598 tests.
@@ -401,9 +401,19 @@ post-signature processing failures produce `failing`; a later valid successful d
- `executor -> executor-visible credentials`, regardless of PAT/CLI/App automation method.
- Initial launch and resume run the same resolution. A successful operation replaces the session
snapshot; a failed operation leaves the previous snapshot unchanged.
- Managed admission resolves the task policy and the selected executor profile before it validates
repository identity. An explicit profile `GITHUB_TOKEN` or `GH_TOKEN` bypasses managed broker
admission because the profile token is the effective source.
- Managed admission completes before Kandev creates, rebinds, resumes, or changes a session, and
before a workflow transition changes the current step. A policy error or an invalid repository
identity leaves the task, workflow, and session state unchanged.
- Each attached repository is resolved once for an individual launch or resume. The resolved
repository set is shared by task configuration and credential-snapshot construction rather than
triggering repeated materialization or origin mutation.
- Broker issuance and redemption use the same persisted repository identity fields. A local Git
checkout and its `origin` are materialization data, not authorization data.
- A public `github.com` remote can supply an in-memory origin when persisted provider-host metadata
is absent. Kandev does not rewrite repository provider identity from partial legacy metadata.
- Changing the workspace policy affects new launches and the next resume, not an already-running
process.
@@ -425,6 +435,9 @@ post-signature processing failures produce `failing`; a later valid successful d
- Public base URL validation requires a canonical HTTPS origin with no credentials, query, or
fragment and rejects loopback, private, link-local, or non-globally-routable DNS results. Kandev
does not fetch the supplied URL as validation.
- Repository identity accepts credential-free HTTPS and SSH remote forms. It rejects an SSH
password, query, or fragment before canonicalization, and an error never contains the rejected
secret-bearing remote.
- App private keys, client secrets, webhook secrets, personal tokens, and live installation tokens
never enter executor environments. Only brokered PAT/CLI tokens or repository-restricted
installation tokens reach a managed trusted child operation; explicit executor-profile
@@ -676,6 +689,20 @@ registration and never creates a global default.
credential policy is selected, **THEN** Kandev leaves its configured `origin` unchanged.
- **GIVEN** managed mode and an explicit executor-profile GitHub token, **WHEN** a task launches,
**THEN** the profile token wins and the session disclosure labels its actor runtime-selected.
- **GIVEN** managed mode and an explicit executor-profile `GITHUB_TOKEN` or `GH_TOKEN`, **WHEN** an
attached repository has incomplete managed identity, **THEN** the profile token remains the
effective source and managed broker admission does not reject the task.
- **GIVEN** a managed repository remote with an SSH password, query, or fragment, **WHEN** Kandev
resolves its credential identity, **THEN** resolution fails without exposing the remote and no
task, workflow, or session state changes.
- **GIVEN** a provider-backed repository whose only usable identity is a local checkout origin,
**WHEN** a managed task launches or the broker authorizes a request, **THEN** both paths reject
the incomplete persisted identity before session state changes.
- **GIVEN** task Git credential policy resolution fails, **WHEN** a normal or Office task launches
or resumes, **THEN** Kandev returns the error and does not create, rebind, or resume a session.
- **GIVEN** a workflow transition selects an agent profile that cannot use managed credentials,
**WHEN** the source step exits, **THEN** Kandev keeps the source step and current session and does
not route the destination step prompt.
- **GIVEN** a managed helper cannot execute or redeem its lease, **WHEN** Git requests GitHub HTTPS
credentials, **THEN** the command fails without falling through to a personal helper or prompt.
- **GIVEN** a broker-enabled managed task whose login profile replaces its inherited `PATH`,
@@ -766,6 +793,8 @@ and the
[system-service identity guardrails repair plan](../../plans/system-service-identity-guardrails/plan.md).
The new-workspace default repair is tracked in
[the new workspace GitHub access defaults plan](../../plans/new-workspace-github-access-defaults/plan.md).
Managed credential admission and repository identity corrections are tracked in
[the managed Git credential admission repair plan](../../plans/managed-git-credential-admission-repair/plan.md).
## Decision