Files
kandev/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go
T
2026-09-05 20:32:25 +00:00

228 lines
8.0 KiB
Go

package lifecycle
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/remoteauth"
"github.com/kandev/kandev/internal/common/logger"
)
// agentSessionsRootSubdir is the subdirectory under the kandev home where
// per-container agent session dirs live. e.g. ~/.kandev/agent-sessions/.
const agentSessionsRootSubdir = "agent-sessions"
// authMethodTypeFiles selects RemoteAuthMethod entries that copy local files
// into the remote environment, while authMethodTypeEnv covers env-var-based
// auth (with an optional SetupScript that runs on the remote to provision
// credential files from the env var).
const (
authMethodTypeFiles = "files"
authMethodTypeEnv = "env"
)
// AgentSessionsRoot returns the directory under the kandev home where
// per-container session dirs are created (e.g. ~/.kandev/agent-sessions/).
func AgentSessionsRoot(kandevHomeDir string) string {
if kandevHomeDir == "" {
return ""
}
return filepath.Join(kandevHomeDir, agentSessionsRootSubdir)
}
// InstanceSessionRoot returns the per-container parent dir that mimics the
// agent's home (e.g. ~/.kandev/agent-sessions/<instance_id>/). Files seeded
// from the host land under this path keyed by their RemoteAuth.TargetRelDir,
// matching the tree the bind-mount target expects inside the container.
func InstanceSessionRoot(kandevHomeDir, instanceID string) string {
if kandevHomeDir == "" || instanceID == "" {
return ""
}
return filepath.Join(AgentSessionsRoot(kandevHomeDir), instanceID)
}
// SessionDirHostPath returns the kandev-managed bind-mount source for one
// agent in one container. It mirrors the structure SessionDirTarget expects
// inside the container: e.g. SessionDirTemplate "{home}/.codex" + target
// "/root/.codex" → host path "<kandev-home>/agent-sessions/<instanceID>/
// .codex". The leaf dotdir is preserved so the tree on the host matches the
// tree the agent expects, which means UploadCredentialFiles + the
// inside-container view of the bind mount are byte-identical.
func SessionDirHostPath(kandevHomeDir, instanceID, sessionDirTemplate string) string {
if sessionDirTemplate == "" {
return ""
}
root := InstanceSessionRoot(kandevHomeDir, instanceID)
if root == "" {
return ""
}
rel := strings.TrimPrefix(sessionDirTemplate, "{home}/")
if sessionDirTemplate == "{home}" {
rel = "."
}
return filepath.Join(root, rel)
}
// localFileUploader implements FileUploader for the host filesystem. Used by
// the docker session seeder to copy auth files from ~/<src> into the
// kandev-managed per-container session dir. The uploader is bound to a
// session root and refuses any write that resolves outside it: instanceID
// flows into that root from internal callers, but the containment check
// keeps a malformed input from escaping into ~/.ssh, /etc, etc.
type localFileUploader struct {
root string
}
func (u localFileUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error {
return writeFileWithinRoot(u.root, path, data, mode)
}
// containedPath returns a cleaned absolute path guaranteed to be inside root,
// or an error if the path escapes via traversal or absolute-path injection.
// Used as the sanitiser between caller-supplied paths and host writes.
func containedPath(root, path string) (string, error) {
if root == "" {
return "", fmt.Errorf("session root is empty")
}
cleanRoot := filepath.Clean(root)
cleanPath := filepath.Clean(path)
rel, err := filepath.Rel(cleanRoot, cleanPath)
if err != nil {
return "", fmt.Errorf("path %q outside session root %q: %w", path, root, err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path %q escapes session root %q", path, root)
}
return cleanPath, nil
}
// SeedAgentSessionDir copies the agent's RemoteAuth.SourceFiles from the host
// home into a per-container session dir. Optional configuration bundles are
// copied only when selectedIDs is provided. Stale per-host state — codex's
// state.db with absolute macOS paths, sessions/ subdirectories, lock files —
// is intentionally NOT copied; the agent rebuilds them inside the container
// with container-local Linux paths.
//
// instanceSessionRoot is the container's session root (e.g. <kandev-home>/
// agent-sessions/<container_id>). The function selects only "files"-type
// remote-auth methods that match the host OS.
func SeedAgentSessionDir(
ctx context.Context,
ag agents.Agent,
instanceSessionRoot string,
log *logger.Logger,
selectedIDs ...[]string,
) error {
var selected []string
if len(selectedIDs) > 0 {
selected = selectedIDs[0]
}
return seedAgentSessionDir(ctx, ag, instanceSessionRoot, log, selected, nil)
}
func seedAgentSessionDir(
ctx context.Context,
ag agents.Agent,
instanceSessionRoot string,
log *logger.Logger,
selectedIDs []string,
onWarnings func([]PortableConfigWarning),
) error {
if ag == nil || instanceSessionRoot == "" {
return nil
}
// Always materialise the per-instance root so the container manager has a
// stable bind-mount source even for agents whose auth is env-only (claude,
// gemini, …) and the dotdir is otherwise empty until the in-container
// SetupScript fills it.
if err := os.MkdirAll(instanceSessionRoot, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", instanceSessionRoot, err)
}
authErr := seedAgentAuthFiles(ctx, ag, instanceSessionRoot, log)
if authErr != nil && log != nil {
log.Warn("failed to seed agent authentication files; continuing with configuration bundles", zap.Error(authErr))
}
if len(selectedIDs) > 0 {
warnings := UploadPortableConfigBundles(
ctx, localFileUploader{root: instanceSessionRoot}, ag, selectedIDs, instanceSessionRoot, log,
)
if onWarnings != nil {
onWarnings(warnings)
}
}
return authErr
}
func seedAgentAuthFiles(ctx context.Context, ag agents.Agent, instanceSessionRoot string, log *logger.Logger) error {
auth := ag.RemoteAuth()
if auth == nil {
return nil
}
methods := authMethodsForHost(auth.Methods, runtime.GOOS)
if len(methods) == 0 {
return nil
}
return UploadCredentialFiles(ctx, localFileUploader{root: instanceSessionRoot}, methods, instanceSessionRoot, log)
}
func authMethodsForHost(methods []agents.RemoteAuthMethod, hostOS string) []remoteauth.Method {
selected := make([]remoteauth.Method, 0, len(methods))
for _, method := range methods {
if method.Type != authMethodTypeFiles || len(method.SourceFiles) == 0 {
continue
}
// Resolve the OS-specific source list into a flat one keyed by host
// OS, falling back to "linux" for cross-platform agents.
sources := selectAuthSourceFiles(method, hostOS)
if len(sources) == 0 {
continue
}
selected = append(selected, remoteauth.Method{
Type: method.Type,
SourceFiles: sources,
TargetRelDir: method.TargetRelDir,
FileConflictPolicy: method.FileConflictPolicy,
})
}
return selected
}
// selectAuthSourceFiles picks the SourceFiles list to use for the host OS,
// degrading gracefully to whatever the agent ships for "linux" or generic if
// the host-specific entry is absent. The returned slice is the relative paths
// (e.g. ".codex/auth.json") expected by UploadCredentialFiles.
func selectAuthSourceFiles(m agents.RemoteAuthMethod, hostOS string) []string {
if v, ok := m.SourceFiles[hostOS]; ok {
return v
}
if v, ok := m.SourceFiles["linux"]; ok {
return v
}
if v, ok := m.SourceFiles[""]; ok {
return v
}
return nil
}
// CleanupAgentSessionDir removes the per-container session root from disk.
// Best-effort: a missing directory or a transient I/O error is logged at
// debug level and not propagated, since cleanup runs after teardown. Called
// only on destructive stops (task/session deleted/archived).
func CleanupAgentSessionDir(instanceSessionRoot string, log *logger.Logger) {
if instanceSessionRoot == "" {
return
}
if err := os.RemoveAll(instanceSessionRoot); err != nil {
log.Debug("failed to remove per-container agent session dir",
zap.String("path", instanceSessionRoot), zap.Error(err))
}
}