Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/root/acp.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func newACPCmd() *cobra.Command {
RunE: flags.runACPCommand,
}

cmd.Flags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetHomeDir(), ".cagent", "session.db"), "Path to the session database")
cmd.Flags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetDataDir(), "session.db"), "Path to the session database")
addRuntimeConfigFlags(cmd, &flags.runConfig)

return cmd
Expand Down
8 changes: 4 additions & 4 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,10 @@ func NewRootCmd() *cobra.Command {
// Add persistent debug flag available to all commands
cmd.PersistentFlags().BoolVarP(&flags.debugMode, "debug", "d", false, "Enable debug logging")
cmd.PersistentFlags().BoolVarP(&flags.enableOtel, "otel", "o", false, "Enable OpenTelemetry tracing")
cmd.PersistentFlags().StringVar(&flags.logFilePath, "log-file", "", "Path to debug log file (default: ~/.cagent/cagent.debug.log; only used with --debug)")
cmd.PersistentFlags().StringVar(&flags.cacheDir, "cache-dir", "", "Override the cache directory (default: ~/Library/Caches/cagent on macOS)")
cmd.PersistentFlags().StringVar(&flags.configDir, "config-dir", "", "Override the config directory (default: ~/.config/cagent)")
cmd.PersistentFlags().StringVar(&flags.dataDir, "data-dir", "", "Override the data directory (default: ~/.cagent)")
cmd.PersistentFlags().StringVar(&flags.logFilePath, "log-file", "", "Path to debug log file (default: <data-dir>/cagent.debug.log; only used with --debug)")
cmd.PersistentFlags().StringVar(&flags.cacheDir, "cache-dir", "", "Override the cache directory")
cmd.PersistentFlags().StringVar(&flags.configDir, "config-dir", "", "Override the config directory")
cmd.PersistentFlags().StringVar(&flags.dataDir, "data-dir", "", "Override the data directory")

cmd.AddCommand(newVersionCmd())
cmd.AddCommand(newRunCmd())
Expand Down
2 changes: 1 addition & 1 deletion cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
cmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Initialize the agent without executing anything")
cmd.PersistentFlags().StringVar(&flags.remoteAddress, "remote", "", "Use remote runtime with specified address")
cmd.PersistentFlags().BoolVar(&flags.connectRPC, "connect-rpc", false, "Use Connect-RPC protocol for remote communication (requires --remote)")
cmd.PersistentFlags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetHomeDir(), ".cagent", "session.db"), "Path to the session database")
cmd.PersistentFlags().StringVarP(&flags.sessionDB, "session-db", "s", filepath.Join(paths.GetDataDir(), "session.db"), "Path to the session database")
cmd.PersistentFlags().StringVar(&flags.sessionID, "session", "", "Continue from a previous session by ID or relative offset (e.g., -1 for last session)")
cmd.PersistentFlags().StringVar(&flags.fakeResponses, "fake", "", "Replay AI responses from cassette file (for testing)")
cmd.PersistentFlags().IntVar(&flags.fakeStreamDelay, "fake-stream", 0, "Simulate streaming with delay in ms between chunks (default 15ms if no value given)")
Expand Down
9 changes: 3 additions & 6 deletions pkg/content/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
"github.com/google/go-containerregistry/pkg/crane"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/tarball"

"github.com/docker/cagent/pkg/paths"
)

// ErrStoreCorrupted indicates that the local artifact store is in an
Expand Down Expand Up @@ -55,12 +57,7 @@ func NewStore(opts ...Opt) (*Store, error) {
}

if store.baseDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("getting home directory: %w", err)
}

store.baseDir = filepath.Join(homeDir, ".cagent", "store")
store.baseDir = filepath.Join(paths.GetDataDir(), "store")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CONFIRMED BUG - Error Handling Degradation

The new code calls paths.GetDataDir() without error handling. If os.UserHomeDir() fails, GetDataDir() silently falls back to filepath.Join(os.TempDir(), ".cagent"), creating the store under /tmp/.cagent/store.

Impact: On systems where /tmp is cleared on reboot, stored artifacts will be lost, causing unexpected re-downloads. The old code explicitly returned the error, allowing callers to handle the failure appropriately.

Recommendation: Either:

  1. Make GetDataDir() return an error and handle it here, OR
  2. Add explicit validation that the returned path is not under os.TempDir() and fail with a clear error message

Failing fast is better than silently using an ephemeral location for persistent data.

}

if err := os.MkdirAll(store.baseDir, 0o755); err != nil {
Expand Down
8 changes: 3 additions & 5 deletions pkg/gateway/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"strings"
"sync"
"time"

"github.com/docker/cagent/pkg/paths"
)

const (
Expand Down Expand Up @@ -169,11 +171,7 @@ func refreshCatalogFromNetwork() bool {
}

func getCacheFilePath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, ".cagent", catalogCacheFileName)
return filepath.Join(paths.GetCacheDir(), catalogCacheFileName)
}

func loadCatalogFromCache(cacheFile string) (Catalog, time.Duration, error) {
Expand Down
23 changes: 11 additions & 12 deletions pkg/history/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"path/filepath"
"slices"
"strings"

"github.com/docker/cagent/pkg/paths"
)

type History struct {
Expand All @@ -17,14 +19,14 @@ type History struct {
}

type options struct {
homeDir string
dataDir string
}

type Opt func(*options)

func WithBaseDir(dir string) Opt {
return func(o *options) {
o.homeDir = dir
o.dataDir = dir
}
}

Expand All @@ -34,20 +36,17 @@ func New(opts ...Opt) (*History, error) {
opt(o)
}

homeDir := o.homeDir
if homeDir == "" {
var err error
if homeDir, err = os.UserHomeDir(); err != nil {
return nil, err
}
dataDir := o.dataDir
if dataDir == "" {
dataDir = paths.GetDataDir()
}

h := &History{
path: filepath.Join(homeDir, ".cagent", "history"),
path: filepath.Join(dataDir, "history"),
current: -1,
}

if err := h.migrateOldHistory(homeDir); err != nil {
if err := h.migrateOldHistory(dataDir); err != nil {
return nil, err
}

Expand All @@ -58,8 +57,8 @@ func New(opts ...Opt) (*History, error) {
return h, nil
}

func (h *History) migrateOldHistory(homeDir string) error {
oldPath := filepath.Join(homeDir, ".cagent", "history.json")
func (h *History) migrateOldHistory(dataDir string) error {
oldPath := filepath.Join(dataDir, "history.json")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 LIKELY BUG - Migration Path Issue

The migrateOldHistory function looks for history.json at filepath.Join(dataDir, "history.json"), where dataDir comes from paths.GetDataDir().

Problem: If the XDG directory exists but is empty (e.g., created manually or by another component), GetDataDir() returns the XDG path instead of the legacy path. The migration then looks for history.json in the empty XDG directory instead of the legacy ~/.cagent/ directory, failing to find and migrate the user's existing history.

Recommendation: The migration should explicitly check the legacy directory for history.json, regardless of which path GetDataDir() returns:

legacyHistoryPath := filepath.Join(legacyDir(), "history.json")
if _, err := os.Stat(legacyHistoryPath); err == nil {
    // migrate from legacy location
}

This ensures migration always checks the legacy location first.


data, err := os.ReadFile(oldPath)
if os.IsNotExist(err) {
Expand Down
6 changes: 3 additions & 3 deletions pkg/history/history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,10 @@ func TestHistory_MultilineMessage(t *testing.T) {

func TestHistory_MigrateOldFormat(t *testing.T) {
tmpDir := t.TempDir()
err := os.MkdirAll(filepath.Join(tmpDir, ".cagent"), 0o755)
err := os.MkdirAll(tmpDir, 0o755)
require.NoError(t, err)
oldHistFile := filepath.Join(tmpDir, ".cagent", "history.json")
newHistFile := filepath.Join(tmpDir, ".cagent", "history")
oldHistFile := filepath.Join(tmpDir, "history.json")
newHistFile := filepath.Join(tmpDir, "history")

require.NoError(t, os.WriteFile(oldHistFile, []byte(`{"messages":["old1","old2","old3"]}`), 0o644))

Expand Down
9 changes: 3 additions & 6 deletions pkg/modelsdev/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"strings"
"sync"
"time"

"github.com/docker/cagent/pkg/paths"
)

const (
Expand All @@ -31,12 +33,7 @@ type Store struct {
// NewStore creates a new models.dev store.
// The database is loaded on first access via GetDatabase.
func NewStore() (*Store, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get user home directory: %w", err)
}

cacheDir := filepath.Join(homeDir, ".cagent")
cacheDir := paths.GetCacheDir()
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
Expand Down
110 changes: 84 additions & 26 deletions pkg/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,52 +51,53 @@ func SetDataDir(dir string) { dataDirOverride.Set(dir) }
//
// If an override has been set via [SetCacheDir] it is returned instead.
//
// On Linux this follows XDG: $XDG_CACHE_HOME/cagent (default ~/.cache/cagent).
// On macOS this uses ~/Library/Caches/cagent.
// On Windows this uses %LocalAppData%/cagent.
// The default location follows the XDG Base Directory Specification:
// - $XDG_CACHE_HOME/cagent (Linux, default ~/.cache/cagent)
// - ~/Library/Caches/cagent (macOS)
// - %LocalAppData%/cagent (Windows)
//
// If the cache directory cannot be determined, it falls back to a directory
// under the system temporary directory.
// For backward compatibility, if the legacy ~/.cagent directory exists and
// the XDG directory does not, the legacy path is used instead.
func GetCacheDir() string {
return cacheDirOverride.get(func() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
return filepath.Clean(filepath.Join(os.TempDir(), ".cagent-cache"))
}
return filepath.Clean(filepath.Join(cacheDir, "cagent"))
return resolveWithLegacyFallback(xdgCacheDir())
})
}

// GetConfigDir returns the user's config directory for cagent.
//
// If an override has been set via [SetConfigDir] it is returned instead.
//
// If the home directory cannot be determined, it falls back to a directory
// under the system temporary directory. This is a best-effort fallback and
// not intended to be a security boundary.
// The default location is the OS-standard user config directory
// (as returned by [os.UserConfigDir]) with a "cagent" subdirectory:
// - $XDG_CONFIG_HOME/cagent on Linux (default ~/.config/cagent)
// - ~/Library/Application Support/cagent on macOS
// - %AppData%/cagent on Windows
//
// For backward compatibility, if the legacy ~/.cagent directory exists and
// the standard directory does not, the legacy path is used instead.
func GetConfigDir() string {
return configDirOverride.get(func() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return filepath.Clean(filepath.Join(os.TempDir(), ".cagent-config"))
}
return filepath.Clean(filepath.Join(homeDir, ".config", "cagent"))
return resolveWithLegacyFallback(xdgConfigDir())
})
}

// GetDataDir returns the user's data directory for cagent (caches, content, logs).
// GetDataDir returns the user's data directory for cagent (sessions, history,
// installed tools, OCI store, etc.).
//
// If an override has been set via [SetDataDir] it is returned instead.
//
// If the home directory cannot be determined, it falls back to a directory
// under the system temporary directory.
// The default location follows the XDG Base Directory Specification on Linux:
// - $XDG_DATA_HOME/cagent (default ~/.local/share/cagent)
//
// On macOS and Windows the same Linux-style path is used for consistency
// (~/.local/share/cagent), since Go does not provide an os.UserDataDir.
//
// For backward compatibility, if the legacy ~/.cagent directory exists and
// the XDG directory does not, the legacy path is used instead.
func GetDataDir() string {
return dataDirOverride.get(func() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return filepath.Clean(filepath.Join(os.TempDir(), ".cagent"))
}
return filepath.Clean(filepath.Join(homeDir, ".cagent"))
return resolveWithLegacyFallback(xdgDataDir())
})
}

Expand All @@ -110,3 +111,60 @@ func GetHomeDir() string {
}
return filepath.Clean(homeDir)
}

// --- XDG directory helpers ---

func xdgCacheDir() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
return filepath.Join(os.TempDir(), ".cagent-cache")
}
return filepath.Join(cacheDir, "cagent")
}

func xdgConfigDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
return filepath.Join(os.TempDir(), ".cagent-config")
}
return filepath.Join(configDir, "cagent")
}

func xdgDataDir() string {
if dir := os.Getenv("XDG_DATA_HOME"); dir != "" {
return filepath.Join(dir, "cagent")
}
homeDir, err := os.UserHomeDir()
if err != nil {
return filepath.Join(os.TempDir(), ".cagent")
}
return filepath.Join(homeDir, ".local", "share", "cagent")
}

// --- Legacy fallback ---

// resolveWithLegacyFallback returns the legacy ~/.cagent path when it exists
// and xdgDir does not yet exist, preserving data for existing users.
// Otherwise it returns xdgDir.
func resolveWithLegacyFallback(xdgDir string) string {
if legacy := legacyDir(); legacy != "" && dirExists(legacy) && !dirExists(xdgDir) {
return filepath.Clean(legacy)
}
return filepath.Clean(xdgDir)
}

// legacyDir returns the legacy ~/.cagent directory path, or empty string
// if the home directory cannot be determined.
func legacyDir() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, ".cagent")
}

// dirExists reports whether dir exists and is a directory.
func dirExists(dir string) bool {
info, err := os.Stat(dir)
return err == nil && info.IsDir()
}
Loading
Loading