209c454d6c
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
183 lines
6.5 KiB
Go
183 lines
6.5 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
|
|
"github.com/kandev/kandev/internal/gitconfigenv"
|
|
"github.com/kandev/kandev/internal/githubauth"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
var ErrProfileSecretUnavailable = errors.New("BLOCKED_PROFILE_SECRET")
|
|
|
|
// mergeAgentProfileEnv fills missing keys in env from the agent profile's
|
|
// env_vars. Existing keys in env (office tokens, executor profile env, etc.)
|
|
// are never overwritten.
|
|
func (m *Manager) mergeAgentProfileEnv(ctx context.Context, profileID string, env map[string]string) error {
|
|
return m.mergeAgentProfileEnvWithPartial(ctx, profileID, env, false)
|
|
}
|
|
|
|
// mergeAgentProfileEnvWithPartial is mergeAgentProfileEnv with the partial-vs-
|
|
// fail-closed choice exposed. The ACP session-launch env builder passes
|
|
// partial=true so a broken secret on one profile var drops that var instead of
|
|
// blanking the agent's whole environment (AC-004.1); every other caller keeps
|
|
// the fail-closed default.
|
|
func (m *Manager) mergeAgentProfileEnvWithPartial(ctx context.Context, profileID string, env map[string]string, partial bool) error {
|
|
if profileID == "" || env == nil || m.profileResolver == nil {
|
|
return nil
|
|
}
|
|
info, err := m.profileResolver.ResolveProfile(ctx, profileID)
|
|
if err != nil || info == nil {
|
|
return err
|
|
}
|
|
return m.mergeAgentProfileEnvFromInfoWithPartial(ctx, info, env, partial)
|
|
}
|
|
|
|
func (m *Manager) mergeAgentProfileEnvFromInfo(ctx context.Context, info *AgentProfileInfo, env map[string]string) error {
|
|
return m.mergeAgentProfileEnvFromInfoWithPartial(ctx, info, env, false)
|
|
}
|
|
|
|
func (m *Manager) mergeAgentProfileEnvFromInfoWithPartial(ctx context.Context, info *AgentProfileInfo, env map[string]string, partial bool) error {
|
|
if info == nil || env == nil || len(info.EnvVars) == 0 {
|
|
return nil
|
|
}
|
|
resolved, err := m.resolveAgentProfileEnvVarsWithPartial(ctx, info.EnvVars, partial)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mergeEnvFillMissing(env, resolved)
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) mergeAgentProfileEnvForExecution(ctx context.Context, execution *AgentExecution, env map[string]string) error {
|
|
if execution == nil {
|
|
return nil
|
|
}
|
|
return m.mergeAgentProfileEnv(ctx, execution.AgentProfileID, env)
|
|
}
|
|
|
|
func mergeEnvFillMissing(dst, src map[string]string) {
|
|
if len(src) == 0 || dst == nil {
|
|
return
|
|
}
|
|
for k, v := range src {
|
|
if v == "" || gitconfigenv.IsIndexedKey(k) {
|
|
continue
|
|
}
|
|
if _, exists := dst[k]; !exists {
|
|
dst[k] = v
|
|
}
|
|
}
|
|
merged, err := gitconfigenv.Merge(src, dst)
|
|
if err == nil {
|
|
gitconfigenv.CopyIndexed(dst, merged)
|
|
}
|
|
}
|
|
|
|
// composeExecutionRuntimeEnvironment updates an existing execution snapshot
|
|
// with a per-run overlay. Host GitHub helpers are Kandev-owned generated
|
|
// entries and are removed before composition so a later request can replace
|
|
// or remove them without hiding inherited user configuration.
|
|
func composeExecutionRuntimeEnvironment(base, overlay map[string]string) (map[string]string, error) {
|
|
removeObsoleteManagedCredentialEnvironment(base)
|
|
filtered, err := gitconfigenv.Filter(base, func(index int, entries []gitconfigenv.Entry) bool {
|
|
return !githubauth.IsHostGitHubCredentialHelperEntry(entries[index].Key, entries[index].Value)
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("remove generated host GitHub helper: %w", err)
|
|
}
|
|
return gitconfigenv.Merge(filtered, overlay)
|
|
}
|
|
|
|
func removeObsoleteManagedCredentialEnvironment(env map[string]string) {
|
|
for _, key := range []string{
|
|
githubauth.CredentialBrokerURLEnv,
|
|
githubauth.CredentialHelperPathEnv,
|
|
githubauth.CredentialCLIShimDirEnv,
|
|
githubauth.CredentialCLIBashEnvEnv,
|
|
githubauth.CredentialParentBashEnv,
|
|
githubauth.CredentialLeaseEnv,
|
|
githubauth.CredentialReissueCapabilityEnv,
|
|
githubauth.CredentialTaskIDEnv,
|
|
githubauth.CredentialSessionIDEnv,
|
|
githubauth.CredentialRepositoryEnv,
|
|
githubauth.CredentialOwnerEnv,
|
|
githubauth.CredentialRepoEnv,
|
|
githubauth.CredentialHostEnv,
|
|
githubauth.CredentialScopesEnv,
|
|
} {
|
|
delete(env, key)
|
|
}
|
|
}
|
|
|
|
// resolveAgentProfileEnvVars resolves profile env entries. SecretID wins over
|
|
// Value. A missing secret store or failed reveal aborts the whole profile
|
|
// environment rather than falling back to a literal value or partial map.
|
|
func (m *Manager) resolveAgentProfileEnvVars(ctx context.Context, envVars []settingsmodels.ProfileEnvVar) (map[string]string, error) {
|
|
return m.resolveAgentProfileEnvVarsWithPartial(ctx, envVars, false)
|
|
}
|
|
|
|
// resolveAgentProfileEnvVarsWithPartial resolves profile env entries. When
|
|
// partial is true a single secret-store miss or failed reveal drops only that
|
|
// entry (with a warn log) and the remaining entries are still delivered — a
|
|
// broken secret reference on one variable must not blank the agent's whole
|
|
// environment at session launch. When partial is false the whole environment is
|
|
// aborted (the passthrough-restart and terminal-env callers rely on that
|
|
// fail-closed behavior). Cancellation errors always propagate.
|
|
func (m *Manager) resolveAgentProfileEnvVarsWithPartial(
|
|
ctx context.Context, envVars []settingsmodels.ProfileEnvVar, partial bool,
|
|
) (map[string]string, error) {
|
|
if len(envVars) == 0 {
|
|
return nil, nil
|
|
}
|
|
resolved := make(map[string]string, len(envVars))
|
|
for _, ev := range envVars {
|
|
key := ev.Key
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if ev.SecretID != "" {
|
|
if m.secretStore == nil {
|
|
if partial {
|
|
m.logger.Warn("agent profile env var references a secret but no secret store is configured; dropping entry",
|
|
zap.String("env_key", key))
|
|
continue
|
|
}
|
|
return nil, profileSecretError(key)
|
|
}
|
|
value, err := m.revealGlobalSecret(ctx, ev.SecretID)
|
|
if err != nil {
|
|
// Preserve caller cancellation identity. The sanitized sentinel is
|
|
// for secret failures only; callers use context errors to stop work
|
|
// without misclassifying a cancelled request as bad configuration.
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return nil, err
|
|
}
|
|
if partial {
|
|
m.logger.Warn("failed to reveal secret for agent profile env var; dropping entry",
|
|
zap.String("env_key", key), zap.Error(err))
|
|
continue
|
|
}
|
|
return nil, profileSecretError(key)
|
|
}
|
|
resolved[key] = value
|
|
continue
|
|
}
|
|
if ev.Value != "" {
|
|
resolved[key] = ev.Value
|
|
}
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func profileSecretError(key string) error {
|
|
return fmt.Errorf("%w: env key %q unavailable", ErrProfileSecretUnavailable, key)
|
|
}
|
|
|
|
func (m *Manager) revealGlobalSecret(ctx context.Context, secretID string) (string, error) {
|
|
return revealGlobalSecret(ctx, m.secretStore, secretID)
|
|
}
|