Skip to content
Open
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
4 changes: 4 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
# Session data storage directory (default: ~/.cc-connect)
# Stores conversation history and session state as JSON files.
# 会话数据存储目录(默认 ~/.cc-connect),以 JSON 文件保存对话历史和会话状态。
# NOTE: Prefer an absolute path when running under systemd / launchd — service
# managers may not propagate HOME, and any relative value would be resolved
# against work_dir, which agent subprocesses cd into before reading files.
# 建议在 systemd/launchd 部署下使用绝对路径,避免 HOME 缺失时相对路径解析到 work_dir。
# data_dir = "/path/to/custom/dir"

# Attachment send-back switch for `cc-connect send --image/--file`.
Expand Down
15 changes: 15 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,9 +631,24 @@ func load(path string) (*Config, error) {
if home, err := os.UserHomeDir(); err == nil {
cfg.DataDir = filepath.Join(home, ".cc-connect")
} else {
// HOME unset (common when systemd unit does not inject it):
// warn loudly, because a relative data_dir will be resolved
// differently by any subprocess that changes cwd — most
// notably the claude/codex/etc. agent processes that cd into
// work_dir before reading files written by the supervisor.
cfg.DataDir = ".cc-connect"
slog.Warn("config: data_dir empty and HOME unset; falling back to relative path. Set data_dir to an absolute path in config.toml, or ensure the service manager injects HOME.")
}
}
cfg.DataDir = expandUserPath(cfg.DataDir)
// Always resolve to an absolute path so a data_dir written by the
// supervisor is read back at the same location by agent subprocesses
// regardless of their working directory. Users who write a relative
// data_dir explicitly still get it anchored to the supervisor's cwd
// (the same behaviour they would see running any other CLI).
if abs, err := filepath.Abs(cfg.DataDir); err == nil {
cfg.DataDir = abs
}
cfg.AttachmentSend = strings.ToLower(strings.TrimSpace(cfg.AttachmentSend))
if cfg.AttachmentSend == "" {
cfg.AttachmentSend = "on"
Expand Down
78 changes: 78 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,84 @@ func TestLoad_DefaultsDataDir(t *testing.T) {
}
}

// TestLoad_DataDirAbsoluteWhenExplicitlyRelative regresses the
// "Append system prompt file not found" bug: even when a user sets
// data_dir to a relative path in config.toml, Load must anchor it to
// an absolute path so that agent subprocesses (which cd into
// project.work_dir before reading system prompt files) do not resolve
// data_dir against the child's working directory instead of the
// supervisor's.
func TestLoad_DataDirAbsoluteWhenExplicitlyRelative(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)

cfgPath := filepath.Join(dir, "config.toml")
body := "data_dir = \"./my-data\"\n" + baseConfigTOML
if err := os.WriteFile(cfgPath, []byte(body), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !filepath.IsAbs(cfg.DataDir) {
t.Fatalf("Load() data_dir = %q, want absolute path", cfg.DataDir)
}
if !strings.HasSuffix(cfg.DataDir, "my-data") {
t.Fatalf("Load() data_dir = %q, want suffix my-data", cfg.DataDir)
}
}

func TestLoad_DataDirExpandsCurrentUserHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

cfgPath := filepath.Join(t.TempDir(), "config.toml")
body := "data_dir = \"~/.cc-connect\"\n" + baseConfigTOML
if err := os.WriteFile(cfgPath, []byte(body), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("Load() error: %v", err)
}

want := filepath.Join(home, ".cc-connect")
if cfg.DataDir != want {
t.Fatalf("Load() data_dir = %q, want %q", cfg.DataDir, want)
}
}

// TestLoad_DataDirAbsoluteWhenHOMEEmpty covers the fallback path when
// os.UserHomeDir fails. This is exercised by service managers like
// launchd (gui/... bootstrap) and systemd (system units) that do not
// propagate HOME. Even in that degraded state, Load must still return
// an absolute data_dir so subprocesses do not silently write to
// <work_dir>/.cc-connect. Windows uses USERPROFILE, which we cannot
// reliably clear from a test.
func TestLoad_DataDirAbsoluteWhenHOMEEmpty(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("HOME does not gate os.UserHomeDir on windows")
}
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.toml")
if err := os.WriteFile(cfgPath, []byte(baseConfigTOML), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

t.Setenv("HOME", "")

cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !filepath.IsAbs(cfg.DataDir) {
t.Fatalf("Load() data_dir = %q, want absolute path even when HOME unset", cfg.DataDir)
}
}

func TestLoad_ResolvesEnvPlaceholders(t *testing.T) {

root := t.TempDir()
Expand Down
17 changes: 14 additions & 3 deletions daemon/launchd.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ var templateOwnedEnvKeys = map[string]struct{}{
"CC_LOG_FILE": {},
"CC_LOG_MAX_SIZE": {},
"PATH": {},
"HOME": {},
}

// renderEnvExtraPlist returns the serialized key/value pairs (without the
Expand Down Expand Up @@ -302,6 +303,17 @@ func buildPlist(cfg Config) string {
envPATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
}
envExtra := renderEnvExtraPlist(cfg.EnvExtra)
// HOME: launchd LaunchAgents run under the user's uid, but the HOME
// env var is not always populated (especially when bootstrapped via
// `launchctl bootstrap gui/...`). Without HOME, os.UserHomeDir()
// returns an error inside the daemon and any subprocess it spawns;
// config.Load then falls back to a relative data_dir which agents
// (cd'd into work_dir) resolve to <work_dir>/.cc-connect instead of
// <HOME>/.cc-connect.
homeEntry := ""
if cfg.HomeDir != "" {
homeEntry = fmt.Sprintf("\t\t<key>HOME</key>\n\t\t<string>%s</string>\n", xmlEscape(cfg.HomeDir))
}
// User-supplied paths can legitimately contain XML-special characters
// ('&', '<', '>', '"', '\''). Without escaping, `launchctl bootstrap`
// rejects the plist with a parse error and daemon install fails. The
Expand Down Expand Up @@ -341,13 +353,12 @@ func buildPlist(cfg Config) string {
<string>%d</string>
<key>PATH</key>
<string>%s</string>
%s </dict>
%s%s </dict>
<key>StandardOutPath</key>
<string>/dev/null</string>
<key>StandardErrorPath</key>
<string>/dev/null</string>
</dict>
</plist>
`, launchdLabel, xmlEscape(cfg.BinaryPath), xmlEscape(cfg.WorkDir), xmlEscape(cfg.LogFile), cfg.LogMaxSize, cfg.LogMaxBackups, xmlEscape(envPATH), envExtra)
`, launchdLabel, xmlEscape(cfg.BinaryPath), xmlEscape(cfg.WorkDir), xmlEscape(cfg.LogFile), cfg.LogMaxSize, cfg.LogMaxBackups, xmlEscape(envPATH), homeEntry, envExtra)
}

58 changes: 58 additions & 0 deletions daemon/launchd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,61 @@ func TestInstallLaunchd_TightensExistingPlistFrom0644(t *testing.T) {
t.Errorf("plist mode after reinstall = %o, want 0600", info.Mode().Perm())
}
}

// TestBuildPlist_IncludesHOME regresses the "Append system prompt file not
// found" bug: launchd LaunchAgents do not always propagate HOME, so the
// daemon (and Claude / Codex / etc. subprocesses it spawns) would call
// os.UserHomeDir(), get an error, and config.Load would fall back to a
// relative data_dir that agent subprocesses resolved against work_dir.
func TestBuildPlist_IncludesHOME(t *testing.T) {
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: "/tmp/wd",
LogFile: "/tmp/log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
HomeDir: "/home/app",
}
out := buildPlist(cfg)
if !strings.Contains(out, "<key>HOME</key>\n\t\t<string>/home/app</string>") {
t.Fatalf("plist should include HOME=/home/app; got:\n%s", out)
}
}

// TestBuildPlist_OmitsHOMEWhenEmpty ensures we do not emit an empty
// <string></string> for HOME when the caller could not determine one.
func TestBuildPlist_OmitsHOMEWhenEmpty(t *testing.T) {
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: "/tmp/wd",
LogFile: "/tmp/log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
}
out := buildPlist(cfg)
if strings.Contains(out, "<key>HOME</key>") {
t.Fatalf("plist should omit HOME when HomeDir empty; got:\n%s", out)
}
}

// TestBuildPlist_EnvExtraHOMEDoesNotOverrideTemplateHOME pins that the
// template-owned HOME wins over any HOME leaking in through EnvExtra —
// the same guarantee we make for PATH and CC_LOG_FILE.
func TestBuildPlist_EnvExtraHOMEDoesNotOverrideTemplateHOME(t *testing.T) {
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: "/tmp/wd",
LogFile: "/tmp/log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
HomeDir: "/home/app",
EnvExtra: map[string]string{"HOME": "/should-not-override"},
}
out := buildPlist(cfg)
if strings.Contains(out, "/should-not-override") {
t.Fatalf("EnvExtra HOME leaked past template ownership: %s", out)
}
if !strings.Contains(out, "<string>/home/app</string>") {
t.Fatalf("expected template HOME /home/app to survive; got:\n%s", out)
}
}
37 changes: 23 additions & 14 deletions daemon/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ const (
)

type Config struct {
BinaryPath string
WorkDir string
LogFile string
LogMaxSize int64
LogMaxBackups int
EnvPATH string // capture user's PATH so agents are accessible
EnvExtra map[string]string // selected environment variables needed by the service runtime
BinaryPath string
WorkDir string
HomeDir string // captured HOME so subprocesses can resolve ~/… paths (issue: relative data_dir fallback)
LogFile string
LogMaxSize int64
LogMaxBackups int
EnvPATH string // capture user's PATH so agents are accessible
EnvExtra map[string]string // selected environment variables needed by the service runtime
// NoCaptureSecrets, when true, restricts the install-time env capture
// to proxy-related variables only and skips both the config.toml ${ENV}
// placeholder scan and any extension discoverers registered via
Expand Down Expand Up @@ -74,12 +75,12 @@ func DefaultDataDir() string {
// etc. can locate the log file without parsing service definitions.

type Meta struct {
LogFile string `json:"log_file"`
LogMaxSize int64 `json:"log_max_size"`
LogMaxBackups int `json:"log_max_backups"`
WorkDir string `json:"work_dir"`
BinaryPath string `json:"binary_path"`
InstalledAt string `json:"installed_at"`
LogFile string `json:"log_file"`
LogMaxSize int64 `json:"log_max_size"`
LogMaxBackups int `json:"log_max_backups"`
WorkDir string `json:"work_dir"`
BinaryPath string `json:"binary_path"`
InstalledAt string `json:"installed_at"`
}

func metaPath() string {
Expand Down Expand Up @@ -148,6 +149,15 @@ func Resolve(cfg *Config) error {
if cfg.EnvPATH == "" {
cfg.EnvPATH = os.Getenv("PATH")
}
if cfg.HomeDir == "" {
// os.UserHomeDir uses HOME on Unix and USERPROFILE on Windows.
// A non-empty result gets baked into the service unit so the
// daemon (and any subprocess it spawns) sees the correct home
// even when systemd/launchd would otherwise leave HOME unset.
if home, err := os.UserHomeDir(); err == nil {
cfg.HomeDir = home
}
}
if len(cfg.EnvExtra) == 0 {
cfg.EnvExtra = captureDaemonEnv(cfg.NoCaptureSecrets)
if !cfg.NoCaptureSecrets {
Expand Down Expand Up @@ -269,4 +279,3 @@ func captureConfigEnvPlaceholdersInString(s string, env map[string]string) {
}
}
}

21 changes: 21 additions & 0 deletions daemon/systemd.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ const (
systemdServiceName = ServiceName + ".service"
)

// systemdTemplateOwnedEnvKeys are keys the unit template renders directly; if
// they also appear in cfg.EnvExtra the template version wins.
var systemdTemplateOwnedEnvKeys = map[string]struct{}{
"CC_LOG_FILE": {},
"CC_LOG_MAX_SIZE": {},
"CC_LOG_MAX_BACKUPS": {},
"PATH": {},
"HOME": {},
}

type systemdManager struct {
system bool // true = system-level (/etc/systemd/system), false = user-level (~/.config/systemd/user)
}
Expand Down Expand Up @@ -188,13 +198,24 @@ func (m *systemdManager) buildUnit(cfg Config) string {
if cfg.EnvPATH != "" {
fmt.Fprintf(&sb, "Environment=\"PATH=%s\"\n", cfg.EnvPATH)
}
// HOME: systemd does not propagate HOME to system units by default.
// Without this line, os.UserHomeDir() fails in the daemon and any
// subprocess it spawns; config.Load then falls back to a relative
// data_dir which agents (cd'd into work_dir) resolve to the wrong
// place, e.g. <work_dir>/.cc-connect instead of <HOME>/.cc-connect.
if cfg.HomeDir != "" {
fmt.Fprintf(&sb, "Environment=\"HOME=%s\"\n", escapeSystemdEnvValue(cfg.HomeDir))
}
if len(cfg.EnvExtra) > 0 {
keys := make([]string, 0, len(cfg.EnvExtra))
for key := range cfg.EnvExtra {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if _, owned := systemdTemplateOwnedEnvKeys[key]; owned {
continue
}
if !isValidEnvName(key) {
slog.Warn("daemon: systemd: dropping invalid env name from EnvExtra",
"key", key)
Expand Down
60 changes: 60 additions & 0 deletions daemon/systemd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,66 @@ func TestBuildUnit_DropsEmptyValue(t *testing.T) {
}
}

// TestBuildUnit_IncludesHOME regresses the "Append system prompt file not
// found" bug: systemd does not inherit HOME from the user's shell for
// system units, so the daemon (and any subprocess it spawns) would fail
// os.UserHomeDir() and config.Load would fall back to a relative
// data_dir that resolves against work_dir instead of the user home.
// The install-time capture of HomeDir is now baked into the unit as an
// Environment= line to guarantee the daemon sees the correct home.
func TestBuildUnit_IncludesHOME(t *testing.T) {
mgr := &systemdManager{system: false}
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: "/tmp",
LogFile: "/tmp/log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
HomeDir: "/home/app",
}
out := mgr.buildUnit(cfg)
if !strings.Contains(out, `Environment="HOME=/home/app"`) {
t.Fatalf("unit should include HOME=/home/app when HomeDir is set; got:\n%s", out)
}
}

// TestBuildUnit_OmitsHOMEWhenEmpty ensures the unit does not emit an
// empty HOME= line when the caller could not determine one.
func TestBuildUnit_OmitsHOMEWhenEmpty(t *testing.T) {
mgr := &systemdManager{system: false}
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: "/tmp",
LogFile: "/tmp/log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
}
out := mgr.buildUnit(cfg)
if strings.Contains(out, `Environment="HOME=`) {
t.Fatalf("unit should omit HOME= when HomeDir empty; got:\n%s", out)
}
}

func TestBuildUnit_EnvExtraHOMEDoesNotOverrideTemplateHOME(t *testing.T) {
mgr := &systemdManager{system: false}
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: "/tmp",
LogFile: "/tmp/log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
HomeDir: "/home/app",
EnvExtra: map[string]string{"HOME": "/should-not-override"},
}
out := mgr.buildUnit(cfg)
if strings.Contains(out, "/should-not-override") {
t.Fatalf("EnvExtra HOME leaked past template ownership: %s", out)
}
if !strings.Contains(out, `Environment="HOME=/home/app"`) {
t.Fatalf("expected template HOME /home/app to survive; got:\n%s", out)
}
}

// TestSystemdInstall_TightensExistingUnitFrom0644 covers the upgrade
// path: os.WriteFile would truncate-in-place and KEEP the old 0644
// permissions of a unit file left over from earlier cc-connect
Expand Down
Loading
Loading