Files
chenhg5-cc-connect/daemon/manager_test.go
Shuchao Shao c53f5450ee 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>
2026-06-11 00:14:23 +08:00

261 lines
8.0 KiB
Go

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)
}
}