feat(daemon): harden service-file env capture + add EnvDiscoverer plugin hook (#1034)

* feat(daemon): harden service-file writes and capture config.toml ${ENV}

Three independent improvements to the service-file install path so
operators can store API keys / tokens in config.toml using ${ENV}
placeholders and have them work with the installed daemon, without
those values being world-readable on disk.

1. captureConfigEnvPlaceholders: during `daemon install`, scan the
   target config.toml for ${VAR_NAME} placeholders. For every match
   that is set in the current process environment, copy the value
   into the EnvExtra map so the rendered launchd plist / systemd unit
   / Windows task script carries it. Without this, the daemon
   process starts with empty strings for placeholder values and
   fails to authenticate to any platform.

2. Tighten service-file permissions to 0600. Both the WriteFile mode
   and an explicit os.Chmod after write — the Chmod is required for
   the reinstall path because WriteFile only applies perm on create,
   so a 0644 file left by a previous cc-connect version would keep
   its old permissions in place. Applies to launchd plist, systemd
   unit, and Windows .ps1 script (the latter as a defense-in-depth
   layer; real Windows access control lives in the ACL).

3. EnvExtra hardening in every renderer:
   - drop entries whose key fails POSIX-identifier validation
   - drop entries whose value is empty
   - launchd: XML-escape both keys and values; reserve PATH /
     CC_LOG_FILE / CC_LOG_MAX_SIZE so EnvExtra can't override the
     template-owned keys
   - systemd: backslash- and quote-escape values for the
     `Environment="K=V"` form per systemd.exec(5)

Add --no-capture-secrets / CC_DAEMON_NO_CAPTURE_SECRETS=1 opt-out for
operators who'd rather inject secrets via keychain, secret-tool, or
systemd EnvironmentFile= and keep the service file token-free.

runSystemctl becomes a var so per-platform tests can stub it without
needing a real systemctl on the host.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(daemon): add EnvDiscoverer plugin hook for install-time env capture

Generalises captureDaemonEnv() with a small plugin registry:

    type EnvDiscoverer func() (map[string]string, error)
    daemon.RegisterEnvDiscoverer(d)
    daemon.ResetEnvDiscoverers()    // tests only

Resolve() invokes every registered discoverer (unless NoCaptureSecrets
is set) and merges the result into EnvExtra after the proxy-key capture
and config.toml ${ENV} placeholder scan. Discoverers run in
registration order; the returned map is filtered for valid POSIX env
names and non-empty values before reaching any renderer.

Discoverer errors are logged at WARN level and never fail install —
matching the rest of the install path's tolerance posture.

Use case: lets a downstream / plugin agent contribute extra env vars
the service file should carry, without daemon needing to know about
that agent. Without the hook, the only ways to extend capture are (a)
pre-populate EnvExtra in the caller — verbose at every install site —
or (b) edit daemon/manager.go to import the new package — which couples
daemon to that package and breaks the no-agent-deps boundary.

The isValidEnvName helper moves out of manager.go into the new
env_extension.go alongside the registry, since the validator is shared
between the renderers and the discoverer-merge code.

Comment tweaks in launchd.go / systemd.go acknowledge that discoverer
output is another source of captured secrets justifying the 0600 perms.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(daemon): add non-linux linger check stub

---------

Co-authored-by: Shuchao Shao <shaoshch@yonyou.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Shuchao Shao
2026-06-11 00:14:23 +08:00
committed by GitHub
parent e4c9e8e148
commit c53f5450ee
12 changed files with 1062 additions and 29 deletions

View File

@@ -121,11 +121,20 @@ func parseDaemonInstallArgs(args []string) (daemon.Config, bool, error) {
var cfg daemon.Config
var force bool
// Env-based opt-out: CC_DAEMON_NO_CAPTURE_SECRETS=1 / true / yes / on
// triggers --no-capture-secrets without the CLI flag, for CI / container
// scenarios where the global env is the right configuration surface.
if isTruthyEnv(os.Getenv("CC_DAEMON_NO_CAPTURE_SECRETS")) {
cfg.NoCaptureSecrets = true
}
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--force":
force = true
case arg == "--no-capture-secrets":
cfg.NoCaptureSecrets = true
case arg == "--log-file":
value, next, err := daemonInstallFlagValue(args, i, "--log-file")
if err != nil {
@@ -189,6 +198,16 @@ func daemonInstallFlagValue(args []string, index int, flagName string) (string,
return args[next], next, nil
}
// isTruthyEnv accepts the conventional opt-in values for boolean env vars.
// Anything else, including "0" / "false" / "" / unset, is treated as false.
func isTruthyEnv(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
}
return false
}
// ── uninstall ───────────────────────────────────────────────
func daemonUninstall() {
@@ -431,6 +450,9 @@ Install flags:
--log-max-size N Max log file size in MB (default: 10)
--work-dir DIR Directory containing config.toml (default: current dir)
--force Overwrite existing installation
--no-capture-secrets Do not capture config.toml ${ENV} placeholders into
the service file. Also enabled by setting
CC_DAEMON_NO_CAPTURE_SECRETS=1 in the environment.
Restart flags:
--force Kill existing process before restarting

View File

@@ -1,6 +1,7 @@
package main
import (
"os"
"path/filepath"
"testing"
)
@@ -32,6 +33,73 @@ func TestParseDaemonInstallArgs_ConfigEqualsFormSetsWorkDir(t *testing.T) {
}
}
func TestParseDaemonInstallArgs_NoCaptureSecretsFlag(t *testing.T) {
os.Unsetenv("CC_DAEMON_NO_CAPTURE_SECRETS")
cfg, _, err := parseDaemonInstallArgs([]string{"--no-capture-secrets"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if !cfg.NoCaptureSecrets {
t.Fatal("flag should set NoCaptureSecrets=true")
}
cfg2, _, err := parseDaemonInstallArgs(nil)
if err != nil {
t.Fatalf("parse: %v", err)
}
if cfg2.NoCaptureSecrets {
t.Fatal("default must be false when flag and env are unset")
}
}
func TestParseDaemonInstallArgs_NoCaptureSecretsEnv(t *testing.T) {
for _, v := range []string{"1", "true", "TRUE", "yes", "on"} {
t.Run("truthy="+v, func(t *testing.T) {
t.Setenv("CC_DAEMON_NO_CAPTURE_SECRETS", v)
cfg, _, err := parseDaemonInstallArgs(nil)
if err != nil {
t.Fatalf("parse: %v", err)
}
if !cfg.NoCaptureSecrets {
t.Fatalf("env=%q should opt out", v)
}
})
}
for _, v := range []string{"0", "false", "", "no", "off"} {
t.Run("falsy="+v, func(t *testing.T) {
t.Setenv("CC_DAEMON_NO_CAPTURE_SECRETS", v)
cfg, _, err := parseDaemonInstallArgs(nil)
if err != nil {
t.Fatalf("parse: %v", err)
}
if cfg.NoCaptureSecrets {
t.Fatalf("env=%q should NOT opt out", v)
}
})
}
}
func TestParseDaemonInstallArgs_NoCaptureSecretsFlagAndEnvCombine(t *testing.T) {
// OR semantics: env=truthy + flag=present → still true.
t.Setenv("CC_DAEMON_NO_CAPTURE_SECRETS", "1")
cfg, _, err := parseDaemonInstallArgs([]string{"--no-capture-secrets", "--force"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if !cfg.NoCaptureSecrets {
t.Fatal("flag+env both should leave NoCaptureSecrets=true")
}
// env=truthy without flag → still true.
cfg2, _, err := parseDaemonInstallArgs([]string{"--force"})
if err != nil {
t.Fatalf("parse: %v", err)
}
if !cfg2.NoCaptureSecrets {
t.Fatal("env=1 alone should opt out")
}
}
func TestParseDaemonInstallArgs_WorkDirOverridesConfig(t *testing.T) {
cfg, force, err := parseDaemonInstallArgs([]string{
"--config", "/tmp/example/config.toml",

65
daemon/env_extension.go Normal file
View File

@@ -0,0 +1,65 @@
package daemon
import (
"regexp"
"sync"
)
// EnvDiscoverer is an install-time hook that returns env-var
// name/value pairs to bake into the daemon service file's EnvExtra.
// Discoverers run once per install, only when NoCaptureSecrets=false.
//
// Implementations must read values from os.LookupEnv themselves —
// daemon does not look them up on the discoverer's behalf. The
// returned map keys are env names; values that are empty or whose
// names fail POSIX-identifier validation are dropped by daemon as a
// belt-and-suspenders.
//
// A non-nil error is logged at WARN level and does NOT fail install.
type EnvDiscoverer func() (map[string]string, error)
var (
envDiscoverersMu sync.RWMutex
envDiscoverers []EnvDiscoverer
)
// RegisterEnvDiscoverer adds d to the list of install-time env-var
// discoverers. Typically called from an init() in a plugin file.
// Passing nil is a no-op.
//
// Discoverers run in registration order during Resolve(); later
// discoverers override earlier ones for keys that collide (map merge).
func RegisterEnvDiscoverer(d EnvDiscoverer) {
if d == nil {
return
}
envDiscoverersMu.Lock()
defer envDiscoverersMu.Unlock()
envDiscoverers = append(envDiscoverers, d)
}
// ResetEnvDiscoverers clears the registry. Intended for tests only.
func ResetEnvDiscoverers() {
envDiscoverersMu.Lock()
defer envDiscoverersMu.Unlock()
envDiscoverers = nil
}
func snapshotEnvDiscoverers() []EnvDiscoverer {
envDiscoverersMu.RLock()
defer envDiscoverersMu.RUnlock()
out := make([]EnvDiscoverer, len(envDiscoverers))
copy(out, envDiscoverers)
return out
}
var envNameRegexp = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// isValidEnvName reports whether s is a syntactically valid env-var
// name. Used by every renderer (launchd / systemd / windows) so that
// malformed keys from discoverers or callers cannot leak into a
// service file where they would either fail to parse or, worse,
// inject syntax.
func isValidEnvName(s string) bool {
return envNameRegexp.MatchString(s)
}

View File

@@ -5,9 +5,11 @@ package daemon
import (
"encoding/xml"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -52,9 +54,18 @@ func (m *launchdManager) Install(cfg Config) error {
bootoutLaunchdTargets()
plist := buildPlist(cfg)
if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil {
// 0600: plist may contain captured secret values (config.toml ${ENV}
// placeholders and any EnvDiscoverer extension output). User-only
// LaunchAgents path; root can still read but that is the user's own
// machine boundary. os.WriteFile only applies perm on create, so
// Chmod afterwards is required to harden reinstalls of files that
// pre-existed at 0644 from earlier cc-connect versions.
if err := os.WriteFile(plistPath, []byte(plist), 0600); err != nil {
return fmt.Errorf("write plist: %w", err)
}
if err := os.Chmod(plistPath, 0600); err != nil {
return fmt.Errorf("chmod plist: %w", err)
}
domain := preferredLaunchdDomain()
if out, err := runLaunchctl("bootstrap", domain, plistPath); err != nil {
@@ -240,16 +251,62 @@ func bootoutLaunchdTargets() {
}
}
// templateOwnedEnvKeys are keys the plist template renders directly; if
// they also appear in cfg.EnvExtra the template version wins.
var templateOwnedEnvKeys = map[string]struct{}{
"CC_LOG_FILE": {},
"CC_LOG_MAX_SIZE": {},
"PATH": {},
}
// renderEnvExtraPlist returns the serialized key/value pairs (without the
// surrounding <dict> wrapper) for cfg.EnvExtra, sorted by key and with
// invalid keys / empty values dropped. Both keys and values are XML-escaped.
func renderEnvExtraPlist(envExtra map[string]string) string {
if len(envExtra) == 0 {
return ""
}
keys := make([]string, 0, len(envExtra))
for k := range envExtra {
if _, owned := templateOwnedEnvKeys[k]; owned {
continue
}
if !isValidEnvName(k) {
slog.Warn("daemon: launchd: dropping invalid env name from EnvExtra",
"key", k)
continue
}
if envExtra[k] == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
fmt.Fprintf(&b, "\t\t<key>%s</key>\n\t\t<string>%s</string>\n",
xmlEscape(k), xmlEscape(envExtra[k]))
}
return b.String()
}
func xmlEscape(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
func buildPlist(cfg Config) string {
envPATH := cfg.EnvPATH
if envPATH == "" {
envPATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
}
envExtra := renderEnvExtraPlist(cfg.EnvExtra)
// 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
// label is a hard-coded constant so it does not need escaping; LogMaxSize
// is an int.
// label is a hard-coded constant; LogMaxSize is an int; envExtra is
// escaped by renderEnvExtraPlist.
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -282,26 +339,13 @@ func buildPlist(cfg Config) string {
<string>%d</string>
<key>PATH</key>
<string>%s</string>
</dict>
%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, xmlEscape(envPATH))
}
// xmlEscape escapes the five XML-reserved characters in a string so it can
// be safely embedded inside a plist <string> element.
func xmlEscape(s string) string {
var b strings.Builder
if err := xml.EscapeText(&b, []byte(s)); err != nil {
// xml.EscapeText only fails when the underlying writer fails; a
// strings.Builder never returns a write error. Fall back to the
// raw value defensively rather than panicking.
return s
}
return b.String()
`, launchdLabel, xmlEscape(cfg.BinaryPath), xmlEscape(cfg.WorkDir), xmlEscape(cfg.LogFile), cfg.LogMaxSize, xmlEscape(envPATH), envExtra)
}

View File

@@ -279,7 +279,7 @@ func containsCall(calls []string, want string) bool {
}
// TestBuildPlist_EscapesXMLSpecialCharsInPaths pins the bug where unescaped
// '&', '<', '>', '"', and '\'' in cfg paths produced malformed XML that
// '&', '<', '>', quotes, and apostrophes in cfg paths produced malformed XML that
// `launchctl bootstrap` rejected.
func TestBuildPlist_EscapesXMLSpecialCharsInPaths(t *testing.T) {
cfg := Config{
@@ -334,3 +334,181 @@ func TestBuildPlist_EscapesXMLSpecialCharsInPaths(t *testing.T) {
}
}
}
func TestBuildPlist_IncludesEnvExtraSorted(t *testing.T) {
cfg := Config{
BinaryPath: "/opt/cc/cc",
WorkDir: "/tmp/wd",
LogFile: "/tmp/log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
EnvExtra: map[string]string{
"NO_PROXY": "example.com",
"HTTPS_PROXY": "http://1.2.3.4:8080",
"CUSTOM_TOKEN": "tok",
},
}
xml := buildPlist(cfg)
wantOrder := []string{
"<key>CUSTOM_TOKEN</key>",
"<key>HTTPS_PROXY</key>",
"<key>NO_PROXY</key>",
}
lastIdx := -1
for _, k := range wantOrder {
idx := strings.Index(xml, k)
if idx < 0 {
t.Fatalf("plist missing %s; xml=%s", k, xml)
}
if idx < lastIdx {
t.Fatalf("plist EnvExtra keys not in sorted order; first offender %s; xml=%s", k, xml)
}
lastIdx = idx
}
if !strings.Contains(xml, "<string>tok</string>") {
t.Fatalf("value missing for CUSTOM_TOKEN; xml=%s", xml)
}
}
func TestBuildPlist_RejectsInvalidEnvName(t *testing.T) {
cfg := Config{
BinaryPath: "/x", WorkDir: "/y", LogFile: "/l", LogMaxSize: 1, EnvPATH: "/p",
EnvExtra: map[string]string{"FOO BAR": "v", "1FOO": "v", "OK": "fine"},
}
xml := buildPlist(cfg)
if strings.Contains(xml, "FOO BAR") || strings.Contains(xml, "1FOO") {
t.Fatalf("invalid env names leaked into plist: %s", xml)
}
if !strings.Contains(xml, "<key>OK</key>") {
t.Fatalf("OK should remain: %s", xml)
}
}
func TestBuildPlist_EscapesXMLInValue(t *testing.T) {
cfg := Config{
BinaryPath: "/x", WorkDir: "/y", LogFile: "/l", LogMaxSize: 1, EnvPATH: "/p",
EnvExtra: map[string]string{"TRICKY": `a<b&c"d'e`},
}
xml := buildPlist(cfg)
idx := strings.Index(xml, "<key>TRICKY</key>")
if idx < 0 {
t.Fatalf("TRICKY missing: %s", xml)
}
tail := xml[idx:]
endStr := strings.Index(tail, "</string>")
if endStr < 0 {
t.Fatalf("malformed plist: %s", xml)
}
chunk := tail[:endStr]
for _, bad := range []string{"a<b", "b&c"} {
if strings.Contains(chunk, bad) {
t.Errorf("value not escaped: %s", chunk)
}
}
if !strings.Contains(chunk, "&lt;") {
t.Errorf("< not escaped: %s", chunk)
}
if !strings.Contains(chunk, "&amp;") {
t.Errorf("& not escaped: %s", chunk)
}
}
func TestBuildPlist_SkipsEmptyValuesAndTemplateOwnedKeys(t *testing.T) {
cfg := Config{
BinaryPath: "/x", WorkDir: "/y", LogFile: "/l", LogMaxSize: 1, EnvPATH: "/expected-path",
EnvExtra: map[string]string{
"EMPTY": "",
"PATH": "/should-not-override",
"CC_LOG_FILE": "/should-not-override",
"CC_LOG_MAX_SIZE": "999999",
"REAL": "ok",
},
}
xml := buildPlist(cfg)
if strings.Contains(xml, "<key>EMPTY</key>") {
t.Errorf("empty value should be skipped: %s", xml)
}
if strings.Contains(xml, "/should-not-override") {
t.Errorf("template-owned key was overridden: %s", xml)
}
if !strings.Contains(xml, "<string>/expected-path</string>") {
t.Errorf("expected template PATH preserved: %s", xml)
}
if !strings.Contains(xml, "<key>REAL</key>") {
t.Errorf("REAL key missing: %s", xml)
}
}
func TestInstallLaunchd_WritesPlistAt0600(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
orig := runLaunchctl
t.Cleanup(func() { runLaunchctl = orig })
runLaunchctl = func(args ...string) (string, error) { return "", nil }
mgr := &launchdManager{}
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: t.TempDir(),
LogFile: filepath.Join(t.TempDir(), "cc.log"),
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
EnvExtra: map[string]string{"NO_PROXY": "example.com"},
}
if err := mgr.Install(cfg); err != nil {
t.Fatalf("Install: %v", err)
}
info, err := os.Stat(launchdPlistPath())
if err != nil {
t.Fatalf("stat plist: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("plist mode = %o, want 0600", info.Mode().Perm())
}
}
// TestInstallLaunchd_TightensExistingPlistFrom0644 covers the upgrade
// path: a user from an earlier cc-connect version may already have a
// 0644 plist on disk; os.WriteFile would truncate-in-place and *keep*
// the old permissions, leaving captured token values world-readable.
// Install must explicitly tighten the existing file to 0600.
func TestInstallLaunchd_TightensExistingPlistFrom0644(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
orig := runLaunchctl
t.Cleanup(func() { runLaunchctl = orig })
runLaunchctl = func(args ...string) (string, error) { return "", nil }
plistPath := launchdPlistPath()
if err := os.MkdirAll(filepath.Dir(plistPath), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(plistPath, []byte("<plist>old</plist>\n"), 0o644); err != nil {
t.Fatalf("seed legacy plist: %v", err)
}
if info, _ := os.Stat(plistPath); info.Mode().Perm() != 0o644 {
t.Fatalf("precondition: seeded file mode = %o, want 0644", info.Mode().Perm())
}
mgr := &launchdManager{}
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: t.TempDir(),
LogFile: filepath.Join(t.TempDir(), "cc.log"),
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
EnvExtra: map[string]string{"CUSTOM_TOKEN": "captured"},
}
if err := mgr.Install(cfg); err != nil {
t.Fatalf("Install: %v", err)
}
info, err := os.Stat(plistPath)
if err != nil {
t.Fatalf("stat after Install: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("plist mode after reinstall = %o, want 0600", info.Mode().Perm())
}
}

8
daemon/linger_other.go Normal file
View File

@@ -0,0 +1,8 @@
//go:build !linux
package daemon
// CheckLinger is only meaningful for user-level systemd services on Linux.
func CheckLinger() (enabled bool, user string) {
return true, ""
}

View File

@@ -3,9 +3,15 @@ package daemon
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"time"
"github.com/BurntSushi/toml"
)
const (
@@ -20,6 +26,13 @@ type Config struct {
LogMaxSize int64
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
// RegisterEnvDiscoverer. Operators who'd rather inject secrets via
// keychain / `secret-tool` / EnvironmentFile= set this to keep token
// values out of the service manager files on disk.
NoCaptureSecrets bool
}
type Status struct {
@@ -130,22 +143,124 @@ func Resolve(cfg *Config) error {
cfg.EnvPATH = os.Getenv("PATH")
}
if len(cfg.EnvExtra) == 0 {
cfg.EnvExtra = captureDaemonEnv()
cfg.EnvExtra = captureDaemonEnv(cfg.NoCaptureSecrets)
if !cfg.NoCaptureSecrets {
captureConfigEnvPlaceholders(filepath.Join(cfg.WorkDir, "config.toml"), cfg.EnvExtra)
}
}
return nil
}
func captureDaemonEnv() map[string]string {
keys := []string{
// captureDaemonEnv builds the EnvExtra map baked into the installed
// service file. Proxy-related vars are always captured. When
// noCaptureSecrets is false, every registered EnvDiscoverer is also
// invoked and its (envName -> value) pairs are merged in.
//
// Discoverer errors are logged but never fail the install — the
// daemon's job is to install the service; plugins surface their own
// per-feature warnings at runtime.
func captureDaemonEnv(noCaptureSecrets bool) map[string]string {
env := make(map[string]string)
proxyKeys := []string{
"http_proxy", "https_proxy", "no_proxy",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"all_proxy", "ALL_PROXY",
}
env := make(map[string]string, len(keys))
for _, key := range keys {
for _, key := range proxyKeys {
if value := os.Getenv(key); value != "" {
env[key] = value
}
}
if noCaptureSecrets {
return env
}
for i, d := range snapshotEnvDiscoverers() {
extra, err := d()
if err != nil {
slog.Warn("daemon: env discoverer reported warnings",
"index", i, "err", err)
}
for k, v := range extra {
if !isValidEnvName(k) {
slog.Warn("daemon: dropping invalid env name from discoverer",
"index", i, "key", k)
continue
}
if v == "" {
continue
}
env[k] = v
}
}
return env
}
var configEnvPlaceholderPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// captureConfigEnvPlaceholders scans configPath for ${ENV_NAME} placeholders
// and, for each one set in the current process environment, copies it into
// env. cc-connect resolves these placeholders at startup using os.ExpandEnv;
// if the daemon's service file doesn't carry the values, the started daemon
// process will see empty strings and fail to authenticate to any platform.
//
// Errors are logged and swallowed: a broken or missing config.toml must not
// abort `daemon install`. Empty / unset env names are skipped silently.
func captureConfigEnvPlaceholders(configPath string, env map[string]string) {
if strings.TrimSpace(configPath) == "" || env == nil {
return
}
data, err := os.ReadFile(configPath)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("daemon: config env placeholder discovery failed",
"path", configPath, "err", err)
}
return
}
var raw map[string]any
if err := toml.Unmarshal(data, &raw); err != nil {
slog.Warn("daemon: config env placeholder discovery failed",
"path", configPath, "err", err)
return
}
captureConfigEnvPlaceholdersInValue(reflect.ValueOf(raw), env)
}
func captureConfigEnvPlaceholdersInValue(v reflect.Value, env map[string]string) {
if !v.IsValid() {
return
}
switch v.Kind() {
case reflect.Interface, reflect.Pointer:
if !v.IsNil() {
captureConfigEnvPlaceholdersInValue(v.Elem(), env)
}
case reflect.String:
captureConfigEnvPlaceholdersInString(v.String(), env)
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
captureConfigEnvPlaceholdersInValue(v.Index(i), env)
}
case reflect.Map:
iter := v.MapRange()
for iter.Next() {
captureConfigEnvPlaceholdersInValue(iter.Value(), env)
}
}
}
func captureConfigEnvPlaceholdersInString(s string, env map[string]string) {
matches := configEnvPlaceholderPattern.FindAllStringSubmatch(s, -1)
for _, match := range matches {
if len(match) != 2 {
continue
}
name := match[1]
if v, ok := os.LookupEnv(name); ok && v != "" {
env[name] = v
}
}
}

260
daemon/manager_test.go Normal file
View File

@@ -0,0 +1,260 @@
package daemon
import (
"bytes"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
func captureSlog(t *testing.T) (get func() string, restore func()) {
t.Helper()
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
return func() string { return buf.String() }, func() { slog.SetDefault(prev) }
}
// withDiscoverer registers d for the duration of the test and resets
// the registry on cleanup so tests cannot pollute each other.
func withDiscoverer(t *testing.T, d EnvDiscoverer) {
t.Helper()
ResetEnvDiscoverers()
RegisterEnvDiscoverer(d)
t.Cleanup(ResetEnvDiscoverers)
}
func TestCaptureDaemonEnv_IncludesDiscoveredVars(t *testing.T) {
t.Setenv("CAPTURE_TEST_TOK", "shhh")
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:10818")
withDiscoverer(t, func() (map[string]string, error) {
return map[string]string{"CAPTURE_TEST_TOK": os.Getenv("CAPTURE_TEST_TOK")}, nil
})
got := captureDaemonEnv(false)
if got["CAPTURE_TEST_TOK"] != "shhh" {
t.Errorf("CAPTURE_TEST_TOK = %q, want %q", got["CAPTURE_TEST_TOK"], "shhh")
}
if got["HTTPS_PROXY"] != "http://127.0.0.1:10818" {
t.Errorf("HTTPS_PROXY missing or wrong: %q", got["HTTPS_PROXY"])
}
}
func TestCaptureDaemonEnv_SkipsDiscoverersWhenNoCapture(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:10818")
withDiscoverer(t, func() (map[string]string, error) {
return map[string]string{"CAPTURE_TEST_TOK2": "do-not-capture-me"}, nil
})
got := captureDaemonEnv(true)
if _, ok := got["CAPTURE_TEST_TOK2"]; ok {
t.Errorf("CAPTURE_TEST_TOK2 must not be captured under NoCaptureSecrets")
}
if got["HTTPS_PROXY"] != "http://127.0.0.1:10818" {
t.Errorf("HTTPS_PROXY should still be captured: %q", got["HTTPS_PROXY"])
}
}
func TestCaptureDaemonEnv_DropsEmptyDiscoveredValues(t *testing.T) {
withDiscoverer(t, func() (map[string]string, error) {
return map[string]string{"CAPTURE_TEST_EMPTY": ""}, nil
})
got := captureDaemonEnv(false)
if _, ok := got["CAPTURE_TEST_EMPTY"]; ok {
t.Error("discoverer returned empty value; must not appear in captured map")
}
}
func TestCaptureDaemonEnv_LogsDiscovererErrorButContinues(t *testing.T) {
getLogs, restore := captureSlog(t)
defer restore()
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:10818")
withDiscoverer(t, func() (map[string]string, error) {
return nil, fmt.Errorf("simulated discovery failure")
})
got := captureDaemonEnv(false)
if got["HTTPS_PROXY"] != "http://127.0.0.1:10818" {
t.Errorf("HTTPS_PROXY missing despite discoverer error: %q", got["HTTPS_PROXY"])
}
if !strings.Contains(getLogs(), "env discoverer reported warnings") {
t.Errorf("expected warning log, got: %s", getLogs())
}
}
func TestCaptureDaemonEnv_DropsInvalidEnvName(t *testing.T) {
getLogs, restore := captureSlog(t)
defer restore()
t.Setenv("CAPTURE_TEST_OK", "ok")
withDiscoverer(t, func() (map[string]string, error) {
return map[string]string{
"BAD NAME": "v",
"CAPTURE_TEST_OK": os.Getenv("CAPTURE_TEST_OK"),
}, nil
})
got := captureDaemonEnv(false)
if got["CAPTURE_TEST_OK"] != "ok" {
t.Errorf("valid name missing: %+v", got)
}
if _, ok := got["BAD NAME"]; ok {
t.Errorf("invalid name must not appear: %+v", got)
}
if !strings.Contains(getLogs(), "dropping invalid env name from discoverer") {
t.Errorf("expected warn about invalid env name; got: %s", getLogs())
}
}
func TestResolve_PropagatesNoCaptureSecretsToDiscoverers(t *testing.T) {
t.Setenv("RESOLVE_TOK", "v")
t.Setenv("HTTPS_PROXY", "http://1.2.3.4:8080")
withDiscoverer(t, func() (map[string]string, error) {
return map[string]string{"RESOLVE_TOK": os.Getenv("RESOLVE_TOK")}, nil
})
// NoCaptureSecrets=true → discoverer skipped.
cfg := Config{NoCaptureSecrets: true, BinaryPath: "/bin/true", WorkDir: t.TempDir()}
if err := Resolve(&cfg); err != nil {
t.Fatalf("Resolve: %v", err)
}
if _, ok := cfg.EnvExtra["RESOLVE_TOK"]; ok {
t.Errorf("Resolve must skip discoverer under NoCaptureSecrets; EnvExtra=%+v", cfg.EnvExtra)
}
if cfg.EnvExtra["HTTPS_PROXY"] == "" {
t.Errorf("Resolve must still capture proxy vars; EnvExtra=%+v", cfg.EnvExtra)
}
// NoCaptureSecrets=false → discoverer runs.
cfg2 := Config{NoCaptureSecrets: false, BinaryPath: "/bin/true", WorkDir: t.TempDir()}
if err := Resolve(&cfg2); err != nil {
t.Fatalf("Resolve: %v", err)
}
if cfg2.EnvExtra["RESOLVE_TOK"] != "v" {
t.Errorf("Resolve must capture discoverer output when NoCaptureSecrets=false; EnvExtra=%+v", cfg2.EnvExtra)
}
}
func TestResolveCapturesConfigEnvPlaceholders(t *testing.T) {
workDir := t.TempDir()
if err := os.WriteFile(filepath.Join(workDir, "config.toml"), []byte(`
[[projects]]
name = "youzone"
[[projects.platforms]]
type = "youzone"
[projects.platforms.options]
access_token = "${CAPTURE_CONFIG_YOUZONE}"
tenant_id = "tenant"
robot_id = "robot"
[[projects]]
name = "feishu"
[[projects.platforms]]
type = "feishu"
[projects.platforms.options]
app_id = "${CAPTURE_CONFIG_FEISHU_APP_ID}"
app_secret = "${CAPTURE_CONFIG_FEISHU_SECRET}"
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("CAPTURE_CONFIG_YOUZONE", "youzone-token")
t.Setenv("CAPTURE_CONFIG_FEISHU_APP_ID", "cli_test")
t.Setenv("CAPTURE_CONFIG_FEISHU_SECRET", "feishu-secret")
cfg := Config{BinaryPath: "/bin/true", WorkDir: workDir}
if err := Resolve(&cfg); err != nil {
t.Fatalf("Resolve: %v", err)
}
if cfg.EnvExtra["CAPTURE_CONFIG_YOUZONE"] != "youzone-token" {
t.Errorf("CAPTURE_CONFIG_YOUZONE not captured; EnvExtra=%+v", cfg.EnvExtra)
}
if cfg.EnvExtra["CAPTURE_CONFIG_FEISHU_APP_ID"] != "cli_test" {
t.Errorf("CAPTURE_CONFIG_FEISHU_APP_ID not captured; EnvExtra=%+v", cfg.EnvExtra)
}
if cfg.EnvExtra["CAPTURE_CONFIG_FEISHU_SECRET"] != "feishu-secret" {
t.Errorf("CAPTURE_CONFIG_FEISHU_SECRET not captured; EnvExtra=%+v", cfg.EnvExtra)
}
}
func TestResolveNoCaptureSecretsSkipsConfigEnvPlaceholders(t *testing.T) {
workDir := t.TempDir()
if err := os.WriteFile(filepath.Join(workDir, "config.toml"), []byte(`
[[projects]]
name = "youzone"
[[projects.platforms]]
type = "youzone"
[projects.platforms.options]
access_token = "${CAPTURE_CONFIG_SKIP}"
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("CAPTURE_CONFIG_SKIP", "secret")
t.Setenv("HTTPS_PROXY", "http://1.2.3.4:8080")
cfg := Config{
NoCaptureSecrets: true,
BinaryPath: "/bin/true",
WorkDir: workDir,
}
if err := Resolve(&cfg); err != nil {
t.Fatalf("Resolve: %v", err)
}
if _, ok := cfg.EnvExtra["CAPTURE_CONFIG_SKIP"]; ok {
t.Errorf("config placeholder must not be captured under NoCaptureSecrets; EnvExtra=%+v", cfg.EnvExtra)
}
if cfg.EnvExtra["HTTPS_PROXY"] != "http://1.2.3.4:8080" {
t.Errorf("proxy should still be captured under NoCaptureSecrets; EnvExtra=%+v", cfg.EnvExtra)
}
}
func TestResolveIgnoresCommentedConfigEnvPlaceholders(t *testing.T) {
workDir := t.TempDir()
if err := os.WriteFile(filepath.Join(workDir, "config.toml"), []byte(`
# access_token = "${CAPTURE_CONFIG_COMMENTED}"
[log]
level = "info"
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("CAPTURE_CONFIG_COMMENTED", "secret")
cfg := Config{BinaryPath: "/bin/true", WorkDir: workDir}
if err := Resolve(&cfg); err != nil {
t.Fatalf("Resolve: %v", err)
}
if _, ok := cfg.EnvExtra["CAPTURE_CONFIG_COMMENTED"]; ok {
t.Errorf("commented config placeholder must not be captured; EnvExtra=%+v", cfg.EnvExtra)
}
}
func TestResolveSkipsUnsetEnvPlaceholders(t *testing.T) {
workDir := t.TempDir()
if err := os.WriteFile(filepath.Join(workDir, "config.toml"), []byte(`
[[projects.platforms.options]]
access_token = "${UNSET_PLACEHOLDER_THAT_DOES_NOT_EXIST}"
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
os.Unsetenv("UNSET_PLACEHOLDER_THAT_DOES_NOT_EXIST")
cfg := Config{BinaryPath: "/bin/true", WorkDir: workDir}
if err := Resolve(&cfg); err != nil {
t.Fatalf("Resolve: %v", err)
}
if _, ok := cfg.EnvExtra["UNSET_PLACEHOLDER_THAT_DOES_NOT_EXIST"]; ok {
t.Errorf("unset placeholder must not appear in EnvExtra; EnvExtra=%+v", cfg.EnvExtra)
}
}

View File

@@ -59,9 +59,19 @@ func (m *systemdManager) Install(cfg Config) error {
}
unit := m.buildUnit(cfg)
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
// 0600: unit file may contain captured secret values (config.toml ${ENV}
// placeholders and any EnvDiscoverer extension output). For system-level
// units (/etc/systemd/system/) the file is owned by root and remains
// readable by root only; for user-level units under
// ~/.config/systemd/user it remains owner-only. WriteFile only applies
// perm on create, so Chmod afterwards is required to harden reinstalls
// of pre-existing 0644 units from earlier cc-connect versions.
if err := os.WriteFile(unitPath, []byte(unit), 0600); err != nil {
return fmt.Errorf("write unit file: %w", err)
}
if err := os.Chmod(unitPath, 0600); err != nil {
return fmt.Errorf("chmod unit file: %w", err)
}
for _, cmdArgs := range [][]string{
m.sysArgs("daemon-reload"),
@@ -184,7 +194,16 @@ func (m *systemdManager) buildUnit(cfg Config) string {
}
sort.Strings(keys)
for _, key := range keys {
fmt.Fprintf(&sb, "Environment=\"%s=%s\"\n", key, cfg.EnvExtra[key])
if !isValidEnvName(key) {
slog.Warn("daemon: systemd: dropping invalid env name from EnvExtra",
"key", key)
continue
}
value := cfg.EnvExtra[key]
if value == "" {
continue
}
fmt.Fprintf(&sb, "Environment=\"%s=%s\"\n", key, escapeSystemdEnvValue(value))
}
}
sb.WriteString("\n[Install]\n")
@@ -196,7 +215,35 @@ func (m *systemdManager) buildUnit(cfg Config) string {
return sb.String()
}
func runSystemctl(args ...string) (string, error) {
// escapeSystemdEnvValue prepares a value for inclusion inside the double
// quotes of an `Environment="KEY=VALUE"` directive. Per systemd.exec(5),
// backslashes and double quotes need escaping; literal newlines and tabs
// must be encoded as `\n` / `\t` so the unit file remains a single line.
func escapeSystemdEnvValue(v string) string {
var b strings.Builder
b.Grow(len(v))
for _, r := range v {
switch r {
case '\\':
b.WriteString(`\\`)
case '"':
b.WriteString(`\"`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
b.WriteRune(r)
}
}
return b.String()
}
// runSystemctl is a var (not a func) so tests can stub it without
// requiring a real systemctl process on the test host.
var runSystemctl = func(args ...string) (string, error) {
cmd := exec.Command("systemctl", args...)
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err

138
daemon/systemd_test.go Normal file
View File

@@ -0,0 +1,138 @@
//go:build linux
package daemon
import (
"os"
"strings"
"testing"
)
func TestEscapeSystemdEnvValue(t *testing.T) {
cases := []struct {
in, want string
}{
{`plain`, `plain`},
{`quote " here`, `quote \" here`},
{`back\slash`, `back\\slash`},
{"new\nline", `new\nline`},
{"tab\there", `tab\there`},
{"return\rback", `return\rback`},
}
for _, c := range cases {
if got := escapeSystemdEnvValue(c.in); got != c.want {
t.Errorf("escapeSystemdEnvValue(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestBuildUnit_EscapesEnvValue(t *testing.T) {
mgr := &systemdManager{system: false}
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: "/tmp",
LogFile: "/tmp/log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
EnvExtra: map[string]string{"TRICKY": `a"b\c`},
}
out := mgr.buildUnit(cfg)
if !strings.Contains(out, `Environment="TRICKY=a\"b\\c"`) {
t.Errorf("expected escaped Environment line; got:\n%s", out)
}
}
func TestBuildUnit_DropsInvalidEnvName(t *testing.T) {
mgr := &systemdManager{system: false}
cfg := Config{
BinaryPath: "/x", WorkDir: "/y", LogFile: "/l", LogMaxSize: 1, EnvPATH: "/p",
EnvExtra: map[string]string{"FOO BAR": "v", "OK": "fine"},
}
out := mgr.buildUnit(cfg)
if strings.Contains(out, "FOO BAR") {
t.Errorf("invalid env name leaked: %s", out)
}
if !strings.Contains(out, `Environment="OK=fine"`) {
t.Errorf("OK missing: %s", out)
}
}
func TestBuildUnit_DropsEmptyValue(t *testing.T) {
mgr := &systemdManager{system: false}
cfg := Config{
BinaryPath: "/x", WorkDir: "/y", LogFile: "/l", LogMaxSize: 1, EnvPATH: "/p",
EnvExtra: map[string]string{"EMPTY": "", "OK": "ok"},
}
out := mgr.buildUnit(cfg)
if strings.Contains(out, `Environment="EMPTY=`) {
t.Errorf("empty value should be skipped: %s", out)
}
if !strings.Contains(out, `Environment="OK=ok"`) {
t.Errorf("OK missing: %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
// versions, leaving captured token values world-readable.
func TestSystemdInstall_TightensExistingUnitFrom0644(t *testing.T) {
t.Setenv("HOME", t.TempDir())
origSys := runSystemctl
t.Cleanup(func() { runSystemctl = origSys })
runSystemctl = func(args ...string) (string, error) { return "", nil }
mgr := &systemdManager{system: false}
unitPath := mgr.unitPath()
if err := os.MkdirAll(unitPath[:strings.LastIndex(unitPath, "/")], 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(unitPath, []byte("[Service]\nExecStart=/bin/true\n"), 0o644); err != nil {
t.Fatalf("seed legacy unit: %v", err)
}
if info, _ := os.Stat(unitPath); info.Mode().Perm() != 0o644 {
t.Fatalf("precondition: seeded file mode = %o, want 0644", info.Mode().Perm())
}
cfg := Config{
BinaryPath: "/bin/true",
WorkDir: t.TempDir(),
LogFile: "/tmp/cc.log",
LogMaxSize: 1024,
EnvPATH: "/usr/bin",
EnvExtra: map[string]string{"CUSTOM_TOKEN": "captured"},
}
if err := mgr.Install(cfg); err != nil {
t.Fatalf("Install: %v", err)
}
info, err := os.Stat(unitPath)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("unit mode after reinstall = %o, want 0600", info.Mode().Perm())
}
}
func TestUnitFileMode_Is0600(t *testing.T) {
mgr := &systemdManager{system: false}
t.Setenv("HOME", t.TempDir())
unitPath := mgr.unitPath()
if err := os.MkdirAll(unitPath[:strings.LastIndex(unitPath, "/")], 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
content := mgr.buildUnit(Config{
BinaryPath: "/bin/true", WorkDir: "/tmp", LogFile: "/tmp/l", LogMaxSize: 1, EnvPATH: "/p",
})
if err := os.WriteFile(unitPath, []byte(content), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
info, err := os.Stat(unitPath)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("mode = %o, want 0600", info.Mode().Perm())
}
}

View File

@@ -48,9 +48,18 @@ func (m *schtasksManager) Install(cfg Config) error {
}
scriptPath := windowsTaskScriptPath()
if err := os.WriteFile(scriptPath, []byte(buildWindowsTaskScript(cfg)), 0644); err != nil {
// 0644 has weak semantics on Windows; the file ACL is what matters.
// We still write 0600 so the file's POSIX bits do not advertise read
// access, and rely on the user's own profile ACLs for primary defense
// (the script lives under %USERPROFILE%\.cc-connect by default).
// WriteFile only applies perm on create, so Chmod the existing file
// after writing to harden reinstalls of pre-existing 0644 scripts.
if err := os.WriteFile(scriptPath, []byte(buildWindowsTaskScript(cfg)), 0600); err != nil {
return fmt.Errorf("write task script: %w", err)
}
if err := os.Chmod(scriptPath, 0600); err != nil {
return fmt.Errorf("chmod task script: %w", err)
}
if err := stopWindowsTask(); err != nil {
slog.Warn("schtasks: stop existing task failed", "error", err)
@@ -182,7 +191,16 @@ func buildWindowsTaskScript(cfg Config) string {
}
sort.Strings(keys)
for _, key := range keys {
writePowerShellEnv(&sb, key, cfg.EnvExtra[key])
if !isValidEnvName(key) {
slog.Warn("daemon: windows: dropping invalid env name from EnvExtra",
"key", key)
continue
}
value := cfg.EnvExtra[key]
if value == "" {
continue
}
writePowerShellEnv(&sb, key, value)
}
}
fmt.Fprintf(&sb, "Set-Location -LiteralPath %s\r\n", powerShellLiteral(cfg.WorkDir))

View File

@@ -3,6 +3,7 @@
package daemon
import (
"os"
"strings"
"testing"
)
@@ -123,3 +124,72 @@ func TestPowerShellLiteralEscapesSingleQuotes(t *testing.T) {
t.Fatalf("powerShellLiteral() = %q, want %q", got, want)
}
}
func TestBuildWindowsTaskScript_DropsInvalidEnvName(t *testing.T) {
cfg := Config{
BinaryPath: "x", WorkDir: "y", LogFile: "l", LogMaxSize: 1, EnvPATH: "p",
EnvExtra: map[string]string{"FOO BAR": "v", "OK": "ok"},
}
script := buildWindowsTaskScript(cfg)
if strings.Contains(script, "FOO BAR") {
t.Errorf("invalid env name leaked: %s", script)
}
if !strings.Contains(script, "$env:OK = 'ok'") {
t.Errorf("valid env missing: %s", script)
}
}
func TestBuildWindowsTaskScript_DropsEmptyValue(t *testing.T) {
cfg := Config{
BinaryPath: "x", WorkDir: "y", LogFile: "l", LogMaxSize: 1, EnvPATH: "p",
EnvExtra: map[string]string{"EMPTY": "", "OK": "ok"},
}
script := buildWindowsTaskScript(cfg)
if strings.Contains(script, "$env:EMPTY") {
t.Errorf("empty value should be skipped: %s", script)
}
}
// TestSchtasksInstall_TightensExistingScriptFrom0644 covers the upgrade
// path: os.WriteFile would truncate-in-place and keep the old POSIX
// mode of a script left by an earlier cc-connect version. While
// Windows real access is governed by ACLs, the POSIX bits are still
// expected to reflect intent.
func TestSchtasksInstall_TightensExistingScriptFrom0644(t *testing.T) {
t.Setenv("USERPROFILE", t.TempDir())
orig := runPowerShell
t.Cleanup(func() { runPowerShell = orig })
runPowerShell = func(script string) (string, error) { return "", nil }
if err := os.MkdirAll(DefaultDataDir(), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
scriptPath := windowsTaskScriptPath()
if err := os.WriteFile(scriptPath, []byte("$env:OLD = 'leftover'\r\n"), 0o644); err != nil {
t.Fatalf("seed legacy script: %v", err)
}
if info, _ := os.Stat(scriptPath); info.Mode().Perm() != 0o644 {
t.Fatalf("precondition: seeded file mode = %o, want 0644", info.Mode().Perm())
}
mgr := &schtasksManager{}
cfg := Config{
BinaryPath: "C:\\cc.exe",
WorkDir: t.TempDir(),
LogFile: "C:\\cc.log",
LogMaxSize: 1024,
EnvPATH: "C:\\bin",
EnvExtra: map[string]string{"CUSTOM_TOKEN": "captured"},
}
if err := mgr.Install(cfg); err != nil {
t.Fatalf("Install: %v", err)
}
info, err := os.Stat(scriptPath)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("script mode after reinstall = %o, want 0600", info.Mode().Perm())
}
}